Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0fc64b8
Add a TUI file budget ratchet and remove the unused barrel
TheGreatAxios Sep 7, 2026
380f289
Enforce the TUI budget guard in CI and harden it
TheGreatAxios Sep 7, 2026
92c0cff
Adopt the OpenTUI clipboard service in the TUI host
TheGreatAxios Sep 7, 2026
eed1bcb
Render TUI lists through the OpenTUI select renderable
TheGreatAxios Sep 7, 2026
81da568
Split the TUI shell into single-concern modules
TheGreatAxios Sep 7, 2026
bba5667
Fix clipboard crash, selection resets, and decision clipping
TheGreatAxios Sep 7, 2026
5afd415
Pin the TUI shell import graph in a test
TheGreatAxios Sep 7, 2026
0de945e
Split provider setup into focused modules
TheGreatAxios Sep 7, 2026
967150a
Split the TUI runner into focused modules
TheGreatAxios Sep 7, 2026
b32e08a
Remove the dead choice row counter and widen the graph glob
TheGreatAxios Sep 7, 2026
d233c8d
Coalesce stream row updates to one retext per frame
TheGreatAxios Sep 7, 2026
8b1ccd4
Recompose chrome only when its inputs change
TheGreatAxios Sep 7, 2026
15d2b68
Retext non-markdown rows in place at frame cadence
TheGreatAxios Sep 7, 2026
f65cde1
Fix gutter failure dim, runner cycle, and stale row updates
TheGreatAxios Sep 7, 2026
448328c
Remove OpenTUI re-tests from the overlay list suite
TheGreatAxios Sep 7, 2026
266f1fb
Remove the TUI guardrails from lint, CI, and the test suite
TheGreatAxios Sep 7, 2026
0119839
Merge origin/main into the TUI split
TheGreatAxios Sep 8, 2026
234698d
Simplify decision choices to bare action names with context above
TheGreatAxios Sep 8, 2026
52af711
Merge origin/main into the TUI split
TheGreatAxios Sep 8, 2026
c000182
Correct stale overlay docstring, duplicate import, and doc claim
TheGreatAxios Sep 8, 2026
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
58 changes: 32 additions & 26 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ authorization (`/mcp` is the surface that names them), painted in
not spent on these standing marks. The brand
lockup sits at the left of the bottom rule with the working directory and git
branch at its right (`AppShell.promptTopRule` / `promptBottomRule`,
`src/tui/shell.ts`). Context occupancy rides that bottom rule as a percent:
`src/tui/shell/internals.ts`). Context occupancy rides that bottom rule as a percent:
0–60 `UI.textDim`, 61–80 `UI.warning`, 81–100 `UI.error`; an optional cost
suffix stays dim. Both rules cost zero transcript rows because they
ride the prompt box's own border.
Expand Down Expand Up @@ -265,7 +265,7 @@ for `/status` or an operator question mid-run.
A blocking surface (permissions, an operator question, the model/provider
picker, help) occupies the shell's **single overlay host**
(`src/tui/geometry/resolve.ts`,
`src/tui/shell.ts:openListOverlay`). A second command surface replaces a
`src/tui/shell/overlay-host.ts:openListOverlay`). A second command surface replaces a
non-gate list on that host, or waits with a system line while a live
gate holds it. Palette may stack over a primary; Escape always walks
back along a single path to the prompt.
Expand Down Expand Up @@ -316,17 +316,23 @@ transcript, because that text would otherwise be unreachable before
approval. That dump carries no gutter label.

