"""Help renderers for the interactive shell slash-command catalog.""" from __future__ import annotations import sys from collections.abc import Sequence from dataclasses import dataclass from typing import Literal from rich.console import Console from rich.markup import escape from rich.table import Table from platform.terminal import theme as ui_theme from surfaces.interactive_shell.command_registry.types import SlashCommand from surfaces.interactive_shell.ui.components.choice_menu import ( erase_menu_lines, menu_columns, read_menu_action, write_menu_line, ) from surfaces.interactive_shell.ui.components.rendering import ( print_repl_table, repl_print, repl_table, ) HelpSection = tuple[str, Sequence[SlashCommand]] _HELP_VIEW_ROWS = 21 _HELP_HINT = "↑↓/j/k navigate · Enter run command · Space toggle details · Esc/q close" @dataclass(frozen=True) class HelpRow: section: str | None = None command: SlashCommand | None = None separator: bool = False @property def selectable(self) -> bool: return self.command is not None @dataclass(frozen=True) class HelpDetailLine: text: str role: Literal["label", "value"] @dataclass(frozen=True) class HelpDisplayRow: source_index: int | None = None section: str | None = None command: SlashCommand | None = None detail: HelpDetailLine | None = None separator: bool = False def render_help_index(console: Console, sections: Sequence[HelpSection]) -> None: """Render the compact non-interactive help index.""" table = repl_table( title="Slash commands", title_style=str(ui_theme.BOLD_BRAND), show_header=False, ) table.add_column("command", no_wrap=True, min_width=18) table.add_column("description", style=str(ui_theme.DIM)) for section_name, commands in sections: if not commands: continue table.add_row(f"[{ui_theme.BOLD_BRAND}]{escape(section_name)}[/]", "") for index, command in enumerate(commands): table.add_row( f" [{ui_theme.HIGHLIGHT}]{escape(command.name)}[/]", escape(command.description), end_section=(index == len(commands) - 1), ) print_repl_table(console, table) repl_print( console, f"[{ui_theme.DIM}]Use[/] [bold]/help [/bold] [{ui_theme.DIM}]for usage.[/]", ) def render_section_detail( console: Console, section_name: str, commands: Sequence[SlashCommand], ) -> None: """Render one category using the same compact description-only style.""" table = repl_table( title=f"{section_name} commands", title_style=str(ui_theme.BOLD_BRAND), show_header=False, ) table.add_column("command", no_wrap=True, min_width=18) table.add_column("description", style=str(ui_theme.DIM)) for command in commands: table.add_row( f"[{ui_theme.HIGHLIGHT}]{escape(command.name)}[/]", escape(command.description), ) print_repl_table(console, table) repl_print( console, f"[{ui_theme.DIM}]Use[/] [bold]/help [/bold] [{ui_theme.DIM}]for usage.[/]", ) def render_command_detail(console: Console, command: SlashCommand) -> None: """Render detailed help for one slash command.""" table = Table( title=command.name, title_style=str(ui_theme.BOLD_BRAND), show_header=False, box=None, ) table.add_column("label", style="bold", no_wrap=True) table.add_column("value") table.add_row("description", escape(command.description)) if command.usage: table.add_row("usage", "\n".join(escape(item) for item in command.usage)) if command.examples: table.add_row("examples", "\n".join(escape(item) for item in command.examples)) if command.notes: table.add_row("notes", "\n".join(escape(item) for item in command.notes)) print_repl_table(console, table) def has_help_details(command: SlashCommand) -> bool: """True when a command has expandable usage, examples, or notes.""" return bool(command.usage or command.examples or command.notes) def _flatten_help_rows(sections: Sequence[HelpSection]) -> list[HelpRow]: rows: list[HelpRow] = [] for section_name, commands in sections: if not commands: continue if rows: rows.append(HelpRow(separator=True)) rows.append(HelpRow(section=section_name)) rows.extend(HelpRow(command=command) for command in commands) return rows def _first_selectable_index(rows: Sequence[HelpRow]) -> int | None: for index, row in enumerate(rows): if row.selectable: return index return None def _next_selectable_index(rows: Sequence[HelpRow], current: int, delta: int) -> int: if not rows: return current index = current for _ in range(len(rows)): index = (index + delta) % len(rows) if rows[index].selectable: return index return current def _expanded_detail_lines(command: SlashCommand) -> list[HelpDetailLine]: lines: list[HelpDetailLine] = [] if command.usage: lines.append(HelpDetailLine("usage:", "label")) lines.extend(HelpDetailLine(f" {item}", "value") for item in command.usage) if command.examples: lines.append(HelpDetailLine("examples:", "label")) lines.extend(HelpDetailLine(f" {item}", "value") for item in command.examples) if command.notes: lines.append(HelpDetailLine("notes:", "label")) lines.extend(HelpDetailLine(f" {item}", "value") for item in command.notes) return lines def _display_rows(rows: Sequence[HelpRow], expanded: int | None) -> list[HelpDisplayRow]: display: list[HelpDisplayRow] = [] for index, row in enumerate(rows): display.append( HelpDisplayRow( source_index=index, section=row.section, command=row.command, separator=row.separator, ) ) if expanded == index and row.command is not None: display.extend( HelpDisplayRow(detail=line) for line in _expanded_detail_lines(row.command) ) return display def _display_index_for_source(display_rows: Sequence[HelpDisplayRow], source_index: int) -> int: for index, row in enumerate(display_rows): if row.source_index == source_index: return index return 0 def _detail_end_index(rows: Sequence[HelpDisplayRow], selected: int) -> int: end = selected + 1 while end < len(rows) and rows[end].detail is not None: end += 1 return end def _expanded_viewport_height( rows: Sequence[HelpDisplayRow], selected: int, base_height: int, ) -> int: detail_end = _detail_end_index(rows, selected) expanded_block_height = detail_end - selected if expanded_block_height <= 1: return base_height return max(base_height, expanded_block_height + 2) def _viewport_bounds( rows: Sequence[HelpDisplayRow], selected: int, height: int, ) -> tuple[int, int]: if len(rows) <= height: return 0, len(rows) before = max(0, height // 3) start = max(0, selected - before) end = start + height if end > len(rows): end = len(rows) start = max(0, end - height) detail_end = _detail_end_index(rows, selected) if detail_end > selected + 1 and detail_end - selected <= height: if selected < start: start = selected end = min(len(rows), start + height) if detail_end > end: end = detail_end start = max(0, end - height) # Keep the selected command's category header visible when it fits in the viewport. section_start = selected while section_start > 0 and rows[section_start].section is None: section_start -= 1 if rows[section_start].section is not None and section_start < start: detail_end = _detail_end_index(rows, selected) distance = detail_end - section_start if distance <= height: start = section_start end = min(len(rows), start + height) return start, end def _visible_width(text: str) -> int: return len(text) def _clip(text: str, width: int) -> str: if width <= 0: return "" if _visible_width(text) <= width: return text return text[: max(0, width - 1)] + "…" def _pad(text: str, width: int) -> str: clipped = _clip(text, width) return clipped + (" " * max(0, width - _visible_width(clipped))) def _center(text: str, width: int) -> str: if _visible_width(text) >= width: return text return text.center(width) def _command_name_width(width: int) -> int: return min(18, max(12, width // 3)) def _left_column_width(width: int) -> int: return 6 + _command_name_width(width) def _right_column_width(width: int) -> int: return max(0, width - _left_column_width(width) - 2) def _render_grid_row(left: str, right: str, width: int) -> str: left_column = _pad(left, _left_column_width(width)) right_column = _clip(right, _right_column_width(width)) return _pad(f"{left_column}│ {right_column}", width) def _render_section_row(section: str, width: int) -> str: left_column = _pad(section, _left_column_width(width)) right_column = " " * _right_column_width(width) return ( f"{ui_theme.BOLD_BRAND_ANSI}{left_column}{ui_theme.ANSI_RESET}" f"{ui_theme.DIM_COUNTER_ANSI}│ {right_column}{ui_theme.ANSI_RESET}" ) def _render_detail_row(detail: HelpDetailLine, width: int) -> str: left_column = " " * _left_column_width(width) right_column = _clip(detail.text, _right_column_width(width)) right_padding = " " * max(0, _right_column_width(width) - _visible_width(right_column)) detail_ansi = ui_theme.TEXT_ANSI if detail.role == "label" else ui_theme.DIM_COUNTER_ANSI return ( f"{ui_theme.DIM_COUNTER_ANSI}{left_column}│ {ui_theme.ANSI_RESET}" f"{detail_ansi}{right_column}{right_padding}{ui_theme.ANSI_RESET}" ) def _separator_rule(width: int) -> str: column = _left_column_width(width) if column >= width: return "─" * width return f"{'─' * column}┼{'─' * max(0, width - column - 1)}" def _render_command_row( command: SlashCommand, *, selected: bool, expanded: bool, width: int, ) -> str: marker = ">" if selected else " " affordance = "▾" if expanded else "▸" if has_help_details(command) else " " left = f" {marker} {affordance} {command.name}" padded = _render_grid_row(left, command.description, width) if selected: return f"{ui_theme.MENU_SELECTION_ROW_ANSI}{padded}{ui_theme.ANSI_RESET}" left_width = _left_column_width(width) right_width = _right_column_width(width) prefix = f" {marker} {affordance} " command_width = max(0, left_width - _visible_width(prefix)) command_name = _clip(command.name, command_width) command_padding = " " * max(0, command_width - _visible_width(command_name)) right_column = _clip(command.description, right_width) right_padding = " " * max(0, right_width - _visible_width(right_column)) return ( f"{ui_theme.DIM_COUNTER_ANSI}{prefix}{ui_theme.ANSI_RESET}" f"{ui_theme.HIGHLIGHT_ANSI}{command_name}{ui_theme.ANSI_RESET}" f"{ui_theme.DIM_COUNTER_ANSI}{command_padding}│ {right_column}{right_padding}" f"{ui_theme.ANSI_RESET}" ) def _render_help_row(row: HelpRow, *, selected: bool, expanded: bool, width: int) -> str: if row.separator: return f"{ui_theme.DIM_COUNTER_ANSI}{_separator_rule(width)}{ui_theme.ANSI_RESET}" if row.section is not None: return _render_section_row(row.section, width) if row.command is None: return "" return _render_command_row(row.command, selected=selected, expanded=expanded, width=width) def _render_display_row( row: HelpDisplayRow, *, selected: bool, expanded: bool, width: int, ) -> str: if row.detail is not None: return _render_detail_row(row.detail, width) return _render_help_row( HelpRow(section=row.section, command=row.command, separator=row.separator), selected=selected, expanded=expanded, width=width, ) def _help_menu_height(viewport_height: int) -> int: # leading blank, title, counter, rule, rows, blank, hint return 5 + viewport_height + 1 def _draw_help_menu( rows: Sequence[HelpRow], *, selected: int, expanded: int | None, erase_lines: int, viewport_height: int = _HELP_VIEW_ROWS, ) -> int: width = menu_columns() display = _display_rows(rows, expanded) display_selected = _display_index_for_source(display, selected) effective_viewport_height = _expanded_viewport_height( display, display_selected, viewport_height, ) start, end = _viewport_bounds(display, display_selected, effective_viewport_height) visible = display[start:end] height = _help_menu_height(effective_viewport_height) if erase_lines: erase_menu_lines(erase_lines) selected_count = sum(1 for row in rows[: selected + 1] if row.selectable) total_count = sum(1 for row in rows if row.selectable) write_menu_line() write_menu_line( f"{ui_theme.PROMPT_ACCENT_ANSI}{_center('Slash commands', width)}{ui_theme.ANSI_RESET}" ) write_menu_line( f"{ui_theme.DIM_COUNTER_ANSI}{selected_count}/{total_count}{ui_theme.ANSI_RESET}" ) write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_separator_rule(width)}{ui_theme.ANSI_RESET}") for offset, row in enumerate(visible, start=start): write_menu_line( _render_display_row( row, selected=(offset == display_selected), expanded=(row.source_index == expanded), width=width, ) ) for _ in range(max(0, effective_viewport_height - len(visible))): write_menu_line() write_menu_line() write_menu_line(f"{ui_theme.DIM_COUNTER_ANSI}{_HELP_HINT}{ui_theme.ANSI_RESET}") sys.stdout.flush() return height def choose_help_command(sections: Sequence[HelpSection]) -> str | None: """Let a TTY user browse command details from a grouped viewport.""" rows = _flatten_help_rows(sections) selected = _first_selectable_index(rows) if selected is None: return None erase_lines = 0 expanded: int | None = None while True: erase_lines = _draw_help_menu( rows, selected=selected, expanded=expanded, erase_lines=erase_lines, ) action = read_menu_action() if action == "space": command = rows[selected].command if command is not None and has_help_details(command): expanded = None if expanded == selected else selected continue if action == "enter": command = rows[selected].command erase_menu_lines(erase_lines) return command.name if command is not None else None if action in ("cancel", "eof"): erase_menu_lines(erase_lines) return None if action == "ignore": continue if action == "up": selected = _next_selectable_index(rows, selected, -1) expanded = None elif action == "down": selected = _next_selectable_index(rows, selected, 1) expanded = None __all__ = [ "HelpSection", "HelpRow", "choose_help_command", "render_command_detail", "render_help_index", "render_section_detail", ]