From 9f51c332527e4539edb21f6254c711d6266315a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 19:49:19 -0300 Subject: [PATCH 01/14] fix(install): align recommended profile and wizard contract --- scripts/forge_installkit.py | 2 +- src/apiforge/_installkit.py | 4 ++-- src/apiforge/ui/wizard.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/forge_installkit.py b/scripts/forge_installkit.py index 05cf04f8..018d2e4f 100644 --- a/scripts/forge_installkit.py +++ b/scripts/forge_installkit.py @@ -579,7 +579,7 @@ def _host_dirs(host: str, scope: str) -> list[str]: def profile_asset_kinds(profile: str) -> tuple[str, ...]: return { "minimal": ("config", "mcp", "marker", "state"), - "recommended": ("config", "mcp", "marker", "state", "skill"), + "recommended": ("config", "mcp", "marker", "state", "skill", "agent"), "full": ("config", "mcp", "marker", "state", "skill", "agent"), }[profile] diff --git a/src/apiforge/_installkit.py b/src/apiforge/_installkit.py index 62d9cd1b..54781ce6 100644 --- a/src/apiforge/_installkit.py +++ b/src/apiforge/_installkit.py @@ -33,7 +33,7 @@ _INSTALLKIT_VERSION = "1.0.0" # Filled by the vendor step (scripts/installkit/vendor.py) — the sha256 of # the canonical source body, so drift checks can compare vendored copies. -_SOURCE_SHA256 = "7d960bb98bbf7b1e" +_SOURCE_SHA256 = "9c230ee2fafd1261" SCHEMA_MANIFEST = "forge/InstallationManifest/v1" SCHEMA_RECEIPT = "forge/InstallReceipt/v1" @@ -579,7 +579,7 @@ def _host_dirs(host: str, scope: str) -> list[str]: def profile_asset_kinds(profile: str) -> tuple[str, ...]: return { "minimal": ("config", "mcp", "marker", "state"), - "recommended": ("config", "mcp", "marker", "state", "skill"), + "recommended": ("config", "mcp", "marker", "state", "skill", "agent"), "full": ("config", "mcp", "marker", "state", "skill", "agent"), }[profile] diff --git a/src/apiforge/ui/wizard.py b/src/apiforge/ui/wizard.py index 696cce29..4388fe0d 100644 --- a/src/apiforge/ui/wizard.py +++ b/src/apiforge/ui/wizard.py @@ -119,7 +119,7 @@ def run_wizard( _COMP_ORDER = ("skills", "agents", "mcp", "tui", "graph-studio") _profile_defaults = { "minimal": {"mcp", "tui"}, - "recommended": {"skills", "mcp", "tui", "graph-studio"}, + "recommended": {"skills", "agents", "mcp", "tui", "graph-studio"}, "full": {"skills", "agents", "mcp", "tui", "graph-studio"}, } comp_checked = _profile_defaults.get(profile, set()) From 16d2f657ed8ebc98858d30a4795b321e93520eb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 20:03:41 -0300 Subject: [PATCH 02/14] fix(install): make state writes transactional --- scripts/forge_installkit.py | 38 +++++++++++++++++++++++++++++------ src/apiforge/_installkit.py | 40 ++++++++++++++++++++++++++++++------- 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/scripts/forge_installkit.py b/scripts/forge_installkit.py index 018d2e4f..158d95d1 100644 --- a/scripts/forge_installkit.py +++ b/scripts/forge_installkit.py @@ -725,6 +725,13 @@ def apply_install(ctx: InstallContext, *, approved: bool = False) -> dict[str, A markers: list[dict[str, Any]] = [] checks: list[dict[str, Any]] = [] ledger = ctx.ledger or Ledger.load(ctx.state_dir) + ledger_before = ledger.path.read_bytes() if ledger.path.exists() else None + component_file = components_path(ctx.state_dir) if ctx.options else None + component_before = ( + component_file.read_bytes() + if component_file is not None and component_file.exists() + else None + ) # §14.3 journal — every write is either a creation (removable) or an # overwrite with a pre-state snapshot; on failure the managed half is # reverted before the error propagates. @@ -867,12 +874,31 @@ def _snapshot(target: Path, rel: str) -> None: E_WRITE, f"install failed at write stage; managed writes reverted: {exc}" ) from exc - ledger.save() - if ctx.options: - ctx.state_dir.mkdir(parents=True, exist_ok=True) - _atomic_write( - components_path(ctx.state_dir), _json_bytes(dict(ctx.options)) - ) + try: + ledger.save() + if ctx.options: + ctx.state_dir.mkdir(parents=True, exist_ok=True) + _atomic_write(component_file, _json_bytes(dict(ctx.options))) + except InstallError: + _rollback_writes(ctx, created, backups) + raise + except Exception as exc: + _rollback_writes(ctx, created, backups) + try: + if ledger_before is None: + ledger.path.unlink(missing_ok=True) + else: + _atomic_write(ledger.path, ledger_before) + if component_file is not None: + if component_before is None: + component_file.unlink(missing_ok=True) + else: + _atomic_write(component_file, component_before) + except OSError: + pass + raise InstallError( + E_WRITE, f"install failed at state stage; managed writes reverted: {exc}" + ) from exc written = sum(1 for f in files if f.get("action") in ("created", "updated")) checks.append({"id": "files-written", "status": "PASS", "detail": f"{written} writes"}) receipt = { diff --git a/src/apiforge/_installkit.py b/src/apiforge/_installkit.py index 54781ce6..d61de8c7 100644 --- a/src/apiforge/_installkit.py +++ b/src/apiforge/_installkit.py @@ -33,7 +33,7 @@ _INSTALLKIT_VERSION = "1.0.0" # Filled by the vendor step (scripts/installkit/vendor.py) — the sha256 of # the canonical source body, so drift checks can compare vendored copies. -_SOURCE_SHA256 = "9c230ee2fafd1261" +_SOURCE_SHA256 = "866b66c20f245e7c" SCHEMA_MANIFEST = "forge/InstallationManifest/v1" SCHEMA_RECEIPT = "forge/InstallReceipt/v1" @@ -725,6 +725,13 @@ def apply_install(ctx: InstallContext, *, approved: bool = False) -> dict[str, A markers: list[dict[str, Any]] = [] checks: list[dict[str, Any]] = [] ledger = ctx.ledger or Ledger.load(ctx.state_dir) + ledger_before = ledger.path.read_bytes() if ledger.path.exists() else None + component_file = components_path(ctx.state_dir) if ctx.options else None + component_before = ( + component_file.read_bytes() + if component_file is not None and component_file.exists() + else None + ) # §14.3 journal — every write is either a creation (removable) or an # overwrite with a pre-state snapshot; on failure the managed half is # reverted before the error propagates. @@ -867,12 +874,31 @@ def _snapshot(target: Path, rel: str) -> None: E_WRITE, f"install failed at write stage; managed writes reverted: {exc}" ) from exc - ledger.save() - if ctx.options: - ctx.state_dir.mkdir(parents=True, exist_ok=True) - _atomic_write( - components_path(ctx.state_dir), _json_bytes(dict(ctx.options)) - ) + try: + ledger.save() + if ctx.options: + ctx.state_dir.mkdir(parents=True, exist_ok=True) + _atomic_write(component_file, _json_bytes(dict(ctx.options))) + except InstallError: + _rollback_writes(ctx, created, backups) + raise + except Exception as exc: + _rollback_writes(ctx, created, backups) + try: + if ledger_before is None: + ledger.path.unlink(missing_ok=True) + else: + _atomic_write(ledger.path, ledger_before) + if component_file is not None: + if component_before is None: + component_file.unlink(missing_ok=True) + else: + _atomic_write(component_file, component_before) + except OSError: + pass + raise InstallError( + E_WRITE, f"install failed at state stage; managed writes reverted: {exc}" + ) from exc written = sum(1 for f in files if f.get("action") in ("created", "updated")) checks.append({"id": "files-written", "status": "PASS", "detail": f"{written} writes"}) if "codex" in ctx.hosts and ctx.scope == "project": From 22ea2451f384e377a8d7bc4c1bc6d0b7d6a1f323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 20:12:43 -0300 Subject: [PATCH 03/14] feat(install): route bare CLI to guided wizard --- docs/experience/automation-mode.md | 4 +-- docs/experience/installation-wizard.md | 10 +++--- src/apiforge/cli_install.py | 50 ++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/docs/experience/automation-mode.md b/docs/experience/automation-mode.md index a1711723..7d8cba3e 100644 --- a/docs/experience/automation-mode.md +++ b/docs/experience/automation-mode.md @@ -5,7 +5,7 @@ Every interactive surface degrades to a deterministic, scriptable path. | Situation | Behavior | |---|---| | Non-TTY bare `apiforge` | product summary, exit 0 | -| Non-TTY `apiforge install` | usage error pointing at `install apply` | +| Non-TTY `apiforge install` | structured approval refusal; no prompt, no writes | | Interactive fn called headless | `NonInteractive` raised — never blocks | | `--dry-run` | real plan, zero writes | | `--yes` | explicit approval for writes | @@ -14,7 +14,7 @@ Every interactive surface degrades to a deterministic, scriptable path. CI pattern: ```bash -apiforge install apply --scope project --profile minimal --yes +apiforge install --scope project --profile minimal --yes apiforge doctor ``` diff --git a/docs/experience/installation-wizard.md b/docs/experience/installation-wizard.md index fd835970..abe19a83 100644 --- a/docs/experience/installation-wizard.md +++ b/docs/experience/installation-wizard.md @@ -1,8 +1,8 @@ # Installation wizard Bare `apiforge install` on a TTY opens the guided wizard. On a non-TTY it -exits with a usage error pointing at `install apply` — scripts keep the -flag contract. +returns a structured approval refusal without reading stdin or writing files. +Explicit flags keep the deterministic contract. ## Steps @@ -24,10 +24,10 @@ flag contract. Non-interactive equivalent: ```bash -apiforge install apply --scope project --profile recommended --host all --dry-run -apiforge install apply --scope project --profile recommended --host all --yes +apiforge install --scope project --profile recommended --host all --dry-run +apiforge install --scope project --profile recommended --host all --yes # optional components: -apiforge install apply --yes --components skills,mcp,tui,graph-studio +apiforge install --yes --components skills,mcp,tui,graph-studio apiforge install doctor ``` diff --git a/src/apiforge/cli_install.py b/src/apiforge/cli_install.py index c65f3a2f..b3e0608d 100644 --- a/src/apiforge/cli_install.py +++ b/src/apiforge/cli_install.py @@ -77,6 +77,31 @@ def _call(fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any: raise AssertionError("unreachable") +def _bare_install_requested( + ctx: typer.Context, + *, + scope: str, + host: str, + profile: str, + root: Path | None, + yes: bool, + dry_run: bool, + components: str | None, +) -> bool: + """Detect exact default invocation without guessing explicit flags.""" + if (scope, host, profile, root, yes, dry_run, components) != ( + "project", "all", "recommended", None, False, False, None + ): + return False + source = getattr(ctx, "get_parameter_source", None) + if source is None: + return True + return all( + getattr(source(name), "name", "") == "DEFAULT" + for name in ("scope", "host", "profile", "root", "yes", "dry_run", "components") + ) + + @install_app.callback() def install_apply( ctx: typer.Context, @@ -99,6 +124,31 @@ def install_apply( import sys comps = tuple(c.strip() for c in components.split(",") if c.strip()) if components else None + if ( + _bare_install_requested( + ctx, + scope=scope, + host=host, + profile=profile, + root=root, + yes=yes, + dry_run=dry_run, + components=components, + ) + and sys.stdin.isatty() + and sys.stdout.isatty() + ): + from apiforge.ui.kit import UIContext + from apiforge.ui.wizard import run_wizard + + raise typer.Exit( + run_wizard( + forge_name="api-forge", + install_fn=lambda **kw: service.install(root=None, **kw), + doctor_fn=lambda **kw: service.doctor(**kw), + ctx=UIContext.detect(), + ) + ) approved = yes if not yes and not dry_run and sys.stdin.isatty() and sys.stdout.isatty(): # Interactive approval: render the real plan, confirm once, From 2ce75e8e83a5bd7fbb37fff3eb17214186ec1ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 20:28:03 -0300 Subject: [PATCH 04/14] feat(tui): add filtering and pagination controls --- src/apiforge/ui/app.py | 60 ++++++++++++++++++++++++++++++++++++--- src/apiforge/ui/screen.py | 6 ++-- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/apiforge/ui/app.py b/src/apiforge/ui/app.py index c86250b0..b0a4ec86 100644 --- a/src/apiforge/ui/app.py +++ b/src/apiforge/ui/app.py @@ -88,6 +88,7 @@ class _Pane: selected: int = 0 executing: bool = False log: list[str] = field(default_factory=list) + scroll: int = 0 _HELP = [("up/dn", "nav"), ("enter", "open/run"), ("r", "refresh"), @@ -129,6 +130,7 @@ def __init__(self, ctx: UIContext, *, forge_id: str, title: str, self.focus = "nav" # nav | content self.pane = _Pane() self.filter = "" + self.filter_active = False self.show_help = False self._out = out self._load() @@ -158,7 +160,8 @@ def render(self, buf: Buffer) -> None: right=self.version) nav_w = max(22, min(34, buf.width // 4)) ix, iy, iw, ih = panel(buf, 0, 2, nav_w, buf.height - 5, - title="menu", focused=self.focus == "nav", + title=f"menu /{self.filter}" if self.filter_active or self.filter else "menu", + focused=self.focus == "nav", theme=th) vis = self._visible_entries() for row, (i, e) in enumerate(vis[:ih]): @@ -203,7 +206,7 @@ def _render_content(self, buf: Buffer, x: int, y: int, w: int, h: int) -> None: lines = d.lines if isinstance(d, TextData) else ( [f"action: {d.title}"] + _doc_lines(d) + ["", "press enter to execute"] + self.pane.log) - for i, line in enumerate(lines[:h]): + for i, line in enumerate(lines[self.pane.scroll:self.pane.scroll + h]): buf.draw(y + i, x, line) def _render_help(self, buf: Buffer) -> None: @@ -228,6 +231,8 @@ def _render_help(self, buf: Buffer) -> None: def on_key(self, key: str) -> str | None: if key == "ctrl-c": return "quit" + if self.filter_active: + return self._filter_key(key) if self.show_help: self.show_help = False return None @@ -235,16 +240,41 @@ def on_key(self, key: str) -> str | None: self.show_help = True return None if key == "/": - self.filter = "" # placeholder toggles filter line; keep simple + self.filter_active = True self.focus = "nav" return None - if key == "tab": + if key in ("tab", "backtab"): self.focus = "content" if self.focus == "nav" else "nav" return None if self.focus == "nav": return self._nav_key(key) return self._content_key(key) + def _filter_key(self, key: str) -> str | None: + if key in ("enter", "tab", "backtab"): + self.filter_active = False + return None + if key == "esc": + self.filter = "" + self.filter_active = False + self._sync_filter_selection() + return None + if key == "backspace": + self.filter = self.filter[:-1] + self._sync_filter_selection() + return None + char = key[5:] if key.startswith("char:") else key + if len(char) == 1 and ord(char) >= 32: + self.filter += char + self._sync_filter_selection() + return None + + def _sync_filter_selection(self) -> None: + visible = self._visible_entries() + if visible and self.nav_idx not in {i for i, _ in visible}: + self.nav_idx = visible[0][0] + self._load() + def _nav_key(self, key: str) -> str | None: vis = self._visible_entries() if key in ("up", "k"): @@ -282,11 +312,33 @@ def _content_key(self, key: str) -> str | None: self.pane.selected = max(0, self.pane.selected - 1) elif key in ("down", "j"): self.pane.selected = min(len(d.rows) - 1, self.pane.selected + 1) + elif key == "pageup": + self.pane.selected = max(0, self.pane.selected - 10) + elif key == "pagedown": + self.pane.selected = min(len(d.rows) - 1, self.pane.selected + 10) + elif key == "home": + self.pane.selected = 0 + elif key == "end": + self.pane.selected = len(d.rows) - 1 elif key == "enter" and d.on_enter: d.on_enter(d.rows[self.pane.selected]) self._load() elif isinstance(d, ActionData) and key == "enter": return "exec" # loop decides suspend vs in-place + elif isinstance(d, (TextData, ActionData)): + line_count = len(d.lines) if isinstance(d, TextData) else len(self.pane.log) + 8 + if key in ("up", "k"): + self.pane.scroll = max(0, self.pane.scroll - 1) + elif key in ("down", "j"): + self.pane.scroll = min(max(0, line_count - 1), self.pane.scroll + 1) + elif key == "pageup": + self.pane.scroll = max(0, self.pane.scroll - 10) + elif key == "pagedown": + self.pane.scroll = min(max(0, line_count - 1), self.pane.scroll + 10) + elif key == "home": + self.pane.scroll = 0 + elif key == "end": + self.pane.scroll = max(0, line_count - 1) return None def _execute(self, d: ActionData) -> None: diff --git a/src/apiforge/ui/screen.py b/src/apiforge/ui/screen.py index 1d87da49..72a1ad2d 100644 --- a/src/apiforge/ui/screen.py +++ b/src/apiforge/ui/screen.py @@ -330,7 +330,8 @@ def __exit__(self, *exc) -> bool: _ESC = {"\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", - "\x1b[D": "left", "\x1b[H": "home", "\x1b[F": "end"} + "\x1b[D": "left", "\x1b[H": "home", "\x1b[F": "end", + "\x1b[5~": "pageup", "\x1b[6~": "pagedown", "\x1b[Z": "backtab"} _KEYS = {"\r": "enter", "\n": "enter", " ": "space", "\t": "tab", "\x1b": "esc", "\x7f": "backspace", "\x03": "ctrl-c"} @@ -345,7 +346,8 @@ def read_key(timeout: float = 0.05) -> str | None: ch = msvcrt.getwch() if ch in ("\x00", "\xe0"): # special key prefix return {"H": "up", "P": "down", "M": "right", - "K": "left", "G": "home", "O": "end"}.get(msvcrt.getwch()) + "K": "left", "G": "home", "O": "end", + "I": "pageup", "Q": "pagedown"}.get(msvcrt.getwch()) return _KEYS.get(ch, ch) # POSIX — select-based poll import select From 9eb85dfa61c63f50320ffa2ad5ef5f940008b385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 21:01:15 -0300 Subject: [PATCH 05/14] style(ci): format install and TUI changes --- src/apiforge/_installkit.py | 8 +- src/apiforge/cli_install.py | 8 +- src/apiforge/ui/app.py | 110 +++++++++++++++++++-------- src/apiforge/ui/screen.py | 147 ++++++++++++++++++++++++++---------- 4 files changed, 196 insertions(+), 77 deletions(-) diff --git a/src/apiforge/_installkit.py b/src/apiforge/_installkit.py index d61de8c7..1cff2980 100644 --- a/src/apiforge/_installkit.py +++ b/src/apiforge/_installkit.py @@ -910,7 +910,7 @@ def _snapshot(target: Path, rel: str) -> None: "id": "mcp-scope:codex", "status": "NOT_APPLICABLE", "detail": "codex does not read project .mcp.json; MCP needs " - "--scope user or `codex mcp add`", + "--scope user or `codex mcp add`", } ) receipt = { @@ -1054,9 +1054,11 @@ def doctor(ctx: InstallContext) -> dict[str, Any]: "id": "forge-portal", "status": "PASS" if found else "NOT_APPLICABLE", "detail": presence["install_root"] - or ("vendored assets only — no local forge-portal install found" + or ( + "vendored assets only — no local forge-portal install found" if not found - else presence["manifest"]), + else presence["manifest"] + ), "repairable": False, } ) diff --git a/src/apiforge/cli_install.py b/src/apiforge/cli_install.py index b3e0608d..a794164f 100644 --- a/src/apiforge/cli_install.py +++ b/src/apiforge/cli_install.py @@ -90,7 +90,13 @@ def _bare_install_requested( ) -> bool: """Detect exact default invocation without guessing explicit flags.""" if (scope, host, profile, root, yes, dry_run, components) != ( - "project", "all", "recommended", None, False, False, None + "project", + "all", + "recommended", + None, + False, + False, + None, ): return False source = getattr(ctx, "get_parameter_source", None) diff --git a/src/apiforge/ui/app.py b/src/apiforge/ui/app.py index b0a4ec86..a3b3bb65 100644 --- a/src/apiforge/ui/app.py +++ b/src/apiforge/ui/app.py @@ -58,6 +58,7 @@ class DocMeta: """Contextual doc metadata for an action (Forge Knowledge §21): description, prerequisites, example, risk, expected result and the canonical doc — a pointer, never an inlined manual.""" + description: str = "" prerequisites: str = "" example: str = "" @@ -91,8 +92,14 @@ class _Pane: scroll: int = 0 -_HELP = [("up/dn", "nav"), ("enter", "open/run"), ("r", "refresh"), - ("/", "filter"), ("?", "help"), ("q", "back/quit")] +_HELP = [ + ("up/dn", "nav"), + ("enter", "open/run"), + ("r", "refresh"), + ("/", "filter"), + ("?", "help"), + ("q", "back/quit"), +] def _doc_lines(d: "ActionData") -> list[str]: @@ -117,9 +124,16 @@ def _doc_lines(d: "ActionData") -> list[str]: class ForgeApp: """Two-pane full-screen app: left nav, right content.""" - def __init__(self, ctx: UIContext, *, forge_id: str, title: str, - entries: list[Entry], version: str = "", - out: TextIO | None = None): + def __init__( + self, + ctx: UIContext, + *, + forge_id: str, + title: str, + entries: list[Entry], + version: str = "", + out: TextIO | None = None, + ): self.ctx = ctx self.forge_id = forge_id self.title = title @@ -142,40 +156,52 @@ def _load(self) -> None: try: data = entry.provider() except Exception as exc: # real error state, never a lie - data = StateSpec(kind="error", title="provider failed", - detail=str(exc) or exc.__class__.__name__, - hint="press r to retry") + data = StateSpec( + kind="error", + title="provider failed", + detail=str(exc) or exc.__class__.__name__, + hint="press r to retry", + ) self.pane = _Pane(data=data) def _visible_entries(self) -> list[tuple[int, Entry]]: f = self.filter.lower() - return [(i, e) for i, e in enumerate(self.entries) - if not f or f in e.label.lower()] + return [(i, e) for i, e in enumerate(self.entries) if not f or f in e.label.lower()] # --------------------------------------------------------- render def render(self, buf: Buffer) -> None: ctx, th = self.ctx, self.theme - header(buf, self.forge_id, self.title, theme=th, - right=self.version) + header(buf, self.forge_id, self.title, theme=th, right=self.version) nav_w = max(22, min(34, buf.width // 4)) - ix, iy, iw, ih = panel(buf, 0, 2, nav_w, buf.height - 5, - title=f"menu /{self.filter}" if self.filter_active or self.filter else "menu", - focused=self.focus == "nav", - theme=th) + ix, iy, iw, ih = panel( + buf, + 0, + 2, + nav_w, + buf.height - 5, + title=f"menu /{self.filter}" if self.filter_active or self.filter else "menu", + focused=self.focus == "nav", + theme=th, + ) vis = self._visible_entries() for row, (i, e) in enumerate(vis[:ih]): if row >= ih: break marker = "> " if i == self.nav_idx else " " - style = th.focus if (i == self.nav_idx and self.focus == "nav" - and ctx.color) else "" + style = th.focus if (i == self.nav_idx and self.focus == "nav" and ctx.color) else "" buf.draw(iy + row, ix - 1, marker + e.label, style=style) # content pane - cx, cy, cw, ch = panel(buf, nav_w, 2, buf.width - nav_w, - buf.height - 5, - title=self.entries[self.nav_idx].label, - focused=self.focus == "content", theme=th) + cx, cy, cw, ch = panel( + buf, + nav_w, + 2, + buf.width - nav_w, + buf.height - 5, + title=self.entries[self.nav_idx].label, + focused=self.focus == "content", + theme=th, + ) self._render_content(buf, cx, cy, cw, ch) if self.show_help: self._render_help(buf) @@ -194,19 +220,37 @@ def _render_content(self, buf: Buffer, x: int, y: int, w: int, h: int) -> None: if isinstance(d, TableData): if not d.rows: sub = Buffer(buf.ctx, w, h) - state_view(sub, StateSpec(kind="empty", title="no rows", - detail="nothing to show"), - theme=self.theme) + state_view( + sub, + StateSpec(kind="empty", title="no rows", detail="nothing to show"), + theme=self.theme, + ) for row, line in enumerate(sub.render()): buf.draw(y + row, x, line) return - table(buf, x + 1, y, w - 2, h, columns=d.columns, rows=d.rows, - selected=self.pane.selected, theme=self.theme) + table( + buf, + x + 1, + y, + w - 2, + h, + columns=d.columns, + rows=d.rows, + selected=self.pane.selected, + theme=self.theme, + ) return - lines = d.lines if isinstance(d, TextData) else ( - [f"action: {d.title}"] + _doc_lines(d) + - ["", "press enter to execute"] + self.pane.log) - for i, line in enumerate(lines[self.pane.scroll:self.pane.scroll + h]): + lines = ( + d.lines + if isinstance(d, TextData) + else ( + [f"action: {d.title}"] + + _doc_lines(d) + + ["", "press enter to execute"] + + self.pane.log + ) + ) + for i, line in enumerate(lines[self.pane.scroll : self.pane.scroll + h]): buf.draw(y + i, x, line) def _render_help(self, buf: Buffer) -> None: @@ -364,6 +408,7 @@ def run(self) -> int: if not (ctx.interactive and ctx.ansi): raise NonInteractive("TUI requires an ANSI TTY") import os + with Screen(ctx, out=self._out or sys.stdout) as scr: size = (0, 0) while True: @@ -375,8 +420,7 @@ def run(self) -> int: pass if now != size: size = now - self.ctx = UIContext(**{**ctx.__dict__, - "width": size[0], "height": size[1]}) + self.ctx = UIContext(**{**ctx.__dict__, "width": size[0], "height": size[1]}) ctx = self.ctx buf = Buffer(ctx, size[0], size[1]) if size[0] < MIN_WIDTH or size[1] < MIN_HEIGHT: diff --git a/src/apiforge/ui/screen.py b/src/apiforge/ui/screen.py index 72a1ad2d..2f2fd80f 100644 --- a/src/apiforge/ui/screen.py +++ b/src/apiforge/ui/screen.py @@ -66,16 +66,33 @@ def for_forge(forge_id: str) -> Theme: _STATE_ICON_UNI = { - "empty": "○", "loading": "…", "ready": "●", "ok": "✓", - "error": "✗", "warn": "▲", "disconnected": "⚡", "too_small": "!", + "empty": "○", + "loading": "…", + "ready": "●", + "ok": "✓", + "error": "✗", + "warn": "▲", + "disconnected": "⚡", + "too_small": "!", } _STATE_ICON_ASCII = { - "empty": "o", "loading": "...", "ready": "*", "ok": "+", - "error": "x", "warn": "!", "disconnected": "x", "too_small": "!", + "empty": "o", + "loading": "...", + "ready": "*", + "ok": "+", + "error": "x", + "warn": "!", + "disconnected": "x", + "too_small": "!", } _STATE_COLOR = { - "empty": "dim", "loading": "info", "ready": "ok", "ok": "ok", - "error": "err", "warn": "warn", "disconnected": "warn", + "empty": "dim", + "loading": "info", + "ready": "ok", + "ok": "ok", + "error": "err", + "warn": "warn", + "disconnected": "warn", "too_small": "warn", } @@ -143,8 +160,7 @@ def render(self) -> list[str]: out.append("") continue # later draws overlay earlier: sort by x implicit in draw order - line = "".join(_c(self.ctx, s.style, s.text) if s.style else s.text - for s in spans) + line = "".join(_c(self.ctx, s.style, s.text) if s.style else s.text for s in spans) out.append(line[: self.width * 4]) # ansi-safe bound; clips at SGR return out @@ -152,8 +168,17 @@ def render(self) -> list[str]: # ---------------------------------------------------------------- layout -def panel(buf: Buffer, x: int, y: int, w: int, h: int, *, title: str = "", - focused: bool = False, theme: Theme) -> tuple[int, int, int, int]: +def panel( + buf: Buffer, + x: int, + y: int, + w: int, + h: int, + *, + title: str = "", + focused: bool = False, + theme: Theme, +) -> tuple[int, int, int, int]: """Draw a bordered panel; returns inner (x, y, w, h) for content.""" ctx = buf.ctx b = box(ctx) @@ -162,9 +187,15 @@ def panel(buf: Buffer, x: int, y: int, w: int, h: int, *, title: str = "", if w < 3 or h < 3: 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) + 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 + ) + ) buf.draw(y, x, top, style=border_style) for row in range(1, h - 1): buf.draw(y + row, x, vv, style=border_style) @@ -177,13 +208,11 @@ 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) + 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) -def header(buf: Buffer, forge: str, screen: str, *, theme: Theme, - right: str = "") -> None: +def header(buf: Buffer, forge: str, screen: str, *, theme: Theme, right: str = "") -> None: """2-row header: brand line + accent rule.""" ctx = buf.ctx brand = f" {forge.upper()} " @@ -203,10 +232,10 @@ def header(buf: Buffer, forge: str, screen: str, *, theme: Theme, class StateSpec: """A screen's real state — views render this, never hardcode.""" - kind: str = "ready" # empty|loading|ready|error|disconnected|too_small|warn + kind: str = "ready" # empty|loading|ready|error|disconnected|too_small|warn title: str = "" detail: str = "" - hint: str = "" # recovery hint ("press r to retry") + hint: str = "" # recovery hint ("press r to retry") def state_view(buf: Buffer, state: StateSpec, *, theme: Theme) -> None: @@ -218,19 +247,22 @@ def state_view(buf: Buffer, state: StateSpec, *, theme: Theme) -> None: line = f"{icon} {state.title or state.kind}" buf.draw(cy - 1, max(1, (buf.width - cell_width(line)) // 2), line, style=color) if state.detail: - buf.draw(cy, max(1, (buf.width - cell_width(state.detail)) // 2), - state.detail, style="dim") + buf.draw(cy, max(1, (buf.width - cell_width(state.detail)) // 2), state.detail, style="dim") if state.hint: - buf.draw(cy + 1, max(1, (buf.width - cell_width(state.hint)) // 2), - state.hint) + buf.draw(cy + 1, max(1, (buf.width - cell_width(state.hint)) // 2), state.hint) def too_small(buf: Buffer, *, theme: Theme) -> None: - state_view(buf, StateSpec( - kind="too_small", - title=f"terminal too small ({buf.width}x{buf.height})", - detail=f"need at least {MIN_WIDTH}x{MIN_HEIGHT}", - hint="resize to continue"), theme=theme) + state_view( + buf, + StateSpec( + kind="too_small", + title=f"terminal too small ({buf.width}x{buf.height})", + detail=f"need at least {MIN_WIDTH}x{MIN_HEIGHT}", + hint="resize to continue", + ), + theme=theme, + ) # ---------------------------------------------------------------- table @@ -243,9 +275,18 @@ class Column: width: int | None = None # None = flexible -def table(buf: Buffer, x: int, y: int, w: int, h: int, *, - columns: list[Column], rows: list[dict[str, str]], - selected: int = 0, theme: Theme) -> None: +def table( + buf: Buffer, + x: int, + y: int, + w: int, + h: int, + *, + columns: list[Column], + rows: list[dict[str, str]], + selected: int = 0, + theme: Theme, +) -> None: """Selectable table: fixed columns, flexible last column, scrolls.""" ctx = buf.ctx fixed = sum(c.width for c in columns if c.width) + 2 * (len(columns) - 1) @@ -259,7 +300,7 @@ def table(buf: Buffer, x: int, y: int, w: int, h: int, *, cx += cw + 2 body_h = h - 2 start = max(0, min(selected - body_h + 1, len(rows) - body_h)) - for i, row in enumerate(rows[start:start + body_h]): + for i, row in enumerate(rows[start : start + body_h]): idx = start + i cx = x marker = ">" if idx == selected else " " @@ -317,6 +358,7 @@ def __enter__(self) -> _RawMode: if os.name != "nt" and sys.stdin.isatty(): import termios import tty + self._fd = sys.stdin.fileno() self._saved = termios.tcgetattr(self._fd) tty.setcbreak(self._fd) @@ -325,32 +367,57 @@ def __enter__(self) -> _RawMode: def __exit__(self, *exc) -> bool: if getattr(self, "_saved", None) is not None: import termios + termios.tcsetattr(self._fd, termios.TCSADRAIN, self._saved) return False -_ESC = {"\x1b[A": "up", "\x1b[B": "down", "\x1b[C": "right", - "\x1b[D": "left", "\x1b[H": "home", "\x1b[F": "end", - "\x1b[5~": "pageup", "\x1b[6~": "pagedown", "\x1b[Z": "backtab"} -_KEYS = {"\r": "enter", "\n": "enter", " ": "space", "\t": "tab", - "\x1b": "esc", "\x7f": "backspace", "\x03": "ctrl-c"} +_ESC = { + "\x1b[A": "up", + "\x1b[B": "down", + "\x1b[C": "right", + "\x1b[D": "left", + "\x1b[H": "home", + "\x1b[F": "end", + "\x1b[5~": "pageup", + "\x1b[6~": "pagedown", + "\x1b[Z": "backtab", +} +_KEYS = { + "\r": "enter", + "\n": "enter", + " ": "space", + "\t": "tab", + "\x1b": "esc", + "\x7f": "backspace", + "\x03": "ctrl-c", +} def read_key(timeout: float = 0.05) -> str | None: """Non-blocking key read; returns a normalized name or None.""" if os.name == "nt": import msvcrt + if not msvcrt.kbhit(): time.sleep(timeout) return None ch = msvcrt.getwch() if ch in ("\x00", "\xe0"): # special key prefix - return {"H": "up", "P": "down", "M": "right", - "K": "left", "G": "home", "O": "end", - "I": "pageup", "Q": "pagedown"}.get(msvcrt.getwch()) + return { + "H": "up", + "P": "down", + "M": "right", + "K": "left", + "G": "home", + "O": "end", + "I": "pageup", + "Q": "pagedown", + }.get(msvcrt.getwch()) return _KEYS.get(ch, ch) # POSIX — select-based poll import select + r, _, _ = select.select([sys.stdin], [], [], timeout) if not r: return None From 338e10dea6ae12601a701b0023783085f12aade8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Sun, 11 Oct 2026 21:15:45 -0300 Subject: [PATCH 06/14] fix(install): synchronize wizard execution profile contract --- src/apiforge/ui/i18n.py | 2 ++ src/apiforge/ui/wizard.py | 29 +++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/apiforge/ui/i18n.py b/src/apiforge/ui/i18n.py index 7f437e47..3b193e82 100644 --- a/src/apiforge/ui/i18n.py +++ b/src/apiforge/ui/i18n.py @@ -23,6 +23,7 @@ "toggle_hint": "space toggles, enter confirms; j/k moves", "install_scope_q": "Where do you want to install?", "install_profile_q": "Installation profile?", + "install_gateway_profile_q": "Execution profile?", "install_hosts_q": "Configure AI hosts?", "install_components_q": "Optional components?", "review": "Review installation", @@ -78,6 +79,7 @@ "toggle_hint": "espaço alterna, enter confirma; j/k move", "install_scope_q": "Onde instalar?", "install_profile_q": "Perfil de instalação?", + "install_gateway_profile_q": "Perfil de execução?", "install_hosts_q": "Configurar hosts de IA?", "install_components_q": "Componentes opcionais?", "review": "Revisar instalação", diff --git a/src/apiforge/ui/wizard.py b/src/apiforge/ui/wizard.py index 4388fe0d..4ffd1368 100644 --- a/src/apiforge/ui/wizard.py +++ b/src/apiforge/ui/wizard.py @@ -35,6 +35,12 @@ "minimal": "Economy", "full": "Full", } +_GATEWAY_PROFILES = ("economy", "balanced", "deep") +_GATEWAY_LABELS = { + "economy": "Economy (lowest context cost)", + "balanced": "Balanced (recommended)", + "deep": "Deep (maximum verification)", +} _SCOPE_LABELS = { "project": "Current repository", "workspace": "Entire workspace", @@ -105,6 +111,16 @@ def run_wizard( return 130 profile = _PROFILES[idx] + # --- execution profile -------------------------------------------------- + idx = select( + i18n.t("install_gateway_profile_q", ctx.language), + [_GATEWAY_LABELS[p] for p in _GATEWAY_PROFILES], + ctx=ctx, + ) + if idx is None: + return 130 + gateway_profile = _GATEWAY_PROFILES[idx] + # --- optional components --------------------------------------------------- # The profile decides the default; the operator can narrow or extend it. # "graph-studio"/"tui" are runtime flags persisted to components.json — @@ -157,7 +173,12 @@ def run_wizard( # --- review (real dry-run plan) ------------------------------------------ # Contrato explícito: install_fn aceita `components` — wrappers **kw # encaminham sem inspeção de assinatura (GAP-002). - kwargs: dict[str, Any] = {"scope": scope, "profile": profile, "components": components} + kwargs: dict[str, Any] = { + "scope": scope, + "profile": profile, + "gateway_profile": gateway_profile, + "components": components, + } try: # `none` é opt-out explícito — zero hosts escolhidos nunca vira # "all" (GAP-003); csv de subconjunto é contrato válido (GAP-003b). @@ -175,6 +196,7 @@ def run_wizard( [ ("scope", scope), ("profile", profile), + ("execution", gateway_profile), ("components", ", ".join(components) or "none"), ("hosts", host_arg or "none"), ("managed writes", str(len(planned) if isinstance(planned, list) else planned)), @@ -204,7 +226,10 @@ def run_wizard( # --- verify -------------------------------------------------------------- if doctor_fn is not None and status == "completed": health = doctor_fn(scope=scope) - hstatus = (health.get("status") or health.get("health") or {}).get("status", "unknown") + raw_health = health.get("status") or health.get("health") or "unknown" + hstatus = raw_health + if isinstance(raw_health, dict): + hstatus = raw_health.get("status", "unknown") color = "32" if hstatus in ("healthy", "PASS") else "33" print(f" {i18n.t('health', ctx.language)}: {_c(ctx, color, str(hstatus))}") From 900320edb51946edaf13de373477a31923bba59b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Mon, 12 Oct 2026 01:39:45 -0300 Subject: [PATCH 07/14] fix(ui): harden Rui terminal adaptation --- src/apiforge/ui/app.py | 55 +++++++++++++++-------- src/apiforge/ui/kit.py | 26 +++++++++-- src/apiforge/ui/screen.py | 87 ++++++++++++++++++++++++++++++------- tests/test_ui_resilience.py | 38 ++++++++++++++++ 4 files changed, 168 insertions(+), 38 deletions(-) create mode 100644 tests/test_ui_resilience.py diff --git a/src/apiforge/ui/app.py b/src/apiforge/ui/app.py index a3b3bb65..ee75bd9d 100644 --- a/src/apiforge/ui/app.py +++ b/src/apiforge/ui/app.py @@ -22,7 +22,7 @@ from dataclasses import dataclass, field from typing import TextIO -from apiforge.ui.kit import NonInteractive, UIContext +from apiforge.ui.kit import NonInteractive, UIContext, terminal_size from apiforge.ui.screen import ( MIN_HEIGHT, MIN_WIDTH, @@ -152,6 +152,9 @@ def __init__( # --------------------------------------------------------- data def _load(self) -> None: + if not self.entries: + self.pane = _Pane(data=StateSpec(kind="empty", title="no menu entries")) + return entry = self.entries[self.nav_idx] try: data = entry.provider() @@ -185,9 +188,10 @@ def render(self, buf: Buffer) -> None: theme=th, ) vis = self._visible_entries() - for row, (i, e) in enumerate(vis[:ih]): - if row >= ih: - break + ids = [i for i, _ in vis] + selected_pos = ids.index(self.nav_idx) if self.nav_idx in ids else 0 + start = min(max(0, selected_pos - ih + 1), max(0, len(vis) - ih)) + for row, (i, e) in enumerate(vis[start : start + ih]): marker = "> " if i == self.nav_idx else " " style = th.focus if (i == self.nav_idx and self.focus == "nav" and ctx.color) else "" buf.draw(iy + row, ix - 1, marker + e.label, style=style) @@ -273,7 +277,7 @@ def _render_help(self, buf: Buffer) -> None: # --------------------------------------------------------- keys def on_key(self, key: str) -> str | None: - if key == "ctrl-c": + if key in ("ctrl-c", "ctrl-d"): return "quit" if self.filter_active: return self._filter_key(key) @@ -321,6 +325,10 @@ def _sync_filter_selection(self) -> None: def _nav_key(self, key: str) -> str | None: vis = self._visible_entries() + if not vis: + if key in ("q", "esc"): + return "quit" + return None if key in ("up", "k"): ids = [i for i, _ in vis] pos = ids.index(self.nav_idx) if self.nav_idx in ids else 0 @@ -365,8 +373,17 @@ def _content_key(self, key: str) -> str | None: elif key == "end": self.pane.selected = len(d.rows) - 1 elif key == "enter" and d.on_enter: - d.on_enter(d.rows[self.pane.selected]) - self._load() + try: + d.on_enter(d.rows[self.pane.selected]) + except Exception as exc: + self.pane = _Pane(data=StateSpec( + kind="error", + title="row action failed", + detail=str(exc) or exc.__class__.__name__, + hint="press r to retry", + )) + else: + self._load() elif isinstance(d, ActionData) and key == "enter": return "exec" # loop decides suspend vs in-place elif isinstance(d, (TextData, ActionData)): @@ -385,18 +402,24 @@ def _content_key(self, key: str) -> str | None: self.pane.scroll = max(0, line_count - 1) return None - def _execute(self, d: ActionData) -> None: - """Run the real callable; capture its stdout into the pane.""" + def _execute(self, d: ActionData, *, capture: bool = True) -> None: + """Run real callable; capture inline or leave suspended output attached.""" self.pane.log = [f"$ {d.title}", ""] sink = io.StringIO() try: - with redirect_stdout(sink), redirect_stderr(sink): + if capture: + with redirect_stdout(sink), redirect_stderr(sink): + result = d.callable() + else: result = d.callable() except Exception: self.pane.log += ["", "FAILED:", traceback.format_exc(limit=3)] return - out = sink.getvalue() - self.pane.log += out.rstrip().splitlines() or ["(no output)"] + out = sink.getvalue() if capture else "" + if capture: + self.pane.log += out.rstrip().splitlines() or ["(no output)"] + else: + self.pane.log.append("(output streamed in attached terminal)") if result is not None: self.pane.log += ["", f"exit: {result}"] self.pane.log += ["", "— done · r refresh · esc back"] @@ -413,11 +436,7 @@ def run(self) -> int: size = (0, 0) while True: now = (ctx.width, ctx.height) - try: - sz = os.get_terminal_size() - now = (sz.columns, sz.lines) - except OSError: - pass + now = terminal_size(stream=self._out or sys.stdout, fallback=(ctx.width, ctx.height)) if now != size: size = now self.ctx = UIContext(**{**ctx.__dict__, "width": size[0], "height": size[1]}) @@ -440,7 +459,7 @@ def run(self) -> int: if d.suspend: scr.__exit__(None, None, None) try: - self._execute(d) + self._execute(d, capture=False) finally: scr.__enter__() else: diff --git a/src/apiforge/ui/kit.py b/src/apiforge/ui/kit.py index f93e0cf4..d834bfb6 100644 --- a/src/apiforge/ui/kit.py +++ b/src/apiforge/ui/kit.py @@ -62,18 +62,32 @@ def detect(*, force_plain: bool = False, language: str | None = None) -> UIConte no_color = bool(os.environ.get("NO_COLOR")) or not ansi encoding = (sys.stdout.encoding or "").lower() uni = "utf" in encoding - size = shutil.get_terminal_size((80, 24)) + size = terminal_size() return UIContext( interactive=interactive, color=not no_color, unicode=uni, - width=size.columns, - height=size.lines, + width=size[0], + height=size[1], ansi=ansi, language=language or lang(), ) +def terminal_size(*, stream: TextIO | None = None, fallback: tuple[int, int] = (80, 24)) -> tuple[int, int]: + """Return usable terminal cells, preferring active stream and clamping bad probes.""" + try: + if stream is not None: + size = os.get_terminal_size(stream.fileno()) + raw = (size.columns, size.lines) + else: + size = shutil.get_terminal_size(fallback) + raw = (size.columns, size.lines) + except (AttributeError, OSError, ValueError): + raw = fallback + return max(1, int(raw[0])), max(1, int(raw[1])) + + def _c(ctx: UIContext, code: str, text: str) -> str: return f"\x1b[{code}m{text}\x1b[0m" if ctx.color else text @@ -99,7 +113,9 @@ def _pad_visible(text: str, width: int) -> str: def _ellipsize(ctx: UIContext, text: str, width: int | None = None) -> str: - w = min(width or ctx.width - 2, ctx.width - 2) + w = min(width if width is not None else ctx.width - 2, ctx.width - 2) + if w <= 0: + return "" if cell_width(text) <= w: return text out, used = [], 0 @@ -141,6 +157,8 @@ def _read_key() -> str: return _CTRL_C return f"char:{ch}" ch = sys.stdin.read(1) # pragma: no cover - platform + if not ch: # pragma: no cover - platform + return "ctrl-d" if ch == "\x1b": # pragma: no cover - platform # A bare Esc must not block: the CSI suffix may never arrive. Poll # briefly for "[A"-style sequences; timeout means it was just Esc. diff --git a/src/apiforge/ui/screen.py b/src/apiforge/ui/screen.py index 2f2fd80f..920f2769 100644 --- a/src/apiforge/ui/screen.py +++ b/src/apiforge/ui/screen.py @@ -35,6 +35,7 @@ _ellipsize, _pad_visible, cell_width, + terminal_size, ) MIN_WIDTH = 60 @@ -136,13 +137,14 @@ class Buffer: def __init__(self, ctx: UIContext, width: int, height: int): self.ctx = ctx - self.width = width - self.height = height - self._rows: list[list[_Span]] = [[] for _ in range(height)] + self.width = max(1, width) + self.height = max(1, height) + self._rows: list[list[_Span]] = [[] for _ in range(self.height)] def draw(self, y: int, x: int, text: str, *, style: str = "") -> None: if not (0 <= y < self.height) or x >= self.width: return + x = max(0, x) clipped = _clip(text, self.width - x) if not clipped: return @@ -275,6 +277,39 @@ class Column: width: int | None = None # None = flexible +def _column_widths(width: int, columns: list[Column]) -> list[int]: + """Fit fixed and flexible columns inside viewport, preserving every column.""" + if not columns: + return [] + gaps = 2 * (len(columns) - 1) + budget = max(len(columns), width - gaps) + fixed = [i for i, column in enumerate(columns) if column.width is not None] + flexible = [i for i, column in enumerate(columns) if column.width is None] + widths = [1] * len(columns) + flex_min = 8 if budget >= (len(flexible) * 8 + len(fixed)) else 1 + fixed_budget = max(0, budget - len(flexible) * flex_min) + desired = [max(1, int(columns[i].width or 1)) for i in fixed] + desired_total = sum(desired) + if fixed: + if desired_total <= fixed_budget: + for i, value in zip(fixed, desired, strict=True): + widths[i] = value + else: + remaining = fixed_budget + for pos, (i, value) in enumerate(zip(fixed, desired, strict=True)): + slots_left = len(fixed) - pos + share = min(value, max(1, remaining - (slots_left - 1))) + widths[i] = share + remaining -= share + remaining = max(0, budget - sum(widths[i] for i in fixed)) + for pos, i in enumerate(flexible): + slots_left = len(flexible) - pos + share = max(1, remaining // slots_left) if remaining else 1 + widths[i] = share + remaining = max(0, remaining - share) + return widths + + def table( buf: Buffer, x: int, @@ -289,10 +324,7 @@ def table( ) -> None: """Selectable table: fixed columns, flexible last column, scrolls.""" ctx = buf.ctx - fixed = sum(c.width for c in columns if c.width) + 2 * (len(columns) - 1) - flex = [c for c in columns if c.width is None] - flex_w = max(8, (w - fixed) // max(1, len(flex))) if flex else 0 - widths = [c.width or flex_w for c in columns] + widths = _column_widths(w, columns) # header cx = x for c, cw in zip(columns, widths, strict=True): @@ -413,7 +445,7 @@ def read_key(timeout: float = 0.05) -> str | None: "O": "end", "I": "pageup", "Q": "pagedown", - }.get(msvcrt.getwch()) + }.get(msvcrt.getwch(), "other") return _KEYS.get(ch, ch) # POSIX — select-based poll import select @@ -421,8 +453,35 @@ def read_key(timeout: float = 0.05) -> str | None: r, _, _ = select.select([sys.stdin], [], [], timeout) if not r: return None - ch = os.read(sys.stdin.fileno(), 8).decode(errors="replace") - return _ESC.get(ch) or _KEYS.get(ch) or ch + try: + ch = os.read(sys.stdin.fileno(), 1).decode(errors="replace") + except OSError: + return "ctrl-d" + if not ch: + return "ctrl-d" + if ch != "\x1b": + return _KEYS.get(ch) or ch + seq = ch + deadline = time.monotonic() + min(0.06, max(timeout, 0.0) + 0.01) + while len(seq) < 8: + remain = max(0.0, deadline - time.monotonic()) + if not remain: + break + ready, _, _ = select.select([sys.stdin], [], [], remain) + if not ready: + break + try: + part = os.read(sys.stdin.fileno(), 1).decode(errors="replace") + except OSError: + break + if not part: + break + seq += part + if seq in _ESC: + return _ESC[seq] + if part.isalpha() or part == "~": + break + return _ESC.get(seq, "esc") # ---------------------------------------------------------------- loop @@ -461,11 +520,7 @@ def run_loop( while True: now = (ctx.width, ctx.height) # refresh size every tick (SIGWINCH arrives between polls) - try: - sz = os.get_terminal_size() - now = (sz.columns, sz.lines) - except OSError: - pass + now = terminal_size(fallback=(ctx.width, ctx.height)) if now != size: size = now ctx = UIContext(**{**ctx.__dict__, "width": size[0], "height": size[1]}) @@ -478,7 +533,7 @@ def run_loop( key = read_key() if key is None: continue - if key == "ctrl-c" or (key in ("q", "esc") and on_key is None): + if key in ("ctrl-c", "ctrl-d") or (key in ("q", "esc") and on_key is None): return result action = on_key(key) if on_key else None if action == "quit": diff --git a/tests/test_ui_resilience.py b/tests/test_ui_resilience.py new file mode 100644 index 00000000..3c0f6be6 --- /dev/null +++ b/tests/test_ui_resilience.py @@ -0,0 +1,38 @@ +from apiforge.ui.app import ActionData, Entry, ForgeApp, TableData, TextData +from apiforge.ui.kit import UIContext +from apiforge.ui.screen import Buffer, Column, Theme, table + + +def _ctx(width=60, height=16): + return UIContext(interactive=False, color=False, unicode=False, width=width, height=height, ansi=False) + + +def test_narrow_table_keeps_flexible_column_visible(): + buf = Buffer(_ctx(), 31, 10) + table(buf, 2, 1, 27, 8, + columns=[Column(key="name", title="name", width=28), Column(key="detail", title="detail")], + rows=[{"name": "specialist", "detail": "ready"}], theme=Theme()) + rendered = "\n".join(buf.render()) + assert "NAME" in rendered and "DETAIL" in rendered and "ready" in rendered + + +def test_long_navigation_keeps_selected_item_rendered(): + entries = [Entry(label=f"item-{i}", provider=lambda: TextData(lines=["ok"])) for i in range(30)] + app = ForgeApp(_ctx(), forge_id="api-forge", title="t", entries=entries) + for _ in range(25): + app.on_key("down") + buf = Buffer(_ctx(), 60, 16) + app.render(buf) + assert "item-25" in "\n".join(buf.render()) + + +def test_suspended_action_runs_without_capture(): + seen = [] + app = ForgeApp(_ctx(), forge_id="api-forge", title="t", entries=[ + Entry(label="go", provider=lambda: ActionData(callable=lambda: seen.append("ran"), title="run", suspend=True)) + ]) + action = app.pane.data + assert isinstance(action, ActionData) + app._execute(action, capture=False) + assert seen == ["ran"] + assert "attached terminal" in "\n".join(app.pane.log) From f785c0058a0239c597277858159b881090b2d570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Mon, 12 Oct 2026 01:40:10 -0300 Subject: [PATCH 08/14] docs(ui): record Rui terminal compatibility --- docs/experience/accessibility.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/experience/accessibility.md b/docs/experience/accessibility.md index f0327b18..30d21886 100644 --- a/docs/experience/accessibility.md +++ b/docs/experience/accessibility.md @@ -7,8 +7,8 @@ The UI kit is stdlib-only and capability-detected: - **No color dependency**: `NO_COLOR` and `TERM=dumb` strip all ANSI styling; state is always carried by text (`OK`/`FAIL`/status words), never by color alone. -- **Narrow terminals**: content truncates with a visible ellipsis - instead of wrapping or hiding; minimum useful width is 80 columns. +- **Narrow terminals**: full-screen layout requires 60×16; tables rebalance + columns, menus keep selection visible, and values truncate explicitly. - **Limited charset**: on non-UTF-8 codepages the kit falls back to ASCII glyphs (`>`, `x`, `->`). - **Reduced motion**: no animations — menus redraw in place on keypress @@ -16,6 +16,15 @@ The UI kit is stdlib-only and capability-detected: - **Screen readers / automation**: every interactive flow has a linear headless equivalent (flags + JSON output); see `automation.md`. +## Terminal compatibility + +Rui probes active terminal dimensions on each render cycle. It supports +80×24, 100×30, 120×40, and 160×50 layouts, plus smaller terminals through +the explicit `terminal too small` state. `NO_COLOR`, `TERM=dumb`, non-UTF-8 +codepages, PowerShell, Windows Terminal, SSH, pipes, and closed input follow +capability-aware fallbacks. Real external PTY/ConPTY validation remains +unverified when no observable host session is available. + ## Language `FORGE_LANG=pt|en` selects the UI language (pt-BR and en built in); From 7c5dfb279079a1fddd0440b3da65b8e14d0aeb4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Mon, 12 Oct 2026 01:41:17 -0300 Subject: [PATCH 09/14] fix(ui): render empty navigation safely --- src/apiforge/ui/app.py | 2 +- tests/test_ui_resilience.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/apiforge/ui/app.py b/src/apiforge/ui/app.py index ee75bd9d..b9c609ce 100644 --- a/src/apiforge/ui/app.py +++ b/src/apiforge/ui/app.py @@ -202,7 +202,7 @@ def render(self, buf: Buffer) -> None: 2, buf.width - nav_w, buf.height - 5, - title=self.entries[self.nav_idx].label, + title=self.entries[self.nav_idx].label if self.entries else "menu", focused=self.focus == "content", theme=th, ) diff --git a/tests/test_ui_resilience.py b/tests/test_ui_resilience.py index 3c0f6be6..2dda1885 100644 --- a/tests/test_ui_resilience.py +++ b/tests/test_ui_resilience.py @@ -36,3 +36,10 @@ def test_suspended_action_runs_without_capture(): app._execute(action, capture=False) assert seen == ["ran"] assert "attached terminal" in "\n".join(app.pane.log) + + +def test_empty_menu_renders_empty_state(): + app = ForgeApp(_ctx(), forge_id="api-forge", title="t", entries=[]) + buf = Buffer(_ctx(), 60, 16) + app.render(buf) + assert "no menu entries" in "\n".join(buf.render()) From dcbd973c6ac778dc13e3af5d059320c27aa4f1c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Mon, 12 Oct 2026 01:45:22 -0300 Subject: [PATCH 10/14] fix(ui): compose terminal cells within viewport --- src/apiforge/ui/screen.py | 76 ++++++++++++++++++++++++++++++++++--- tests/test_ui_resilience.py | 8 ++++ 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/src/apiforge/ui/screen.py b/src/apiforge/ui/screen.py index 920f2769..074d1199 100644 --- a/src/apiforge/ui/screen.py +++ b/src/apiforge/ui/screen.py @@ -115,14 +115,30 @@ def state_icon(ctx: UIContext, state: str) -> str: @dataclass(kw_only=True) class _Span: + x: int text: str style: str = "" # ANSI SGR code, "" = plain +_SGR_RE = re.compile(r"\x1b\[([0-9;]*)m") + + def _clip(text: str, width: int) -> str: """Hard cell-width clip — canvas chrome never gets an ellipsis marker.""" - out, used = [], 0 - for ch in text: + if width <= 0: + return "" + out, used, pos = [], 0, 0 + for match in _SGR_RE.finditer(text): + chunk = text[pos : match.start()] + for ch in chunk: + cw = cell_width(ch) + if used + cw > width: + return "".join(out) + "\x1b[0m" if out else "" + out.append(ch) + used += cw + out.append(match.group(0)) + pos = match.end() + for ch in text[pos:]: cw = cell_width(ch) if used + cw > width: break @@ -148,7 +164,7 @@ def draw(self, y: int, x: int, text: str, *, style: str = "") -> None: clipped = _clip(text, self.width - x) if not clipped: return - self._rows[y].append(_Span(text=" " * x + clipped, style=style)) + self._rows[y].append(_Span(x=x, text=clipped, style=style)) def hline(self, y: int, x: int = 0, w: int | None = None, *, style: str = "") -> None: b = box(self.ctx) @@ -161,9 +177,57 @@ def render(self) -> list[str]: if not spans: out.append("") continue - # later draws overlay earlier: sort by x implicit in draw order - line = "".join(_c(self.ctx, s.style, s.text) if s.style else s.text for s in spans) - out.append(line[: self.width * 4]) # ansi-safe bound; clips at SGR + cells = [" "] * self.width + styles = [""] * self.width + + def paint(chunk: str, cursor: int, style: str) -> int: + for ch in chunk: + cw = cell_width(ch) + if cw == 0: + if cursor and cells[cursor - 1] != "": + cells[cursor - 1] += ch + continue + if cursor >= self.width or cursor + cw > self.width: + break + cells[cursor] = ch + styles[cursor] = style + for offset in range(1, cw): + cells[cursor + offset] = "" + styles[cursor + offset] = style + cursor += cw + return cursor + + for span in spans: + cursor = max(0, span.x) + active = span.style + pos = 0 + for match in _SGR_RE.finditer(span.text): + cursor = paint(span.text[pos : match.start()], cursor, active) + code = match.group(1) + active = "" if code == "0" else code + pos = match.end() + paint(span.text[pos:], cursor, active) + + rendered: list[str] = [] + current_style = "" + segment: list[str] = [] + + def flush() -> None: + if not segment: + return + text = "".join(segment) + rendered.append(_c(self.ctx, current_style, text) if current_style else text) + segment.clear() + + for ch, style in zip(cells, styles, strict=True): + if ch == "": + continue + if style != current_style: + flush() + current_style = style + segment.append(ch) + flush() + out.append("".join(rendered)) return out diff --git a/tests/test_ui_resilience.py b/tests/test_ui_resilience.py index 2dda1885..5a2788d8 100644 --- a/tests/test_ui_resilience.py +++ b/tests/test_ui_resilience.py @@ -43,3 +43,11 @@ def test_empty_menu_renders_empty_state(): buf = Buffer(_ctx(), 60, 16) app.render(buf) assert "no menu entries" in "\n".join(buf.render()) + + +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) + buf.draw(0, 0, "left") + buf.draw(0, width - 5, "right") + assert len(buf.render()[0]) <= width From 3689cf3477a1c9d43b1024b3afd775bad711fd89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Mon, 12 Oct 2026 01:48:52 -0300 Subject: [PATCH 11/14] test(ui): cover terminal input and viewport matrix --- tests/test_ui_resilience.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_ui_resilience.py b/tests/test_ui_resilience.py index 5a2788d8..e2e85e8f 100644 --- a/tests/test_ui_resilience.py +++ b/tests/test_ui_resilience.py @@ -1,3 +1,4 @@ +from apiforge.ui import screen from apiforge.ui.app import ActionData, Entry, ForgeApp, TableData, TextData from apiforge.ui.kit import UIContext from apiforge.ui.screen import Buffer, Column, Theme, table @@ -51,3 +52,19 @@ def test_buffer_viewport_matrix_stays_bounded(): buf.draw(0, 0, "left") buf.draw(0, width - 5, "right") assert len(buf.render()[0]) <= width + + +def test_posix_reader_exits_on_eof_and_normalizes_csi(monkeypatch): + if screen.os.name == "nt": + return + import select as select_module + class ClosedStdin: + def fileno(self): + return 0 + monkeypatch.setattr(screen.sys, "stdin", ClosedStdin()) + monkeypatch.setattr(select_module, "select", lambda *args: ([0], [], [])) + monkeypatch.setattr(screen.os, "read", lambda *_args: b"") + assert screen.read_key(0) == "ctrl-d" + chunks = iter((b"\x1b", b"[", b"A")) + monkeypatch.setattr(screen.os, "read", lambda *_args: next(chunks)) + assert screen.read_key(0) == "up" From 91952820c069a8212bef4a86599affaaad6291e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Mon, 12 Oct 2026 02:10:57 -0300 Subject: [PATCH 12/14] style(ui): satisfy API Rui lint gate --- tests/test_ui_resilience.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_ui_resilience.py b/tests/test_ui_resilience.py index e2e85e8f..ac385a3a 100644 --- a/tests/test_ui_resilience.py +++ b/tests/test_ui_resilience.py @@ -1,5 +1,5 @@ from apiforge.ui import screen -from apiforge.ui.app import ActionData, Entry, ForgeApp, TableData, TextData +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 8d14e2a2326ee26ab1ff09f01fc9cd9f71075f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Mon, 12 Oct 2026 02:41:57 -0300 Subject: [PATCH 13/14] refactor(ui): align Rui compositor with lint gates --- src/apiforge/ui/app.py | 21 ++++---- src/apiforge/ui/kit.py | 6 ++- src/apiforge/ui/screen.py | 103 ++++++++++++++++++++++-------------- tests/test_ui_resilience.py | 35 +++++++++--- 4 files changed, 108 insertions(+), 57 deletions(-) diff --git a/src/apiforge/ui/app.py b/src/apiforge/ui/app.py index b9c609ce..477ac76e 100644 --- a/src/apiforge/ui/app.py +++ b/src/apiforge/ui/app.py @@ -376,12 +376,14 @@ def _content_key(self, key: str) -> str | None: try: d.on_enter(d.rows[self.pane.selected]) except Exception as exc: - self.pane = _Pane(data=StateSpec( - kind="error", - title="row action failed", - detail=str(exc) or exc.__class__.__name__, - hint="press r to retry", - )) + self.pane = _Pane( + data=StateSpec( + kind="error", + title="row action failed", + detail=str(exc) or exc.__class__.__name__, + hint="press r to retry", + ) + ) else: self._load() elif isinstance(d, ActionData) and key == "enter": @@ -430,13 +432,14 @@ def run(self) -> int: ctx = self.ctx if not (ctx.interactive and ctx.ansi): raise NonInteractive("TUI requires an ANSI TTY") - import os - with Screen(ctx, out=self._out or sys.stdout) as scr: size = (0, 0) while True: now = (ctx.width, ctx.height) - now = terminal_size(stream=self._out or sys.stdout, fallback=(ctx.width, ctx.height)) + now = terminal_size( + stream=self._out or sys.stdout, + fallback=(ctx.width, ctx.height), + ) if now != size: size = now self.ctx = UIContext(**{**ctx.__dict__, "width": size[0], "height": size[1]}) diff --git a/src/apiforge/ui/kit.py b/src/apiforge/ui/kit.py index d834bfb6..a90d1ac3 100644 --- a/src/apiforge/ui/kit.py +++ b/src/apiforge/ui/kit.py @@ -74,7 +74,11 @@ def detect(*, force_plain: bool = False, language: str | None = None) -> UIConte ) -def terminal_size(*, stream: TextIO | None = None, fallback: tuple[int, int] = (80, 24)) -> tuple[int, int]: +def terminal_size( + *, + stream: TextIO | None = None, + fallback: tuple[int, int] = (80, 24), +) -> tuple[int, int]: """Return usable terminal cells, preferring active stream and clamping bad probes.""" try: if stream is not None: diff --git a/src/apiforge/ui/screen.py b/src/apiforge/ui/screen.py index 074d1199..1d4bf7ea 100644 --- a/src/apiforge/ui/screen.py +++ b/src/apiforge/ui/screen.py @@ -147,6 +147,51 @@ def _clip(text: str, width: int) -> str: return "".join(out) +def _paint_cells( + cells: list[str], + styles: list[str], + chunk: str, + cursor: int, + style: str, + width: int, +) -> int: + for ch in chunk: + cw = cell_width(ch) + if cw == 0: + if cursor and cells[cursor - 1] != "": + cells[cursor - 1] += ch + continue + if cursor >= width or cursor + cw > width: + break + cells[cursor] = ch + styles[cursor] = style + for offset in range(1, cw): + cells[cursor + offset] = "" + styles[cursor + offset] = style + cursor += cw + return cursor + + +def _render_cells(ctx: UIContext, cells: list[str], styles: list[str]) -> str: + rendered: list[str] = [] + current_style = "" + segment: list[str] = [] + for ch, style in zip(cells, styles, strict=True): + if ch == "": + continue + if style != current_style: + if segment: + text = "".join(segment) + rendered.append(_c(ctx, current_style, text) if current_style else text) + segment.clear() + current_style = style + segment.append(ch) + if segment: + text = "".join(segment) + rendered.append(_c(ctx, current_style, text) if current_style else text) + return "".join(rendered) + + class Buffer: """Fixed-size cell grid. ``draw()`` writes styled spans; ``render()`` returns a list of finished lines (ANSI when ctx.color).""" @@ -180,54 +225,32 @@ def render(self) -> list[str]: cells = [" "] * self.width styles = [""] * self.width - def paint(chunk: str, cursor: int, style: str) -> int: - for ch in chunk: - cw = cell_width(ch) - if cw == 0: - if cursor and cells[cursor - 1] != "": - cells[cursor - 1] += ch - continue - if cursor >= self.width or cursor + cw > self.width: - break - cells[cursor] = ch - styles[cursor] = style - for offset in range(1, cw): - cells[cursor + offset] = "" - styles[cursor + offset] = style - cursor += cw - return cursor - for span in spans: cursor = max(0, span.x) active = span.style pos = 0 for match in _SGR_RE.finditer(span.text): - cursor = paint(span.text[pos : match.start()], cursor, active) + cursor = _paint_cells( + cells, + styles, + span.text[pos : match.start()], + cursor, + active, + self.width, + ) code = match.group(1) active = "" if code == "0" else code pos = match.end() - paint(span.text[pos:], cursor, active) - - rendered: list[str] = [] - current_style = "" - segment: list[str] = [] - - def flush() -> None: - if not segment: - return - text = "".join(segment) - rendered.append(_c(self.ctx, current_style, text) if current_style else text) - segment.clear() - - for ch, style in zip(cells, styles, strict=True): - if ch == "": - continue - if style != current_style: - flush() - current_style = style - segment.append(ch) - flush() - out.append("".join(rendered)) + _paint_cells( + cells, + styles, + span.text[pos:], + cursor, + active, + self.width, + ) + + out.append(_render_cells(self.ctx, cells, styles)) return out diff --git a/tests/test_ui_resilience.py b/tests/test_ui_resilience.py index ac385a3a..bef439ac 100644 --- a/tests/test_ui_resilience.py +++ b/tests/test_ui_resilience.py @@ -5,14 +5,23 @@ def _ctx(width=60, height=16): - return UIContext(interactive=False, color=False, unicode=False, width=width, height=height, ansi=False) + return UIContext( + interactive=False, color=False, unicode=False, width=width, height=height, ansi=False + ) def test_narrow_table_keeps_flexible_column_visible(): buf = Buffer(_ctx(), 31, 10) - table(buf, 2, 1, 27, 8, - columns=[Column(key="name", title="name", width=28), Column(key="detail", title="detail")], - rows=[{"name": "specialist", "detail": "ready"}], theme=Theme()) + table( + buf, + 2, + 1, + 27, + 8, + columns=[Column(key="name", title="name", width=28), Column(key="detail", title="detail")], + rows=[{"name": "specialist", "detail": "ready"}], + theme=Theme(), + ) rendered = "\n".join(buf.render()) assert "NAME" in rendered and "DETAIL" in rendered and "ready" in rendered @@ -29,9 +38,19 @@ def test_long_navigation_keeps_selected_item_rendered(): def test_suspended_action_runs_without_capture(): seen = [] - app = ForgeApp(_ctx(), forge_id="api-forge", title="t", entries=[ - Entry(label="go", provider=lambda: ActionData(callable=lambda: seen.append("ran"), title="run", suspend=True)) - ]) + app = ForgeApp( + _ctx(), + forge_id="api-forge", + title="t", + entries=[ + Entry( + label="go", + provider=lambda: ActionData( + callable=lambda: seen.append("ran"), title="run", suspend=True + ), + ) + ], + ) action = app.pane.data assert isinstance(action, ActionData) app._execute(action, capture=False) @@ -58,9 +77,11 @@ def test_posix_reader_exits_on_eof_and_normalizes_csi(monkeypatch): if screen.os.name == "nt": return import select as select_module + class ClosedStdin: def fileno(self): return 0 + monkeypatch.setattr(screen.sys, "stdin", ClosedStdin()) monkeypatch.setattr(select_module, "select", lambda *args: ([0], [], [])) monkeypatch.setattr(screen.os, "read", lambda *_args: b"") From 3893a0931320924f924e75e91f9d63f6bdfe9cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20S=C3=B3crates=20Caria?= Date: Mon, 12 Oct 2026 02:54:29 -0300 Subject: [PATCH 14/14] fix(ui): close Rui quality gates --- src/apiforge/ui/screen.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/apiforge/ui/screen.py b/src/apiforge/ui/screen.py index 1d4bf7ea..d0764849 100644 --- a/src/apiforge/ui/screen.py +++ b/src/apiforge/ui/screen.py @@ -127,7 +127,8 @@ def _clip(text: str, width: int) -> str: """Hard cell-width clip — canvas chrome never gets an ellipsis marker.""" if width <= 0: return "" - out, used, pos = [], 0, 0 + out: list[str] = [] + used, pos = 0, 0 for match in _SGR_RE.finditer(text): chunk = text[pos : match.start()] for ch in chunk: