Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/experience/interactive-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
94 changes: 81 additions & 13 deletions src/apiforge/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
]
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
40 changes: 28 additions & 12 deletions src/apiforge/ui/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -511,6 +526,7 @@ def __exit__(self, *exc) -> bool:
"\x1b": "esc",
"\x7f": "backspace",
"\x03": "ctrl-c",
"\x0b": "ctrl-k",
}


Expand Down Expand Up @@ -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]})
Expand Down
58 changes: 42 additions & 16 deletions src/apiforge/ui/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand Down Expand Up @@ -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)
Expand All @@ -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]:
Expand All @@ -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()
Loading