The decision surfaces (permission approval, operator question) are the one
framed content in the shell, and they are shaped rather than merely listed
(`src/tui/overlay-body.ts`): a dithered header (`░▒▓`) carries the
subject in the action color — the only Breakthrough Orange on the card.
The overlay host border and title use calm dim chrome (`UI.textDim`);
consequence impact in the description zone paints `UI.warning` (sand), not
orange. A blank row separates the subject from context. Choices wrap on word
boundaries — never middle-ellipsized — to a shared row count at the current
width (minimum two rows so short labels still breathe; a taller wrap raises
every choice to the same height so list paging stays a simple multiple). The
active choice is marked by a solid block (`█`) rather than a background fill
(cream text, not orange).
framed content in the shell, and their body is shaped rather than merely
listed (`src/tui/overlay-body.ts`): a dithered header (`░▒▓`) carries the
subject in the action color — the only Breakthrough Orange on the card. The
overlay host border and title use calm dim chrome (`UI.textDim`); consequence
impact in the description zone paints `UI.warning` (sand), not orange. A
blank row separates the subject from context. Choices are deliberately small:
each one is a bare, single-line action name (`Reject`, `Accept once`, the
scope's label) with no consequence text folded into the row. A scope's hint
paints instead as a body message above the choice list
(`permissionBodyFromRequest` in `src/tui/gate-wire.ts`), and the expand key,
which binds only when the subject carries collapsed payloads, reveals the
full body — collapsed payloads and hints alike — in the overlay
and, whole, in the transcript. Every choice reserves the same fixed two rows
(label plus a row of air) so list paging stays a simple multiple. The active
choice is marked by text color alone — cream (`UI.text`) against the dim rows
— with no leading marker, block, or background fill (`createOverlayList` in
`src/tui/shell/overlay-list.ts`).

## How selectors should work

Expand All @@ -344,7 +350,7 @@ explicit pick and can go stale (`ProductHostConfig.activeModelId`'s doc
comment and `annotateCurrent` in `src/tui/product-host.ts`).

The `/` command list specifically (`src/tui/command-catalog.ts`,
`shell.ts:openPalette`/`repaintPalette`): width matches the prompt box — both
`src/tui/shell/palette.ts:openPalette`/`repaintPalette`): width matches the prompt box — both
are painted at the geometry resolver's shared `contentWidth`
(`geometry/resolve.ts:assignRects`, `overlay-view.ts:overlayRowWidth`). There is no
leading marker column and no per-row kind column; the selected row is marked
Expand Down Expand Up @@ -378,14 +384,14 @@ queued gate); Enter then dismisses and leaves the prompt as typed (`/z`).
Every entry is backed by the live command registry
(`src/tui/command-catalog.ts:commandItemsFromRegistry`) — there is no
separate palette overlay and no shell-owned action outside the registry. The
overlay this reuses is still internally called `"palette"` (`shell.ts`'s
overlay this reuses is still internally called `"palette"` (`src/tui/shell/internals.ts`'s
`PrimaryOverlayKind`), a naming leftover from when a Ctrl+O command palette
also opened it; that chord is gone (see keybindings.ts), and the identifier
stayed because renaming an internal overlay tag has no user-facing effect.

`?` no longer binds anything — it is a literal character everywhere, prompt
or transcript. The shortcut list it used to open is still reachable, as
`/help` (`src/tui/commands/built-in.ts`, routed to `shell.ts:openHelpOverlay`
`/help` (`src/tui/commands/built-in.ts`, routed to `src/tui/shell/palette.ts:openHelpOverlay`
via `openCommandSurface`'s `"help"` case, `command-surfaces.ts`); the `/` row
in `SHELL_SHORTCUTS` documents that in place of a dedicated `?` row.

Expand All @@ -408,7 +414,7 @@ permissions. An 80-column terminal still seats the compact mark next to
them; when the terminal is too narrow, the hints win and the mark drops.

The running build version is chrome, not part of the landing composition:
`shell.ts`'s `versionRow`/`versionBadge`, a dedicated row pinned to the
`src/tui/shell/index.ts`'s `versionRow`/`versionBadge`, a dedicated row pinned to the
terminal's last line and right-aligned, distinct from `landing.ts`'s hero and
below sections. It only reserves that row while the landing screen is
showing (`relayout`'s `versionReserved`/`terminalForGeometry`) — once there
Expand All @@ -427,7 +433,7 @@ runs — sees one row fewer than the real terminal. The badge does not sit in
way the task or agents panel is. An operator composing a long prompt on the
landing screen at, say, 23 rows gets an 8-row cap instead of 9. This is a
known, accepted cost of the badge rather than an oversight — see
`terminalForGeometry`'s doc comment in `shell.ts` for the exact mechanism.
`terminalForGeometry`'s doc comment in `src/tui/shell/layout.ts` for the exact mechanism.

While the landing is mounted, a mount-scoped 125ms timer advances snow
across a frozen mountain. It is cancelled on the first real transcript
Expand Down Expand Up @@ -586,13 +592,13 @@ Up/Down are caret motion first inside a multi-line buffer. History recall
only fires when the caret is already at the first or last wrapped row of the
buffer — i.e., has nowhere further to go
(`promptCaretAtFirstRow`/`promptCaretAtLastRow` in `prompt-input.ts`,
consumed in `shell.ts`'s key handler). This is deliberate, not incidental:
consumed in `src/tui/shell/keys.ts`'s key handler). This is deliberate, not incidental:
with DEC mouse reporting on, a terminal translates a wheel tick into the same
arrow-key byte sequence as a real keypress, so scroll and history navigation
cannot both be arrow-driven at the same time without one shadowing the
other. That is also why the main shell routes the mouse wheel to the
transcript rather than the prompt even when the wheel event hits the prompt's
own hit-tested region (`routePromptWheelToTranscript`, `shell.ts`) — arrow
own hit-tested region (`routePromptWheelToTranscript`, `src/tui/shell/keys.ts`) — arrow
keys stay history/caret, wheel stays transcript scroll, and the two never
collide.

Expand All @@ -602,17 +608,17 @@ paste replayed as raw keystrokes on a terminal that never sends a real
`paste` event, so pasted multi-line text does not get split into multiple
sent messages. Once a real `paste` event has fired even once, the fallback
heuristic is permanently skipped for the rest of the session
(`shell.ts`, the `sawBracketedPaste` guard).
(`src/tui/shell/keys.ts`, the `sawBracketedPaste` guard).

Ctrl+V and Ctrl+P attach a PNG from the macOS clipboard
(`attachClipboardImage` in `shell.ts` → `readClipboardImage` in
(`attachClipboardImage` in `src/tui/shell/prompt.ts` → `readClipboardImage` in
`image-attachments.ts`).
Cmd+V stays text (bracketed paste above). Clipboard image attach is
macOS-only; Linux/Windows bitmap clipboard paste is not supported.
`/paste-image` is the same attach path.

@-mention path completion opens a popup keyed off the `@token` under the
cursor (`openAtMentionSuggestions`, `src/tui/shell.ts`); every keystroke re-queries,
cursor (`openAtMentionSuggestions`, `src/tui/shell/internals.ts`); every keystroke re-queries,
and a generation counter discards a slower, stale query's results if a newer
one already landed. Accept is refused unless that generation is still current
and a live `@` token is under the cursor (the same `@` the lookup started on).
Expand All @@ -631,7 +637,7 @@ Consecutive kills in the same direction accumulate into one ring entry the
way readline does, so a `Ctrl+K Ctrl+K … Ctrl+Y` sequence restores the whole
killed run in original order.

The prompt repaints on every keystroke (`onFrame` in `shell.ts` calls
The prompt repaints on every keystroke (`onFrame` in `src/tui/shell/index.ts` calls
`syncPromptRows`/`syncTranscriptSpacer`/`syncNoticeAfterLayout` every frame,
not on a debounce) — anything added to the prompt's paint path must stay
cheap, because it runs at typing speed.
Expand All @@ -642,7 +648,7 @@ attachments. Clearing prompt text arms a 2-second quit window
Ctrl+C while the window is open quits — this
replaced an Ink-era yes/no exit-confirm modal with the same intent (an
explicit second confirmation) without adding a modal (`handleCtrlC`,
`shell.ts`). See "Soft steer vs. follow-up" above for the two
`src/tui/shell/prompt.ts`). See "Soft steer vs. follow-up" above for the two
mid-run gestures and what interrupting does to fleet-agent lanes. The interrupt
keeps whatever is sitting in the queue rather than discarding it — the
operator typed those messages meaning them delivered, not meaning "cancel
Expand Down Expand Up @@ -671,7 +677,7 @@ running its own selection. Two chords cover remaining copy needs:
`ttlMs: RUNTIME_FLASH_MS` so they clear themselves; omit TTL only for
live conditions that stay true until replaced (stall notice, landing hold).
- **Alt+M** toggles DEC mouse reporting off and back on
(`toggleMouseCapture`, `shell.ts`). Off, the terminal's own drag-select
(`toggleMouseCapture`, `src/tui/shell/copy.ts`). Off, the terminal's own drag-select
and copy work exactly as in any other terminal program; the status flash
names the trade both ways ("Mouse released · drag to select and copy as
usual · Alt+M to click rows" / "Mouse captured · drag text to copy ·
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { classifyErrorClass } from "./telemetry/classify.js";
import { getTelemetry, setTelemetry } from "./telemetry/singleton.js";
import { runExec } from "./exec/runner.js";
import { runOnboarding } from "./tui/onboarding.js";
import { runTUI } from "./tui/runner.js";
import { runTUI } from "./tui/runner/index.js";

export interface Runners {
runTUI: (config: import("./config/index.js").Config) => Promise<number>;
Expand Down
27 changes: 12 additions & 15 deletions src/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@ Shipping OpenTUI shell and co-located TUI modules. Pure TypeScript / imperative

## Modules

| Path | Role |
| ------------------ | --------------------------------------------------------------- |
| `geometry/` | Pure zone registry + `resolveGeometry` |
| `focus/` | Focus tree + scroll lease state machine |
| `list-viewport.ts` | Pure list windowing kit |
| `chrome-state.ts` | Live task/agents → `setChromeZones` lines |
| `shell.ts` | App shell frame (`createAppShell`) — OpenTUI **core class** API |
| Path | Role |
| ----------------- | ----------------------------------------------------------------------- |
| `geometry/` | Pure zone registry + `resolveGeometry` |
| `focus/` | Focus tree + scroll lease state machine |
| `chrome-state.ts` | Live task/agents → `setChromeZones` lines |
| `shell/` | App shell split (see `shell/internals.ts`) — OpenTUI **core class** API |

## Live chrome zones

Product host owns task / subagent state and pushes snapshots (event or poll):

```ts
import { formatChromeZones, setChromeZones } from "./index";
import { formatChromeZones } from "./chrome-state";
import { setChromeZones } from "./shell/chrome.js";

// On task/subagent change:
setChromeZones(
Expand All @@ -35,12 +35,8 @@ setChromeZones(
Host enters with real child rows + label; appends child events while focused; Esc restores parent.

```ts
import {
appendObserveStreamRow,
appendStreamRow,
enterSubagentObserve,
leaveSubagentObserve,
} from "./shell";
import { appendStreamRow, appendObserveStreamRow } from "./shell/chrome.js";
import { enterSubagentObserve, leaveSubagentObserve } from "./shell/observe.js";

enterSubagentObserve(shell, {
sessionId: child.id,
Expand All @@ -62,7 +58,8 @@ Demo/fixture path (`makeObserveFixture`) is unchanged for `v` / palette observe.
## App shell

```ts
import { createAppShell, appendTranscript } from "./shell";
import { createAppShell } from "./shell/index.js";
import { appendTranscript } from "./shell/chrome.js";

// renderer from createCliRenderer() or createTestRenderer()
const shell = createAppShell(renderer, { title: "corbits" });
Expand Down
8 changes: 5 additions & 3 deletions src/tui/approval-prompt-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@
* the prompt box's growth and over the overlay's own context text.
*/
import { describe, expect, test } from "bun:test";
import { withTestRenderer } from "./harness.js";
import { createAppShell, appendStreamRow, type AppShell } from "./shell.js";
import { openPermissionsOverlay, makePermissionItems } from "./overlays.js";
import { makePermissionItems, withTestRenderer } from "./harness.js";
import { appendStreamRow } from "./shell/chrome.js";
import { createAppShell } from "./shell/index.js";
import type { AppShell } from "./shell/internals.js";
import { openPermissionsOverlay } from "./overlays.js";

const WIDTH = 80;
// Deliberately spans from far below the documented 24-row baseline down to
Expand Down
107 changes: 107 additions & 0 deletions src/tui/chrome-repaint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* Chrome repaint gating (CL-6791 J2): paintChrome recomposes only when a
* composed input changed, so idle poll ticks cost nothing.
*/
import { describe, expect, test } from "bun:test";
import { withTestRenderer } from "./harness";
import { chromeComposeCount, paintChrome, setLockupFrame, setStatusFlash } from "./shell/chrome";
import { createAppShell } from "./shell/index";
import type { AppShell } from "./shell/internals";

async function withShell(fn: (shell: AppShell) => void, columns = 80): Promise<void> {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
title: "test",
cwd: "/src/corbits-code",
terminal: { columns, rows: 24 },
wireKeys: false,
});
try {
fn(shell);
} finally {
shell.dispose();
}
},
{ width: columns, height: 24 },
);
}

describe("chrome repaint gate", () => {
test("idle ticks do not recompose", async () => {
await withShell((shell) => {
paintChrome(shell);
const baseline = chromeComposeCount(shell);
// What stickyPoll does every 200ms while fully idle.
for (let tick = 0; tick < 10; tick++) paintChrome(shell);
expect(chromeComposeCount(shell)).toBe(baseline);
});
});

test("flipping each composed input individually recomposes exactly once", async () => {
await withShell((shell) => {
paintChrome(shell);
const baseline = chromeComposeCount(shell);

// Notice text (status flash feeds the notice row). The extra recompose
// is the notice row appearing: visibility flips trigger a relayout whose
// trailing chrome pass is forced by design.
setStatusFlash(shell, "hold on");
paintChrome(shell);
const afterNotice = chromeComposeCount(shell);
expect(afterNotice).toBeGreaterThan(baseline);

// Workspace label.
shell.workspace = { ...shell.workspace, branch: "feature/x" };
paintChrome(shell);
expect(chromeComposeCount(shell)).toBe(afterNotice + 1);

// Border column budget.
shell.layout = { ...shell.layout, contentWidth: 60 };
paintChrome(shell);
expect(chromeComposeCount(shell)).toBe(afterNotice + 2);

// Lockup frame state.
setLockupFrame(shell, {
nowMs: 5_000,
animating: true,
phase: "working",
rampPhase: null,
stalledForMs: null,
});
expect(chromeComposeCount(shell)).toBe(afterNotice + 3);
paintChrome(shell);
expect(chromeComposeCount(shell)).toBe(afterNotice + 3);
});
});

test("animating lockup frames recompose per frame", async () => {
await withShell((shell) => {
paintChrome(shell);
const baseline = chromeComposeCount(shell);
for (let frame = 0; frame < 3; frame++) {
setLockupFrame(shell, {
nowMs: 10_000 + frame * 80,
animating: true,
phase: "working",
rampPhase: null,
stalledForMs: null,
});
}
expect(chromeComposeCount(shell)).toBe(baseline + 3);
});
});

test("forced repaint recomposes despite an unchanged tuple", async () => {
await withShell((shell) => {
paintChrome(shell);
const baseline = chromeComposeCount(shell);
paintChrome(shell, { force: true });
paintChrome(shell, { force: true });
expect(chromeComposeCount(shell)).toBe(baseline + 2);
// And the gate still holds afterwards.
paintChrome(shell);
expect(chromeComposeCount(shell)).toBe(baseline + 2);
});
});
});
10 changes: 3 additions & 7 deletions src/tui/collapse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,9 @@ import { describe, expect, test } from "bun:test";
import { toolCallRow } from "./diff";
import { resolveSideMargin } from "./geometry/margins";
import { withTestRenderer } from "./harness";
import {
appendStreamRow,
createAppShell,
toggleCollapsedRow,
shellFocusTranscript,
type AppShell,
} from "./shell";
import { appendStreamRow, toggleCollapsedRow, shellFocusTranscript } from "./shell/chrome";
import { createAppShell } from "./shell/index";
import type { AppShell } from "./shell/internals";
import {
EXPAND_HINT_LABEL,
isCollapsibleRow,
Expand Down
2 changes: 1 addition & 1 deletion src/tui/command-registry-setup.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test";
import { setUpCommandRegistry } from "./runner.js";
import { setUpCommandRegistry } from "./runner/commands.js";
import { getCommand, listCommands } from "./commands/registry.js";
import type { PluginConfig } from "../config/settings.js";
import type { PluginModule } from "../plugins/loader.js";
Expand Down
12 changes: 5 additions & 7 deletions src/tui/command-surfaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,15 @@ import type { KeyEvent } from "@opentui/core";
import { focusOwner } from "./focus/index.js";
import { withTestRenderer, type Harness } from "./harness";
import { projectPluginsRoot, userPluginsRoot } from "../plugins/uninstall.js";
import { createAppShell } from "./shell/index";
import type { AppShell } from "./shell/internals";
import { acceptOverlaySelection, closeInsetOverlay, openListOverlay } from "./shell/overlay-host";
import {
acceptOverlaySelection,
closeInsetOverlay,
createAppShell,
cycleOverlaySelection,
moveOverlaySelection,
openListOverlay,
openPalette,
runOverlayAction,
type AppShell,
} from "./shell";
} from "./shell/overlay-list";
import { openPalette } from "./shell/palette";

function baseSnapshot(): SettingsSnapshot {
return {
Expand Down
Loading
Loading