Skip to content

Commit 00577f3

Browse files
Merge pull request #826 from corbitsdev/cl-6791-tui-split
Split the TUI god-files and gate repaints on change
2 parents 8ffbf2c + c000182 commit 00577f3

140 files changed

Lines changed: 14464 additions & 12254 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/TUI.md‎

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ authorization (`/mcp` is the surface that names them), painted in
8484
not spent on these standing marks. The brand
8585
lockup sits at the left of the bottom rule with the working directory and git
8686
branch at its right (`AppShell.promptTopRule` / `promptBottomRule`,
87-
`src/tui/shell.ts`). Context occupancy rides that bottom rule as a percent:
87+
`src/tui/shell/internals.ts`). Context occupancy rides that bottom rule as a percent:
8888
0–60 `UI.textDim`, 61–80 `UI.warning`, 81–100 `UI.error`; an optional cost
8989
suffix stays dim. Both rules cost zero transcript rows because they
9090
ride the prompt box's own border.
@@ -265,7 +265,7 @@ for `/status` or an operator question mid-run.
265265
A blocking surface (permissions, an operator question, the model/provider
266266
picker, help) occupies the shell's **single overlay host**
267267
(`src/tui/geometry/resolve.ts`,
268-
`src/tui/shell.ts:openListOverlay`). A second command surface replaces a
268+
`src/tui/shell/overlay-host.ts:openListOverlay`). A second command surface replaces a
269269
non-gate list on that host, or waits with a system line while a live
270270
gate holds it. Palette may stack over a primary; Escape always walks
271271
back along a single path to the prompt.
@@ -316,17 +316,23 @@ transcript, because that text would otherwise be unreachable before
316316
approval. That dump carries no gutter label.
317317

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

331337
## How selectors should work
332338

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

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

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

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

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

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

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

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

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

