|
|
"""Pure-ANSI terminal UI toolkit — zero external dependencies.
|
||
|
|
|
||
|
|
Provides banner, inline arrow-key selector, confirm, text input, step
|
||
|
|
headers, coloured log helpers, spinner, and countdown. Designed for a
|
||
|
|
minimal / industrial aesthetic inspired by Claude Code.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import getpass
|
||
|
|
import os
|
||
|
|
import shutil
|
||
|
|
import sys
|
||
|
|
import textwrap
|
||
|
|
import time
|
||
|
|
from contextlib import contextmanager
|
||
|
|
from typing import Generator
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Colour helpers
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def _is_color() -> bool:
|
||
|
|
return sys.stdout.isatty() and os.environ.get("TERM", "dumb") != "dumb"
|
||
|
|
|
||
|
|
|
||
|
|
def _wrap(text: str, code: str) -> str:
|
||
|
|
if not _is_color():
|
||
|
|
return text
|
||
|
|
return f"\033[{code}m{text}\033[0m"
|
||
|
|
|
||
|
|
|
||
|
|
def accent(text: str) -> str:
|
||
|
|
return _wrap(text, "38;5;111")
|
||
|
|
|
||
|
|
|
||
|
|
def success(text: str) -> str:
|
||
|
|
return _wrap(text, "38;5;78")
|
||
|
|
|
||
|
|
|
||
|
|
def warn(text: str) -> str:
|
||
|
|
return _wrap(text, "38;5;214")
|
||
|
|
|
||
|
|
|
||
|
|
def error(text: str) -> str:
|
||
|
|
return _wrap(text, "38;5;203")
|
||
|
|
|
||
|
|
|
||
|
|
def dim(text: str) -> str:
|
||
|
|
return _wrap(text, "2")
|
||
|
|
|
||
|
|
|
||
|
|
def bold(text: str) -> str:
|
||
|
|
return _wrap(text, "1")
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Terminal geometry
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def term_width() -> int:
|
||
|
|
return min(max(shutil.get_terminal_size((80, 24)).columns, 60), 100)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Low-level key reading (Unix / Windows)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def _read_key() -> str:
|
||
|
|
"""Read a single keypress. Returns 'up', 'down', 'enter', or char."""
|
||
|
|
try:
|
||
|
|
import tty
|
||
|
|
import termios
|
||
|
|
|
||
|
|
fd = sys.stdin.fileno()
|
||
|
|
old = termios.tcgetattr(fd)
|
||
|
|
try:
|
||
|
|
tty.setraw(fd)
|
||
|
|
ch = sys.stdin.read(1)
|
||
|
|
if ch == "\x1b":
|
||
|
|
seq = sys.stdin.read(2)
|
||
|
|
if seq == "[A":
|
||
|
|
return "up"
|
||
|
|
if seq == "[B":
|
||
|
|
return "down"
|
||
|
|
return "esc"
|
||
|
|
if ch in ("\r", "\n"):
|
||
|
|
return "enter"
|
||
|
|
if ch == "\x03":
|
||
|
|
raise KeyboardInterrupt
|
||
|
|
return ch
|
||
|
|
finally:
|
||
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
||
|
|
except ImportError:
|
||
|
|
# Windows fallback
|
||
|
|
import msvcrt # type: ignore[import-untyped]
|
||
|
|
|
||
|
|
ch = msvcrt.getwch()
|
||
|
|
if ch == "\xe0":
|
||
|
|
code = msvcrt.getwch()
|
||
|
|
if code == "H":
|
||
|
|
return "up"
|
||
|
|
if code == "P":
|
||
|
|
return "down"
|
||
|
|
return "esc"
|
||
|
|
if ch in ("\r", "\n"):
|
||
|
|
return "enter"
|
||
|
|
if ch == "\x03":
|
||
|
|
raise KeyboardInterrupt
|
||
|
|
return ch
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Banner
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def banner(title: str, lines: list[str] | None = None) -> None:
|
||
|
|
w = term_width()
|
||
|
|
inner = w - 4 # usable chars between "│ " and " │"
|
||
|
|
|
||
|
|
print()
|
||
|
|
dashes = max(w - 4 - len(title), 0)
|
||
|
|
print(f"┌ {accent(title)} " + "─" * dashes + "┐")
|
||
|
|
|
||
|
|
if lines:
|
||
|
|
print(f"│{' ' * (w - 2)}│")
|
||
|
|
for line in lines:
|
||
|
|
for wrapped in textwrap.wrap(line, width=inner) or [""]:
|
||
|
|
print(f"│ {dim(wrapped.ljust(inner))} │")
|
||
|
|
print(f"│{' ' * (w - 2)}│")
|
||
|
|
|
||
|
|
print("└" + "─" * (w - 2) + "┘")
|
||
|
|
print()
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Inline arrow-key selector
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def select(prompt: str, options: list[tuple[str, str, str]]) -> str:
|
||
|
|
"""Arrow-key selector. *options* is a list of ``(value, label, desc)``.
|
||
|
|
|
||
|
|
Returns the *value* of the chosen option. Falls back to numbered
|
||
|
|
input when stdin is not a tty.
|
||
|
|
"""
|
||
|
|
if not sys.stdin.isatty():
|
||
|
|
return _select_fallback(prompt, options)
|
||
|
|
|
||
|
|
idx = 0
|
||
|
|
total = len(options)
|
||
|
|
|
||
|
|
print(dim(f" {prompt}"))
|
||
|
|
print()
|
||
|
|
|
||
|
|
def _render() -> None:
|
||
|
|
for i, (_, label, desc) in enumerate(options):
|
||
|
|
if i == idx:
|
||
|
|
marker = accent("> ")
|
||
|
|
text = f"{bold(label)} {dim(desc)}" if desc else bold(label)
|
||
|
|
else:
|
||
|
|
marker = " "
|
||
|
|
text = dim(f"{label} {desc}") if desc else dim(label)
|
||
|
|
print(f" {marker}{text}")
|
||
|
|
|
||
|
|
_render()
|
||
|
|
|
||
|
|
while True:
|
||
|
|
key = _read_key()
|
||
|
|
if key == "up":
|
||
|
|
idx = (idx - 1) % total
|
||
|
|
elif key == "down":
|
||
|
|
idx = (idx + 1) % total
|
||
|
|
elif key == "enter":
|
||
|
|
# Move past the option block
|
||
|
|
sys.stdout.write(f"\033[{total}A")
|
||
|
|
sys.stdout.write(f"\033[J")
|
||
|
|
chosen_label = options[idx][1]
|
||
|
|
chosen_desc = options[idx][2]
|
||
|
|
print(f" {accent('>')} {bold(chosen_label)} {dim(chosen_desc)}")
|
||
|
|
print()
|
||
|
|
return options[idx][0]
|
||
|
|
elif key == "esc":
|
||
|
|
continue
|
||
|
|
else:
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Redraw: move cursor up by `total` lines, clear, re-render
|
||
|
|
sys.stdout.write(f"\033[{total}A")
|
||
|
|
sys.stdout.write(f"\033[J")
|
||
|
|
_render()
|
||
|
|
|
||
|
|
|
||
|
|
def _select_fallback(prompt: str, options: list[tuple[str, str, str]]) -> str:
|
||
|
|
print(f" {prompt}")
|
||
|
|
for i, (_, label, desc) in enumerate(options, 1):
|
||
|
|
print(f" {i}. {label} {desc}")
|
||
|
|
while True:
|
||
|
|
answer = input(" > ").strip()
|
||
|
|
if answer.isdigit() and 1 <= int(answer) <= len(options):
|
||
|
|
return options[int(answer) - 1][0]
|
||
|
|
for value, label, _ in options:
|
||
|
|
if answer in (value, label):
|
||
|
|
return value
|
||
|
|
print(warn(" Please choose a valid option."))
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Confirm (Y/n)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def confirm(prompt: str, default: bool = True) -> bool:
|
||
|
|
hint = "[Y/n]" if default else "[y/N]"
|
||
|
|
while True:
|
||
|
|
answer = input(f" {prompt} {dim(hint)} ").strip().lower()
|
||
|
|
if not answer:
|
||
|
|
return default
|
||
|
|
if answer in ("y", "yes"):
|
||
|
|
return True
|
||
|
|
if answer in ("n", "no"):
|
||
|
|
return False
|
||
|
|
print(warn(" Please answer y or n."))
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Text input
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def text_input(prompt: str, default: str = "", secret: bool = False) -> str:
|
||
|
|
display = f" {prompt}"
|
||
|
|
if default and not secret:
|
||
|
|
display += f" {dim(f'[{default}]')}"
|
||
|
|
display += dim(": ")
|
||
|
|
|
||
|
|
if secret:
|
||
|
|
value = getpass.getpass(display)
|
||
|
|
else:
|
||
|
|
value = input(display).strip()
|
||
|
|
return value or default
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Step header
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def step(current: int, total: int | str, title: str) -> None:
|
||
|
|
print()
|
||
|
|
w = term_width()
|
||
|
|
label = f" {current}/{total} "
|
||
|
|
left = (w - len(label)) // 2
|
||
|
|
right = w - left - len(label)
|
||
|
|
print(dim("─" * left + label + "─" * right))
|
||
|
|
print(f" {bold(title)}")
|
||
|
|
print()
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Log helpers
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def log_info(msg: str) -> None:
|
||
|
|
print(f" {accent('·')} {msg}")
|
||
|
|
|
||
|
|
|
||
|
|
def log_success(msg: str) -> None:
|
||
|
|
print(f" {success('✓')} {msg}")
|
||
|
|
|
||
|
|
|
||
|
|
def log_warn(msg: str) -> None:
|
||
|
|
print(f" {warn('!')} {msg}")
|
||
|
|
|
||
|
|
|
||
|
|
def log_error(msg: str) -> None:
|
||
|
|
print(f" {error('✗')} {msg}")
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Spinner (context manager)
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
_SPINNER_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
||
|
|
|
||
|
|
|
||
|
|
@contextmanager
|
||
|
|
def spinner(label: str) -> Generator[None, None, None]:
|
||
|
|
"""Simple braille-dot spinner shown while the body executes."""
|
||
|
|
import threading
|
||
|
|
|
||
|
|
stop = threading.Event()
|
||
|
|
|
||
|
|
def _spin() -> None:
|
||
|
|
i = 0
|
||
|
|
while not stop.is_set():
|
||
|
|
frame = _SPINNER_FRAMES[i % len(_SPINNER_FRAMES)]
|
||
|
|
sys.stdout.write(f"\r {accent(frame)} {label}")
|
||
|
|
sys.stdout.flush()
|
||
|
|
i += 1
|
||
|
|
stop.wait(0.08)
|
||
|
|
sys.stdout.write("\r" + " " * (len(label) + 6) + "\r")
|
||
|
|
sys.stdout.flush()
|
||
|
|
|
||
|
|
t = threading.Thread(target=_spin, daemon=True)
|
||
|
|
t.start()
|
||
|
|
try:
|
||
|
|
yield
|
||
|
|
finally:
|
||
|
|
stop.set()
|
||
|
|
t.join()
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Countdown
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
def countdown(seconds: int, label: str = "Starting in") -> None:
|
||
|
|
for remaining in range(seconds, 0, -1):
|
||
|
|
sys.stdout.write(f"\r {dim(label)} {accent(str(remaining))}s ")
|
||
|
|
sys.stdout.flush()
|
||
|
|
time.sleep(1)
|
||
|
|
sys.stdout.write("\r" + " " * (len(label) + 12) + "\r")
|
||
|
|
sys.stdout.flush()
|