4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
491 lines
16 KiB
Python
491 lines
16 KiB
Python
"""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 <command>[/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 <command>[/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",
|
|
]
|