634-
The prompt repaints on every keystroke (`onFrame` in `shell.ts` calls
640+
The prompt repaints on every keystroke (`onFrame` in `src/tui/shell/index.ts` calls
635641
`syncPromptRows`/`syncTranscriptSpacer`/`syncNoticeAfterLayout` every frame,
636642
not on a debounce) — anything added to the prompt's paint path must stay
637643
cheap, because it runs at typing speed.
@@ -642,7 +648,7 @@ attachments. Clearing prompt text arms a 2-second quit window
642648
Ctrl+C while the window is open quits — this
643649
replaced an Ink-era yes/no exit-confirm modal with the same intent (an
644650
explicit second confirmation) without adding a modal (`handleCtrlC`,
645-
`shell.ts`). See "Soft steer vs. follow-up" above for the two
651+
`src/tui/shell/prompt.ts`). See "Soft steer vs. follow-up" above for the two
646652
mid-run gestures and what interrupting does to fleet-agent lanes. The interrupt
647653
keeps whatever is sitting in the queue rather than discarding it — the
648654
operator typed those messages meaning them delivered, not meaning "cancel
@@ -671,7 +677,7 @@ running its own selection. Two chords cover remaining copy needs:
671677
`ttlMs: RUNTIME_FLASH_MS` so they clear themselves; omit TTL only for
672678
live conditions that stay true until replaced (stall notice, landing hold).
673679
- **Alt+M** toggles DEC mouse reporting off and back on
674-
(`toggleMouseCapture`, `shell.ts`). Off, the terminal's own drag-select
680+
(`toggleMouseCapture`, `src/tui/shell/copy.ts`). Off, the terminal's own drag-select
675681
and copy work exactly as in any other terminal program; the status flash
676682
names the trade both ways ("Mouse released · drag to select and copy as
677683
usual · Alt+M to click rows" / "Mouse captured · drag text to copy ·

‎src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { classifyErrorClass } from "./telemetry/classify.js";
1313
import { getTelemetry, setTelemetry } from "./telemetry/singleton.js";
1414
import { runExec } from "./exec/runner.js";
1515
import { runOnboarding } from "./tui/onboarding.js";
16-
import { runTUI } from "./tui/runner.js";
16+
import { runTUI } from "./tui/runner/index.js";
1717

1818
export interface Runners {
1919
runTUI: (config: import("./config/index.js").Config) => Promise<number>;

‎src/tui/README.md‎

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,20 @@ Shipping OpenTUI shell and co-located TUI modules. Pure TypeScript / imperative
44

55
## Modules
66

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

1514
## Live chrome zones
1615

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

1918
```ts
20-
import { formatChromeZones, setChromeZones } from "./index";
19+
import { formatChromeZones } from "./chrome-state";
20+
import { setChromeZones } from "./shell/chrome.js";
2121

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

3737
```ts
38-
import {
39-
appendObserveStreamRow,
40-
appendStreamRow,
41-
enterSubagentObserve,
42-
leaveSubagentObserve,
43-
} from "./shell";
38+
import { appendStreamRow, appendObserveStreamRow } from "./shell/chrome.js";
39+
import { enterSubagentObserve, leaveSubagentObserve } from "./shell/observe.js";
4440

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

6460
```ts
65-
import { createAppShell, appendTranscript } from "./shell";
61+
import { createAppShell } from "./shell/index.js";
62+
import { appendTranscript } from "./shell/chrome.js";
6663

6764
// renderer from createCliRenderer() or createTestRenderer()
6865
const shell = createAppShell(renderer, { title: "corbits" });

‎src/tui/approval-prompt-visibility.test.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
* the prompt box's growth and over the overlay's own context text.
66
*/
77
import { describe, expect, test } from "bun:test";
8-
import { withTestRenderer } from "./harness.js";
9-
import { createAppShell, appendStreamRow, type AppShell } from "./shell.js";
10-
import { openPermissionsOverlay, makePermissionItems } from "./overlays.js";
8+
import { makePermissionItems, withTestRenderer } from "./harness.js";
9+
import { appendStreamRow } from "./shell/chrome.js";
10+
import { createAppShell } from "./shell/index.js";
11+
import type { AppShell } from "./shell/internals.js";
12+
import { openPermissionsOverlay } from "./overlays.js";
1113

1214
const WIDTH = 80;
1315
// Deliberately spans from far below the documented 24-row baseline down to

‎src/tui/chrome-repaint.test.ts‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/**
2+
* Chrome repaint gating (CL-6791 J2): paintChrome recomposes only when a
3+
* composed input changed, so idle poll ticks cost nothing.
4+
*/
5+
import { describe, expect, test } from "bun:test";
6+
import { withTestRenderer } from "./harness";
7+
import { chromeComposeCount, paintChrome, setLockupFrame, setStatusFlash } from "./shell/chrome";
8+
import { createAppShell } from "./shell/index";
9+
import type { AppShell } from "./shell/internals";
10+
11+
async function withShell(fn: (shell: AppShell) => void, columns = 80): Promise<void> {
12+
await withTestRenderer(
13+
async (h) => {
14+
const shell = createAppShell(h.renderer, {
15+
title: "test",
16+
cwd: "/src/corbits-code",
17+
terminal: { columns, rows: 24 },
18+
wireKeys: false,
19+
});
20+
try {
21+
fn(shell);
22+
} finally {
23+
shell.dispose();
24+
}
25+
},
26+
{ width: columns, height: 24 },
27+
);
28+
}
29+
30+
describe("chrome repaint gate", () => {
31+
test("idle ticks do not recompose", async () => {
32+
await withShell((shell) => {
33+
paintChrome(shell);
34+
const baseline = chromeComposeCount(shell);
35+
// What stickyPoll does every 200ms while fully idle.
36+
for (let tick = 0; tick < 10; tick++) paintChrome(shell);
37+
expect(chromeComposeCount(shell)).toBe(baseline);
38+
});
39+
});
40+
41+
test("flipping each composed input individually recomposes exactly once", async () => {
42+
await withShell((shell) => {
43+
paintChrome(shell);
44+
const baseline = chromeComposeCount(shell);
45+
46+
// Notice text (status flash feeds the notice row). The extra recompose
47+
// is the notice row appearing: visibility flips trigger a relayout whose
48+
// trailing chrome pass is forced by design.
49+
setStatusFlash(shell, "hold on");
50+
paintChrome(shell);
51+
const afterNotice = chromeComposeCount(shell);
52+
expect(afterNotice).toBeGreaterThan(baseline);
53+
54+
// Workspace label.
55+
shell.workspace = { ...shell.workspace, branch: "feature/x" };
56+
paintChrome(shell);
57+
expect(chromeComposeCount(shell)).toBe(afterNotice + 1);
58+
59+
// Border column budget.
60+
shell.layout = { ...shell.layout, contentWidth: 60 };
61+
paintChrome(shell);
62+
expect(chromeComposeCount(shell)).toBe(afterNotice + 2);
63+
64+
// Lockup frame state.
65+
setLockupFrame(shell, {
66+
nowMs: 5_000,
67+
animating: true,
68+
phase: "working",
69+
rampPhase: null,
70+
stalledForMs: null,
71+
});
72+
expect(chromeComposeCount(shell)).toBe(afterNotice + 3);
73+
paintChrome(shell);
74+
expect(chromeComposeCount(shell)).toBe(afterNotice + 3);
75+
});
76+
});
77+
78+
test("animating lockup frames recompose per frame", async () => {
79+
await withShell((shell) => {
80+
paintChrome(shell);
81+
const baseline = chromeComposeCount(shell);
82+
for (let frame = 0; frame < 3; frame++) {
83+
setLockupFrame(shell, {
84+
nowMs: 10_000 + frame * 80,
85+
animating: true,
86+
phase: "working",
87+
rampPhase: null,
88+
stalledForMs: null,
89+
});
90+
}
91+
expect(chromeComposeCount(shell)).toBe(baseline + 3);
92+
});
93+
});
94+
95+
test("forced repaint recomposes despite an unchanged tuple", async () => {
96+
await withShell((shell) => {
97+
paintChrome(shell);
98+
const baseline = chromeComposeCount(shell);
99+
paintChrome(shell, { force: true });
100+
paintChrome(shell, { force: true });
101+
expect(chromeComposeCount(shell)).toBe(baseline + 2);
102+
// And the gate still holds afterwards.
103+
paintChrome(shell);
104+
expect(chromeComposeCount(shell)).toBe(baseline + 2);
105+
});
106+
});
107+
});

‎src/tui/collapse.test.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,9 @@ import { describe, expect, test } from "bun:test";
77
import { toolCallRow } from "./diff";
88
import { resolveSideMargin } from "./geometry/margins";
99
import { withTestRenderer } from "./harness";
10-
import {
11-
appendStreamRow,
12-
createAppShell,
13-
toggleCollapsedRow,
14-
shellFocusTranscript,
15-
type AppShell,
16-
} from "./shell";
10+
import { appendStreamRow, toggleCollapsedRow, shellFocusTranscript } from "./shell/chrome";
11+
import { createAppShell } from "./shell/index";
12+
import type { AppShell } from "./shell/internals";
1713
import {
1814
EXPAND_HINT_LABEL,
1915
isCollapsibleRow,

‎src/tui/command-registry-setup.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, test, expect } from "bun:test";
2-
import { setUpCommandRegistry } from "./runner.js";
2+
import { setUpCommandRegistry } from "./runner/commands.js";
33
import { getCommand, listCommands } from "./commands/registry.js";
44
import type { PluginConfig } from "../config/settings.js";
55
import type { PluginModule } from "../plugins/loader.js";

‎src/tui/command-surfaces.test.ts‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,15 @@ import type { KeyEvent } from "@opentui/core";
2323
import { focusOwner } from "./focus/index.js";
2424
import { withTestRenderer, type Harness } from "./harness";
2525
import { projectPluginsRoot, userPluginsRoot } from "../plugins/uninstall.js";
26+
import { createAppShell } from "./shell/index";
27+
import type { AppShell } from "./shell/internals";
28+
import { acceptOverlaySelection, closeInsetOverlay, openListOverlay } from "./shell/overlay-host";
2629
import {
27-
acceptOverlaySelection,
28-
closeInsetOverlay,
29-
createAppShell,
3030
cycleOverlaySelection,
3131
moveOverlaySelection,
32-
openListOverlay,
33-
openPalette,
3432
runOverlayAction,
35-
type AppShell,
36-
} from "./shell";
33+
} from "./shell/overlay-list";
34+
import { openPalette } from "./shell/palette";
3735

3836
function baseSnapshot(): SettingsSnapshot {
3937
return {

0 commit comments

Comments
 (0)