"""Base Rich/Typer console layer for the specify CLI. This module is the single source of Rich ``Console`` instances and Typer UI helpers used throughout ``specify_cli``. Nothing in this file should import from other ``specify_cli`` sub-modules; all dependencies must flow *into* this layer, not out of it, to avoid circular imports. """ from __future__ import annotations from collections.abc import Callable import readchar import typer from rich.align import Align from rich.console import Console from rich.live import Live from rich.panel import Panel from rich.table import Table from rich.text import Text from rich.tree import Tree from typer.core import TyperGroup BANNER = """ ███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗ ██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝ ███████╗██████╔╝█████╗ ██║ ██║█████╗ ╚████╔╝ ╚════██║██╔═══╝ ██╔══╝ ██║ ██║██╔══╝ ╚██╔╝ ███████║██║ ███████╗╚██████╗██║██║ ██║ ╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝╚═╝ ╚═╝ """ TAGLINE = "GitHub Spec Kit - Spec-Driven Development Toolkit" console = Console(highlight=False) class StepTracker: """Track and render hierarchical steps without emojis, similar to Claude Code tree output. Supports live auto-refresh via an attached refresh callback. """ def __init__(self, title: str): self.title = title self.steps = [] # list of dicts: {key, label, status, detail} self.status_order = {"pending": 0, "running": 1, "done": 2, "error": 3, "skipped": 4} self._refresh_cb: Callable[[], None] | None = None def attach_refresh(self, cb: Callable[[], None]) -> None: self._refresh_cb = cb def add(self, key: str, label: str): if key not in [s["key"] for s in self.steps]: self.steps.append({"key": key, "label": label, "status": "pending", "detail": ""}) self._maybe_refresh() def start(self, key: str, detail: str = ""): self._update(key, status="running", detail=detail) def complete(self, key: str, detail: str = ""): self._update(key, status="done", detail=detail) def error(self, key: str, detail: str = ""): self._update(key, status="error", detail=detail) def skip(self, key: str, detail: str = ""): self._update(key, status="skipped", detail=detail) def _update(self, key: str, status: str, detail: str): for s in self.steps: if s["key"] == key: s["status"] = status if detail: s["detail"] = detail self._maybe_refresh() return self.steps.append({"key": key, "label": key, "status": status, "detail": detail}) self._maybe_refresh() def _maybe_refresh(self): if self._refresh_cb: try: self._refresh_cb() except Exception: pass def render(self): tree = Tree(f"[cyan]{self.title}[/cyan]", guide_style="grey50") for step in self.steps: label = step["label"] detail_text = step["detail"].strip() if step["detail"] else "" status = step["status"] if status == "done": symbol = "[green]●[/green]" elif status == "pending": symbol = "[green dim]○[/green dim]" elif status == "running": symbol = "[cyan]○[/cyan]" elif status == "error": symbol = "[red]●[/red]" elif status == "skipped": symbol = "[yellow]○[/yellow]" else: symbol = " " if status == "pending": # Entire line light gray (pending) if detail_text: line = f"{symbol} [bright_black]{label} ({detail_text})[/bright_black]" else: line = f"{symbol} [bright_black]{label}[/bright_black]" else: # Label white, detail (if any) light gray in parentheses if detail_text: line = f"{symbol} [white]{label}[/white] [bright_black]({detail_text})[/bright_black]" else: line = f"{symbol} [white]{label}[/white]" tree.add(line) return tree def get_key(): """Get a single keypress in a cross-platform way using readchar.""" key = readchar.readkey() if key == readchar.key.UP or key == readchar.key.CTRL_P: return 'up' if key == readchar.key.DOWN or key == readchar.key.CTRL_N: return 'down' if key == readchar.key.ENTER: return 'enter' if key == readchar.key.ESC: return 'escape' if key == readchar.key.CTRL_C: raise KeyboardInterrupt return key def select_with_arrows( options: dict[str, str], prompt_text: str = "Select an option", default_key: str | None = None, ) -> str: """ Interactive selection using arrow keys with Rich Live display. Args: options: Dict with keys as option keys and values as descriptions prompt_text: Text to show above the options default_key: Default option key to start with Returns: Selected option key """ if not options: raise ValueError("select_with_arrows() requires at least one option.") option_keys = list(options.keys()) if default_key and default_key in option_keys: selected_index = option_keys.index(default_key) else: selected_index = 0 selected_key = None def create_selection_panel(): """Create the selection panel with current selection highlighted.""" table = Table.grid(padding=(0, 2)) table.add_column(style="cyan", justify="left", width=3) table.add_column(style="white", justify="left") for i, key in enumerate(option_keys): if i == selected_index: table.add_row("▶", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]") else: table.add_row(" ", f"[cyan]{key}[/cyan] [dim]({options[key]})[/dim]") table.add_row("", "") table.add_row("", "[dim]Use ↑/↓ to navigate, Enter to select, Esc to cancel[/dim]") return Panel( table, title=f"[bold]{prompt_text}[/bold]", border_style="cyan", padding=(1, 2) ) console.print() def run_selection_loop(): nonlocal selected_key, selected_index with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live: while True: try: key = get_key() if key == 'up': selected_index = (selected_index - 1) % len(option_keys) elif key == 'down': selected_index = (selected_index + 1) % len(option_keys) elif key == 'enter': selected_key = option_keys[selected_index] break elif key == 'escape': console.print("\n[yellow]Selection cancelled[/yellow]") raise typer.Exit(code=1) live.update(create_selection_panel(), refresh=True) except KeyboardInterrupt: console.print("\n[yellow]Selection cancelled[/yellow]") raise typer.Exit(code=1) run_selection_loop() if selected_key is None: console.print("\n[red]Selection failed.[/red]") raise typer.Exit(code=1) return selected_key class BannerGroup(TyperGroup): """Custom group that shows banner before help.""" def format_help(self, ctx, formatter): # Show banner before help show_banner() super().format_help(ctx, formatter) def show_banner(): """Display the ASCII art banner.""" banner_lines = BANNER.strip().split('\n') colors = ["bright_blue", "blue", "cyan", "bright_cyan", "white", "bright_white"] styled_banner = Text() for i, line in enumerate(banner_lines): color = colors[i % len(colors)] styled_banner.append(line + "\n", style=color) console.print(Align.center(styled_banner)) console.print(Align.center(Text(TAGLINE, style="italic bright_yellow"))) console.print()