diff --git a/docs/experience/interactive-cli.md b/docs/experience/interactive-cli.md index 02cc4a79..df956d6c 100644 --- a/docs/experience/interactive-cli.md +++ b/docs/experience/interactive-cli.md @@ -22,6 +22,28 @@ product summary instead — automation never hits an interactive prompt. | Space | toggle (multi-select) | | q / Esc | cancel or quit | | Ctrl+C | cancel immediately | +| Ctrl+K | open command palette | + +## Product polish and terminal behavior + +The full-screen experience keeps the same real CLI actions while reducing +navigation cost: + +- `Ctrl+K` opens a searchable command palette. Type part of an action, move + with ↑/↓ or `j`/`k`, and press Enter to open it. +- The footer prioritizes shortcuts and drops lower-priority hints when the + terminal is narrow, so key labels never push content off-screen. +- Long-running, interactive, browser, host, graph, install, and verification + actions suspend the alternate screen and stream in the user's terminal. +- Captured actions have a bounded 300-second timeout and return a clear exit + code with a direct-run hint instead of leaving the TUI apparently frozen. +- Terminal setup rolls back cursor/alternate-screen state if raw mode cannot + start, preserving the user's shell even on partial failures. + +The automated suite covers 60×16 and wider viewports, palette navigation, +timeout feedback, resize handling, EOF, and ANSI-free rendering. External +PTY/ConPTY and screen-reader validation remain explicitly unverified in this +environment. ## What the menu runs diff --git a/src/apiforge/ui/app.py b/src/apiforge/ui/app.py index 477ac76e..704b4a54 100644 --- a/src/apiforge/ui/app.py +++ b/src/apiforge/ui/app.py @@ -16,13 +16,12 @@ import io import sys -import traceback from collections.abc import Callable from contextlib import redirect_stderr, redirect_stdout from dataclasses import dataclass, field from typing import TextIO -from apiforge.ui.kit import NonInteractive, UIContext, terminal_size +from apiforge.ui.kit import NonInteractive, UIContext, _ellipsize, terminal_size from apiforge.ui.screen import ( MIN_HEIGHT, MIN_WIDTH, @@ -93,10 +92,9 @@ class _Pane: _HELP = [ - ("up/dn", "nav"), + ("ctrl-k", "commands"), + ("up/dn", "navigate"), ("enter", "open/run"), - ("r", "refresh"), - ("/", "filter"), ("?", "help"), ("q", "back/quit"), ] @@ -145,6 +143,9 @@ def __init__( self.pane = _Pane() self.filter = "" self.filter_active = False + self.palette_active = False + self.palette_query = "" + self.palette_idx = 0 self.show_help = False self._out = out self._load() @@ -209,6 +210,8 @@ def render(self, buf: Buffer) -> None: self._render_content(buf, cx, cy, cw, ch) if self.show_help: self._render_help(buf) + if self.palette_active: + self._render_palette(buf) key_bar(buf, _HELP, theme=th) def _render_content(self, buf: Buffer, x: int, y: int, w: int, h: int) -> None: @@ -244,15 +247,11 @@ def _render_content(self, buf: Buffer, x: int, y: int, w: int, h: int) -> None: theme=self.theme, ) return + action_hint = "running..." if self.pane.executing else "press enter to execute" lines = ( d.lines if isinstance(d, TextData) - else ( - [f"action: {d.title}"] - + _doc_lines(d) - + ["", "press enter to execute"] - + self.pane.log - ) + else ([f"action: {d.title}"] + _doc_lines(d) + ["", action_hint] + self.pane.log) ) for i, line in enumerate(lines[self.pane.scroll : self.pane.scroll + h]): buf.draw(y + i, x, line) @@ -265,6 +264,7 @@ def _render_help(self, buf: Buffer) -> None: "up / k · down / j move", "enter open / run / select", "tab switch pane", + "ctrl-k command palette", "r refresh real data", "/ filter menu", "? this help", @@ -279,6 +279,14 @@ def _render_help(self, buf: Buffer) -> None: def on_key(self, key: str) -> str | None: if key in ("ctrl-c", "ctrl-d"): return "quit" + if key == "ctrl-k": + self.palette_active = not self.palette_active + self.palette_query = "" + self.palette_idx = 0 + self.show_help = False + return None + if self.palette_active: + return self._palette_key(key) if self.filter_active: return self._filter_key(key) if self.show_help: @@ -298,6 +306,65 @@ def on_key(self, key: str) -> str | None: return self._nav_key(key) return self._content_key(key) + def _palette_matches(self) -> list[tuple[int, Entry]]: + query = self.palette_query.lower().strip() + return [ + (i, entry) + for i, entry in enumerate(self.entries) + if not query or query in entry.label.lower() + ] + + def _render_palette(self, buf: Buffer) -> None: + width = min(72, max(24, buf.width - 4)) + height = min(14, max(6, buf.height - 4)) + x0, y0 = (buf.width - width) // 2, (buf.height - height) // 2 + ix, iy, iw, ih = panel( + buf, + x0, + y0, + width, + height, + title=f"commands /{self.palette_query}" if self.palette_query else "commands", + focused=True, + theme=self.theme, + ) + matches = self._palette_matches() + if not matches: + buf.draw(iy, ix, "no matching commands", style="dim") + return + self.palette_idx = min(self.palette_idx, len(matches) - 1) + start = min(max(0, self.palette_idx - ih + 1), max(0, len(matches) - ih)) + for row, (_, entry) in enumerate(matches[start : start + ih]): + selected = start + row == self.palette_idx + line = f"{'>' if selected else ' '} {_ellipsize(self.ctx, entry.label, iw - 3)}" + buf.draw(iy + row, ix, line, style=self.theme.focus if selected else "") + + def _palette_key(self, key: str) -> str | None: + matches = self._palette_matches() + if key in ("esc", "ctrl-k"): + self.palette_active = False + self.palette_query = "" + return None + if key in ("up", "k") and matches: + self.palette_idx = (self.palette_idx - 1) % len(matches) + elif key in ("down", "j") and matches: + self.palette_idx = (self.palette_idx + 1) % len(matches) + elif key == "enter" and matches: + self.nav_idx = matches[self.palette_idx][0] + self._load() + self.focus = "content" + self.palette_active = False + self.palette_query = "" + elif key == "backspace": + self.palette_query = self.palette_query[:-1] + self.palette_idx = 0 + else: + char = key[5:] if key.startswith("char:") else key + if len(char) == 1 and ord(char) >= 32: + self.palette_query += char + self.palette_idx = 0 + return None + def _filter_key(self, key: str) -> str | None: if key in ("enter", "tab", "backtab"): self.filter_active = False @@ -414,8 +481,9 @@ def _execute(self, d: ActionData, *, capture: bool = True) -> None: result = d.callable() else: result = d.callable() - except Exception: - self.pane.log += ["", "FAILED:", traceback.format_exc(limit=3)] + except Exception as exc: + detail = str(exc) or exc.__class__.__name__ + self.pane.log += ["", f"FAILED: {detail}", "press r to retry"] return out = sink.getvalue() if capture else "" if capture: diff --git a/src/apiforge/ui/screen.py b/src/apiforge/ui/screen.py index d0764849..02802f6a 100644 --- a/src/apiforge/ui/screen.py +++ b/src/apiforge/ui/screen.py @@ -278,13 +278,9 @@ def panel( return x, y, w, h cap = f" {title} " if title else "" top = ( - tl + hh * (w - 2 - cell_width(cap)) + cap - if False - else ( - tl + cap + hh * max(0, w - 2 - cell_width(cap)) + tr - if cell_width(cap) <= w - 2 - else tl + hh * (w - 2) + tr - ) + tl + cap + hh * max(0, w - 2 - cell_width(cap)) + tr + if cell_width(cap) <= w - 2 + else tl + hh * (w - 2) + tr ) buf.draw(y, x, top, style=border_style) for row in range(1, h - 1): @@ -298,8 +294,21 @@ def key_bar(buf: Buffer, bindings: Iterable[tuple[str, str]], *, theme: Theme) - """Footer line: ``key action · key action``.""" y = buf.height - 1 buf.hline(y - 1, style="2") - parts = " · ".join(f"{_c(buf.ctx, '1' if buf.ctx.color else '', k)} {v}" for k, v in bindings) - buf.draw(y, 1, re.sub(r"\x1b\[[0-9;]*m", "", parts) if not buf.ctx.color else parts) + all_bindings = list(bindings) + max_width = max(1, buf.width - 2) + + def plain(parts: list[tuple[str, str]]) -> str: + return " · ".join(f"{key} {label}" for key, label in parts) + + visible = all_bindings[:] + while len(visible) > 1 and cell_width(plain(visible + [("...", "more")])) > max_width: + visible.pop() + if len(visible) < len(all_bindings): + visible.append(("...", "more")) + text = " · ".join( + f"{_c(buf.ctx, '1', key) if buf.ctx.color else key} {label}" for key, label in visible + ) + buf.draw(y, 1, re.sub(r"\x1b\[[0-9;]*m", "", text) if not buf.ctx.color else text) def header(buf: Buffer, forge: str, screen: str, *, theme: Theme, right: str = "") -> None: @@ -311,6 +320,7 @@ def header(buf: Buffer, forge: str, screen: str, *, theme: Theme, right: str = " left = _c(ctx, theme.accent, brand) + _c(ctx, "dim", title) if ctx.color else brand + title buf.draw(0, 0, left) if right: + right = _ellipsize(ctx, right, buf.width - 2) buf.draw(0, max(0, buf.width - cell_width(right) - 1), right, style="dim") buf.hline(1, style=theme.accent) @@ -452,8 +462,13 @@ def __enter__(self) -> Screen: out = self.out out.write("\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H") out.flush() - self._raw = _RawMode().__enter__() if os.name != "nt" else None - self._entered = True + try: + self._raw = _RawMode().__enter__() if os.name != "nt" else None + self._entered = True + except Exception: + out.write("\x1b[?25h\x1b[?1049l") + out.flush() + raise return self def __exit__(self, *exc) -> bool: @@ -511,6 +526,7 @@ def __exit__(self, *exc) -> bool: "\x1b": "esc", "\x7f": "backspace", "\x03": "ctrl-c", + "\x0b": "ctrl-k", } @@ -608,7 +624,7 @@ def run_loop( while True: now = (ctx.width, ctx.height) # refresh size every tick (SIGWINCH arrives between polls) - now = terminal_size(fallback=(ctx.width, ctx.height)) + now = terminal_size(stream=out, fallback=(ctx.width, ctx.height)) if now != size: size = now ctx = UIContext(**{**ctx.__dict__, "width": size[0], "height": size[1]}) diff --git a/src/apiforge/ui/tui.py b/src/apiforge/ui/tui.py index 15a612d5..7205e910 100644 --- a/src/apiforge/ui/tui.py +++ b/src/apiforge/ui/tui.py @@ -17,7 +17,25 @@ # argv[0]s that stream progressively or open external surfaces — # those run attached (alt-screen suspended) instead of captured. -_SUSPEND_VERBS = {"analyze", "judge", "collect", "migrate", "graph", "sdd"} +_SUSPEND_VERBS = { + "agents", + "ai", + "analyze", + "capabilities", + "collect", + "contract", + "discover", + "evals", + "field", + "forge", + "graph", + "install", + "judge", + "migrate", + "runtime", + "sdd", + "verify", +} _LEARN_DOC: dict[str, str] = { @@ -45,10 +63,14 @@ def _run() -> int: print("$ " + " ".join(argv[:4]) + (" …" if len(argv) > 4 else "")) if suspend: return home._run_argv(argv) - env = {**__import__('os').environ, - 'FORGE_TEXT_OUTPUT': '1', 'NO_COLOR': '1'} - cp = subprocess.run(argv, capture_output=True, text=True, env=env, - timeout=300, check=False) + env = {**__import__("os").environ, "FORGE_TEXT_OUTPUT": "1", "NO_COLOR": "1"} + try: + cp = subprocess.run( + argv, capture_output=True, text=True, env=env, timeout=300, check=False + ) + except subprocess.TimeoutExpired: + print("command timed out after 300s; run it directly for live progress") + return 124 sys.stdout.write(cp.stdout) if cp.stderr: sys.stdout.write("\n[stderr]\n" + cp.stderr) @@ -59,16 +81,20 @@ def _run() -> int: example=f"{home.CLI_NAME} {' '.join(extra)}", doc_path=_LEARN_DOC.get(extra[0], ""), ) - return ActionData(callable=_run, title=f"{home.CLI_NAME} {' '.join(extra)}", - suspend=suspend, doc=doc) + return ActionData( + callable=_run, title=f"{home.CLI_NAME} {' '.join(extra)}", suspend=suspend, doc=doc + ) def _wizard_entry() -> Entry: - return Entry(label="installation wizard", - provider=lambda: ActionData( - callable=lambda: home._run_wizard( - UIContext.detect(force_plain=True)), - title="install wizard", suspend=True)) + return Entry( + label="installation wizard", + provider=lambda: ActionData( + callable=lambda: home._run_wizard(UIContext.detect(force_plain=True)), + title="install wizard", + suspend=True, + ), + ) def entries() -> list[Entry]: @@ -79,12 +105,12 @@ def entries() -> list[Entry]: if argv == "__wizard__": out.append(_wizard_entry()) else: - out.append(Entry(label=label, - provider=lambda a=argv, lbl=label: _action_for(lbl, a))) + out.append(Entry(label=label, provider=lambda a=argv, lbl=label: _action_for(lbl, a))) return out def run_tui(*, ctx: UIContext | None = None, version: str = "") -> int: ctx = ctx or UIContext.detect() - return ForgeApp(ctx, forge_id=home.FORGE_NAME, title="command center", - entries=entries(), version=version).run() + return ForgeApp( + ctx, forge_id=home.FORGE_NAME, title="command center", entries=entries(), version=version + ).run() diff --git a/tests/test_ui_resilience.py b/tests/test_ui_resilience.py index bef439ac..16a6c3ea 100644 --- a/tests/test_ui_resilience.py +++ b/tests/test_ui_resilience.py @@ -1,7 +1,7 @@ -from apiforge.ui import screen +from apiforge.ui import screen, tui from apiforge.ui.app import ActionData, Entry, ForgeApp, TextData from apiforge.ui.kit import UIContext -from apiforge.ui.screen import Buffer, Column, Theme, table +from apiforge.ui.screen import Buffer, Column, Theme, key_bar, table def _ctx(width=60, height=16): @@ -65,6 +65,45 @@ def test_empty_menu_renders_empty_state(): assert "no menu entries" in "\n".join(buf.render()) +def test_command_palette_finds_and_opens_entry(): + entries = [ + Entry(label="status", provider=lambda: TextData(lines=["ok"])), + Entry(label="installation health", provider=lambda: TextData(lines=["healthy"])), + ] + app = ForgeApp(_ctx(), forge_id="api-forge", title="t", entries=entries) + app.on_key("ctrl-k") + for key in "health": + app.on_key(key) + app.on_key("enter") + assert app.nav_idx == 1 + assert isinstance(app.pane.data, TextData) + assert app.focus == "content" + assert not app.palette_active + + +def test_shortcut_footer_stays_inside_viewport(): + buf = Buffer(_ctx(31, 10), 31, 10) + key_bar( + buf, + [("ctrl-k", "commands"), ("enter", "open"), ("?", "help"), ("q", "quit")], + theme=Theme(), + ) + assert len(buf.render()[-1]) <= 31 + assert "ctrl-k" in buf.render()[-1] + + +def test_captured_command_timeout_returns_actionable_exit(monkeypatch, capsys): + monkeypatch.setattr(tui.home, "_cli_argv", lambda _extra: ["apiforge"]) + + def timeout(*_args, **_kwargs): + raise tui.subprocess.TimeoutExpired(["apiforge"], 300) + + monkeypatch.setattr(tui.subprocess, "run", timeout) + action = tui._action_for("slow status", ["status"]) + assert action.callable() == 124 + assert "timed out" in capsys.readouterr().out + + def test_buffer_viewport_matrix_stays_bounded(): for width, height in ((60, 16), (80, 24), (100, 30), (120, 40), (160, 50)): buf = Buffer(_ctx(width, height), width, height)