diff --git a/docs/TUI.md b/docs/TUI.md index 612c0db18..d44e42db9 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -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. @@ -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. @@ -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 @@ -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 @@ -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. @@ -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 @@ -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 @@ -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. @@ -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). @@ -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. @@ -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 @@ -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 · diff --git a/src/index.ts b/src/index.ts index 17ad08c76..310f9e21b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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; diff --git a/src/tui/README.md b/src/tui/README.md index 2d598caba..dd61f9839 100644 --- a/src/tui/README.md +++ b/src/tui/README.md @@ -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( @@ -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, @@ -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" }); diff --git a/src/tui/approval-prompt-visibility.test.ts b/src/tui/approval-prompt-visibility.test.ts index 9c25c652e..66f23a07f 100644 --- a/src/tui/approval-prompt-visibility.test.ts +++ b/src/tui/approval-prompt-visibility.test.ts @@ -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 diff --git a/src/tui/chrome-repaint.test.ts b/src/tui/chrome-repaint.test.ts new file mode 100644 index 000000000..656e1d37f --- /dev/null +++ b/src/tui/chrome-repaint.test.ts @@ -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 { + 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); + }); + }); +}); diff --git a/src/tui/collapse.test.ts b/src/tui/collapse.test.ts index 37d4b1876..f64704ec8 100644 --- a/src/tui/collapse.test.ts +++ b/src/tui/collapse.test.ts @@ -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, diff --git a/src/tui/command-registry-setup.test.ts b/src/tui/command-registry-setup.test.ts index c98712dfc..e3093e51d 100644 --- a/src/tui/command-registry-setup.test.ts +++ b/src/tui/command-registry-setup.test.ts @@ -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"; diff --git a/src/tui/command-surfaces.test.ts b/src/tui/command-surfaces.test.ts index 88659a722..9666e6e8c 100644 --- a/src/tui/command-surfaces.test.ts +++ b/src/tui/command-surfaces.test.ts @@ -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 { diff --git a/src/tui/command-surfaces.ts b/src/tui/command-surfaces.ts index 2e7ecf29f..9a381ef0a 100644 --- a/src/tui/command-surfaces.ts +++ b/src/tui/command-surfaces.ts @@ -12,24 +12,22 @@ import { isAbsoluteHTTPURL, validateMCPServerName } from "../mcp/add-server.js"; import { formatPluginWarningsSummary } from "../plugins/diagnostics.js"; import type { PluginOrigin } from "../plugins/admin.js"; import { classifyPluginRemove, isOwnedDiskInstall } from "../plugins/uninstall.js"; -import { maskEcho, maskSecret } from "./provider-setup.js"; +import { maskEcho, maskSecret } from "./provider/form.js"; +import { writeClipboard } from "./copy-path.js"; import { residualIdFromSelection, type ResidualCatalogEntry } from "./residuals.js"; +import { setStatusFlash } from "./shell/chrome.js"; +import type { AppShell, ItemDescription, OverlaySelection } from "./shell/internals.js"; import { captureOverlayContinuation, closeInsetOverlay, closeReplaceableOverlay, isOverlayContinuationCurrent, isOverlayGenerationCurrent, - openHelpOverlay, openListOverlay, - openSettingsOverlay, reserveOverlayHost, setOwnedOverlayItems, - setStatusFlash, - type AppShell, - type ItemDescription, - type OverlaySelection, -} from "./shell.js"; +} from "./shell/overlay-host.js"; +import { openHelpOverlay, openSettingsOverlay } from "./shell/palette.js"; /** A remembered approval, flattened for display and revocation by id. */ export interface GrantEntry { @@ -1390,13 +1388,17 @@ export function openMcpSurface( const url = target.authURL; if (target.state !== "needs-auth" || url === undefined) return; mcp.openAuthURL(url); - // The copy is the fallback that makes this work over SSH, where the - // browser that must receive the redirect is not on this machine. - void shell.clipboard.writeText(url); - closeReplaceableOverlay(shell); - setStatusFlash(shell, `opening ${target.name} authorization — link copied`, { - ttlMs: MCP_AUTH_FLASH_MS, + // The copy is the SSH fallback: the browser taking the redirect is + // often not this machine. + // Flash long enough (6s) to notice the browser was asked to open. + const flashTtl = { ttlMs: 6000 }; + const say = (suffix: string): void => + setStatusFlash(shell, `opening ${target.name} authorization — ${suffix}`, flashTtl); + writeClipboard(shell.clipboard, url, { + onSuccess: () => say("link copied"), + onFailure: () => say("copy failed"), }); + closeReplaceableOverlay(shell); }, onAction: (id, key) => { if (key.ctrl || !(key.meta || key.option)) return false; @@ -1435,9 +1437,6 @@ export function openMcpSurface( }); } -/** Long enough to notice the browser was asked to open, and why. */ -const MCP_AUTH_FLASH_MS = 6000; - function errorText(err: unknown): string { return err instanceof Error ? err.message : String(err); } diff --git a/src/tui/copy-wire.test.ts b/src/tui/copy-wire.test.ts index 022a86654..20fefb384 100644 --- a/src/tui/copy-wire.test.ts +++ b/src/tui/copy-wire.test.ts @@ -1,15 +1,11 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { CliRenderEvents } from "@opentui/core"; import { createHarness, type Harness } from "./harness"; -import { - appendStreamRow, - confirmCopySelection, - copyAllTargets, - createAppShell, - enterCopyMode, - toggleMouseCapture, - type FlashSchedule, -} from "./shell"; +import { appendStreamRow } from "./shell/chrome"; +import { enterCopyMode, toggleMouseCapture } from "./shell/copy"; +import { createAppShell } from "./shell/index"; +import type { FlashSchedule } from "./shell/internals"; +import { confirmCopySelection, copyAllTargets } from "./shell/overlay-host"; import { createRecordingClipboard } from "./copy-path"; import { RUNTIME_FLASH_MS } from "./runtime-notices"; diff --git a/src/tui/decision-truncation.test.ts b/src/tui/decision-truncation.test.ts new file mode 100644 index 000000000..c560c59ba --- /dev/null +++ b/src/tui/decision-truncation.test.ts @@ -0,0 +1,117 @@ +/** + * Decision overlays paint bare, single-line choice rows. Labels carry no + * consequence text — scope hints paint in the body above the list and ride + * the expand dump — so nothing ever ellipsizes inside a choice, and the fixed + * two-row budget (label row + row of air) always matches what the list paints. + */ +import { EventEmitter } from "node:events"; +import { describe, expect, test } from "bun:test"; +import { SelectRenderable } from "@opentui/core"; +import type { PermissionRequest } from "../permission/types.js"; +import { withTestRenderer } from "./harness"; +import { createAppShell } from "./shell/index.js"; +import { createOverlayList, toggleOverlayExpand } from "./shell/overlay-list.js"; +import { wireGates } from "./gate-wire.js"; +import { DECISION_CHOICE_ROWS } from "./overlay-body"; +import { + createOverlayView, + overlayRowsPerItem, + type OverlayListPresentation, +} from "./overlay-view"; + +const HINT = "runs rm -rf in the workspace root without asking again"; + +const hintRequest: PermissionRequest = { + tool: "run_shell", + action: "Run shell command", + subject: 'git commit -m "line one\nline two\nline three"', + scopes: [ + { + id: "always", + label: "Allow always", + pattern: "rm -rf *", + hint: HINT, + }, + ], +}; + +function bodySelect(view: ReturnType): SelectRenderable { + const found = view.body.getChildren().find((row) => row instanceof SelectRenderable); + if (!(found instanceof SelectRenderable)) throw new Error("expected the overlay list"); + return found; +} + +describe("decision choice rendering", () => { + test("choices paint bare names with no ellipsis and the budget matches the pair", async () => { + await withTestRenderer(async (h) => { + const contentWidth = 60; + const list = createOverlayList(h.renderer, { count: 1, items: 4 }); + const view = createOverlayView(h.renderer); + h.renderer.root.add(view.host); + view.host.visible = true; + view.paintList( + { + kind: "permissions", + items: ["Reject", "Accept once", "Allow always"], + paletteCommands: [], + list, + bodyLines: [], + bodyFgs: [], + answer: null, + describe: () => undefined, + } satisfies Omit & { list: typeof list }, + contentWidth, + ); + + const select = bodySelect(view); + expect(select.options.map((option) => option.name)).toEqual([ + "Reject", + "Accept once", + "Allow always", + ]); + for (const option of select.options) { + expect(option.description).toBe(""); + expect(option.name.endsWith("…")).toBe(false); + } + + // Reserved rows equal painted rows: the pair, not a growing budget. + const perItem = overlayRowsPerItem("permissions"); + expect(perItem).toBe(DECISION_CHOICE_ROWS); + expect(select.height).toBe(list.height * perItem); + }); + }); + + test("hint text renders above the list and is included in the expand dump", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + const emitter = new EventEmitter(); + const dispose = wireGates(emitter, shell); + emitter.emit("permission.gate", { + request: hintRequest, + resolve: () => {}, + }); + + try { + // Choices are bare action names. + expect(shell.overlayItems).toEqual(["Reject", "Accept once", "Allow always"]); + + // The scope hint paints as a body message above the choice list. + const bodyText = shell.overlayBodyLines.join("\n"); + expect(bodyText).toContain("Allow always:"); + expect(bodyText).toContain("without asking again"); + + // The expand key dumps the body — hint included — to the transcript. + expect(toggleOverlayExpand(shell)).toBe(true); + const dump = shell.streamLog.at(-1); + expect(dump?.role).toBe("system"); + expect(dump?.text).toContain(`Allow always: ${HINT}`); + } finally { + dispose(); + shell.dispose(); + } + }); + }); +}); diff --git a/src/tui/demo.ts b/src/tui/demo.ts index 2851bf54a..c8ecb6e46 100644 --- a/src/tui/demo.ts +++ b/src/tui/demo.ts @@ -22,18 +22,11 @@ import { import type { ObserveSession } from "./residuals.js"; import { openModelPickerOverlay, openOperatorOverlay, openPermissionsOverlay } from "./overlays.js"; import { formatChromeZones } from "./chrome-state.js"; -import { - appendStreamRow, - createAppShell, - enterSubagentObserve, - openHelpOverlay, - openListOverlay, - openMentionsOverlay, - openSettingsOverlay, - paintChrome, - setChromeZones, - setShellRunState, -} from "./shell.js"; +import { appendStreamRow, paintChrome, setChromeZones, setShellRunState } from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; +import { enterSubagentObserve } from "./shell/observe.js"; +import { openListOverlay } from "./shell/overlay-host.js"; +import { openHelpOverlay, openMentionsOverlay, openSettingsOverlay } from "./shell/palette.js"; /** Demo-only rows: never shipped, just something to look at in `s`/`l`/`e`/`n`. */ const DEMO_SETTINGS_ITEMS: readonly string[] = [ @@ -43,6 +36,29 @@ const DEMO_SETTINGS_ITEMS: readonly string[] = [ "Close settings", ]; +const DEMO_PERMISSION_ITEMS: readonly string[] = [ + "Allow once", + "Allow session", + "Always allow this tool", + "Deny", +]; + +const DEMO_OPERATOR_BODY = + "The agent wants to run a destructive command on the working tree.\n\nProposed: git reset --hard origin/main && rm -rf node_modules"; + +const DEMO_OPERATOR_CHOICES: readonly string[] = [ + "Cancel — keep working tree", + "Allow this once", + "Open diff first", +]; + +const DEMO_MODEL_ITEMS: readonly string[] = [ + "claude-sonnet-4 * [anthropic]", + "gpt-5 * [openai]", + "gemini-2.5-pro * [google]", + "grok-3 * [xai]", +]; + const DEMO_PLUGINS_ITEMS: readonly string[] = [ "plugin:linear — enabled", "plugin:github — needs trust", @@ -208,17 +224,17 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => { } if (key.name === "p" && !key.ctrl && !key.meta && shell.prompt.value.length === 0) { - openPermissionsOverlay(shell); + openPermissionsOverlay(shell, { items: DEMO_PERMISSION_ITEMS }); return; } if (key.name === "o" && !key.ctrl && !key.meta && shell.prompt.value.length === 0) { - openOperatorOverlay(shell); + openOperatorOverlay(shell, { body: DEMO_OPERATOR_BODY, choices: DEMO_OPERATOR_CHOICES }); return; } if (key.name === "m" && !key.ctrl && !key.meta && shell.prompt.value.length === 0) { - openModelPickerOverlay(shell); + openModelPickerOverlay(shell, { items: DEMO_MODEL_ITEMS }); return; } diff --git a/src/tui/description-zone.test.ts b/src/tui/description-zone.test.ts index 5fd20334f..e04b29b1d 100644 --- a/src/tui/description-zone.test.ts +++ b/src/tui/description-zone.test.ts @@ -7,16 +7,11 @@ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness.js"; -import { - appendStreamRow, - closeInsetOverlay, - createAppShell, - cycleOverlaySelection, - moveOverlaySelection, - openListOverlay, - type AppShell, - type ItemDescription, -} from "./shell.js"; +import { appendStreamRow } from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; +import type { AppShell, ItemDescription } from "./shell/internals.js"; +import { closeInsetOverlay, openListOverlay } from "./shell/overlay-host.js"; +import { cycleOverlaySelection, moveOverlaySelection } from "./shell/overlay-list.js"; async function withShell( fn: (shell: AppShell) => Promise | void, diff --git a/src/tui/diff-rows.test.ts b/src/tui/diff-rows.test.ts index 308838e73..95513e405 100644 --- a/src/tui/diff-rows.test.ts +++ b/src/tui/diff-rows.test.ts @@ -8,7 +8,8 @@ import { rgbToHex, type CapturedSpan } from "@opentui/core"; import { toolCallRow } from "./diff"; import { withTestRenderer, type Harness } from "./harness"; -import { appendStreamRow, createAppShell } from "./shell"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; import { DIFF_FG } from "./stream"; import { toolResultRow } from "./mcp-view"; diff --git a/src/tui/focus-routing.test.ts b/src/tui/focus-routing.test.ts index 6f9448117..eab512c16 100644 --- a/src/tui/focus-routing.test.ts +++ b/src/tui/focus-routing.test.ts @@ -6,18 +6,14 @@ import { describe, expect, test } from "bun:test"; import { focusOwner } from "./focus/index"; import { createHarness, withTestRenderer, type Harness } from "./harness"; import { openPermissionsOverlay } from "./overlays"; -import { providerChoiceRows, runProviderSetup } from "./provider-setup"; -import { - appendStreamRow, - closeInsetOverlay, - createAppShell, - enterSubagentObserve, - leaveSubagentObserve, - openInsetOverlay, - openPalette, - toggleShellFocus, - type AppShell, -} from "./shell"; +import { providerChoiceRows } from "./provider/choices"; +import { runProviderSetup } from "./provider/setup"; +import { appendStreamRow, toggleShellFocus } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import type { AppShell } from "./shell/internals"; +import { enterSubagentObserve, leaveSubagentObserve } from "./shell/observe"; +import { closeInsetOverlay, openInsetOverlay } from "./shell/overlay-host"; +import { openPalette } from "./shell/palette"; async function typeInto(h: Harness, text: string): Promise { for (const ch of text) h.mockInput.pressKey(ch); diff --git a/src/tui/gate-wire.test.ts b/src/tui/gate-wire.test.ts index 61fdd4de7..3adf722fe 100644 --- a/src/tui/gate-wire.test.ts +++ b/src/tui/gate-wire.test.ts @@ -7,17 +7,16 @@ import type { PermissionRequest } from "../permission/types.js"; import type { KeyEvent } from "@opentui/core"; import { withTestRenderer, type Harness } from "./harness.js"; import { OVERLAY_MAX_FRACTION } from "./geometry/index.js"; +import { createAppShell } from "./shell/index.js"; +import type { AppShell } from "./shell/internals.js"; import { acceptOverlaySelection, closeInsetOverlay, - createAppShell, exitOverlayAnswerMode, handleOverlayAnswerKey, - moveOverlaySelection, setOverlayAnswerActive, - toggleOverlayExpand, - type AppShell, -} from "./shell.js"; +} from "./shell/overlay-host.js"; +import { moveOverlaySelection, toggleOverlayExpand } from "./shell/overlay-list.js"; import { streamRowGutter } from "./stream.js"; import { approvalOutcomeFromSelection, @@ -48,7 +47,7 @@ describe("permissionChoicesFromRequest", () => { expect(choices.outcomes).toEqual([{ allow: false }, { allow: true }]); }); - test("appends scopes with optional hint; persist only when pattern set", () => { + test("appends scopes as bare labels; persist only when pattern set", () => { const scopeWithPattern = { id: "session-git", label: "Allow git *", @@ -66,12 +65,7 @@ describe("permissionChoicesFromRequest", () => { scopes: [scopeWithPattern, onceScope], }), ); - expect(choices.items).toEqual([ - "Reject", - "Accept once", - "Allow git * (family)", - "Allow this path", - ]); + expect(choices.items).toEqual(["Reject", "Accept once", "Allow git *", "Allow this path"]); expect(choices.itemIds).toEqual([ PERMISSION_DENY_ID, PERMISSION_ONCE_ID, @@ -154,6 +148,24 @@ describe("permissionBodyFromRequest", () => { ).toBe("run_shell\nRun shell command\nbun test\nagent: explorer\nmega-chain"); }); + test("scope hints paint in the body above the choices, collapsed and expanded", () => { + const request = baseRequest({ + scopes: [ + { + id: "always", + label: "Allow always", + pattern: "rm -rf *", + hint: "deletes generated output before the next build starts", + }, + { id: "once", label: "Allow once", pattern: null }, + ], + }); + for (const opts of [{}, { expanded: true } as const]) { + const body = permissionBodyFromRequest(request, opts); + expect(body).toContain("Allow always: deletes generated output before the next build starts"); + } + }); + test("a chained command stays visibly chained, one numbered line per segment", () => { const body = permissionBodyFromRequest( baseRequest({ subject: "npm install && rm -rf /tmp/cache; echo done" }), diff --git a/src/tui/gate-wire.ts b/src/tui/gate-wire.ts index 44353c6a8..c67544126 100644 --- a/src/tui/gate-wire.ts +++ b/src/tui/gate-wire.ts @@ -9,14 +9,14 @@ import type { OperatorResult } from "../agent/tools.js"; import { formatCommandForApproval } from "./command-display.js"; import { openOperatorOverlay, openPermissionsOverlay } from "./overlays.js"; import type { ApprovalOutcome, ApprovalScope, PermissionRequest } from "../permission/types.js"; -import type { AppShell, OverlaySelection } from "./shell.js"; +import { appendStreamRow } from "./shell/chrome.js"; +import type { AppShell, OverlaySelection } from "./shell/internals.js"; import { - appendStreamRow, closeInsetOverlay, isOverlayHostIdle, onOverlayClosed, setOverlayBody, -} from "./shell.js"; +} from "./shell/overlay-host.js"; import { EXPAND_KEY } from "./stream.js"; import type { OperatorGateEvent, PermissionGateEvent } from "./gate-events.js"; import { @@ -52,7 +52,10 @@ export interface GateSelection { /** * Build permission overlay rows from a live PermissionRequest. - * Order: Reject → Accept once → request.scopes (label + optional hint). + * Order: Reject → Accept once → request.scopes. Labels are bare so the choice + * list stays short action names; each scope's hint paints in the body above + * the list (see permissionBodyFromRequest) instead of being truncated inside + * a choice row. */ export function permissionChoicesFromRequest(request: PermissionRequest): PermissionGateChoices { const items: string[] = []; @@ -68,8 +71,7 @@ export function permissionChoicesFromRequest(request: PermissionRequest): Permis outcomes.push({ allow: true }); for (const scope of request.scopes) { - const label = scope.hint ? `${scope.label} (${scope.hint})` : scope.label; - items.push(label); + items.push(scope.label); itemIds.push(scope.id); outcomes.push({ allow: true, @@ -109,6 +111,10 @@ export interface PermissionBodyOpts { * The subject is rendered through the approval formatter so a chained command * shows one numbered line per segment and bulk payloads collapse to a * placeholder the operator can expand before approving. + * + * Scope hints ride here, above the choice list: the choices stay bare action + * names, and the expand key dumps this body whole, so the consequence text is + * reachable even when the overlay's context budget clips it. */ export function permissionBodyFromRequest( request: PermissionRequest, @@ -127,6 +133,7 @@ export function permissionBodyFromRequest( request.tool, request.action, ...display.lines, + ...request.scopes.flatMap((scope) => (scope.hint ? [`${scope.label}: ${scope.hint}`] : [])), request.agentLabel ? `agent: ${request.agentLabel}` : "", request.notice ?? "", hint, diff --git a/src/tui/gutter-labels.test.ts b/src/tui/gutter-labels.test.ts index 6b1c81ce8..5837ea16e 100644 --- a/src/tui/gutter-labels.test.ts +++ b/src/tui/gutter-labels.test.ts @@ -7,13 +7,9 @@ import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness.js"; import { overlayKindWord } from "./overlay-body.js"; -import { - acceptOverlaySelection, - createAppShell, - openListOverlay, - type AppShell, - type PrimaryOverlayKind, -} from "./shell.js"; +import { createAppShell } from "./shell/index.js"; +import type { AppShell, PrimaryOverlayKind } from "./shell/internals.js"; +import { acceptOverlaySelection, openListOverlay } from "./shell/overlay-host.js"; import { streamRowGutter, type RowLayout } from "./stream.js"; const OVERLAY_KIND_GUTTER = { diff --git a/src/tui/harness.ts b/src/tui/harness.ts index a3c68b38c..971c78aef 100644 --- a/src/tui/harness.ts +++ b/src/tui/harness.ts @@ -36,6 +36,60 @@ export interface HarnessOptions { readonly exitOnCtrlC?: boolean; } +/** Fixture: permission choices for overlay suites (moved out of overlays.ts). */ +export function makePermissionItems(count = 30): readonly string[] { + const n = Math.max(1, Math.floor(count)); + return Array.from({ length: n }, (_, i) => { + if (i === 0) return "Allow once"; + if (i === 1) return "Allow session"; + if (i === 2) return "Always allow this tool"; + if (i === 3) return "Deny"; + return `Allow tool call #${i - 3}`; + }); +} + +/** Fixture: long operator question + many choices for overlay suites. */ +export function makeOperatorQuestion(): { + readonly body: string; + readonly choices: readonly string[]; +} { + const body = [ + "The agent wants to run a destructive command on the working tree.", + "Review the plan carefully — this cannot be undone from the TUI.", + "", + "Proposed: git reset --hard origin/main && rm -rf node_modules", + "Files at risk: 128 modified, 12 untracked.", + "Continue only if you accept discarding local work.", + ].join("\n"); + const choices = [ + "Cancel — keep working tree", + "Allow this once", + "Allow for this session", + "Always allow git reset", + "Open diff first", + "Ask again later", + "Switch to dry-run", + "Abort agent run", + ]; + return { body, choices }; +} + +/** Fixture: model/provider picker list for overlay suites. */ +export function makeModelPickerItems(): readonly string[] { + return [ + "claude-sonnet-4 * [anthropic]", + "claude-opus-4 * [anthropic]", + "gpt-5 * [openai]", + "gpt-5-mini * [openai]", + "gemini-2.5-pro * [google]", + "gemini-2.5-flash * [google]", + "grok-3 * [xai]", + "ollama-llama3.3 * [local]", + "o3 * [codex]", + "o4-mini * [codex]", + ]; +} + export interface KeyModifiers { readonly shift?: boolean; readonly ctrl?: boolean; diff --git a/src/tui/index.ts b/src/tui/index.ts deleted file mode 100644 index 7dceeb295..000000000 --- a/src/tui/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** OpenTUI platform kit — not wired to the CLI entry yet. */ -export const PLATFORM_VERSION = "0.5.10" as const; - -export * from "./geometry/index"; -export * from "./focus/index"; -export * from "./list-viewport"; -export * from "./session-queue"; -export * from "./stream"; -export * from "./stream-event-map"; -export { - attachSessionBridge, - createRecordingPort, - FIXTURE_BUSY_SESSION, - mapReactorLike, - type PortCall, - type SessionBridge, - type SessionPort, - type SessionPortHandlers, -} from "./runtime-bridge"; -export * from "./live-session-port"; -export * from "./overlays"; -export * from "./long-log"; -export * from "./command-catalog"; -export * from "./model-catalog"; -export * from "./copy-path"; -export * from "./chrome-state"; -export * from "./residuals"; -export * from "./gate-wire"; -export * from "./landing"; -export * from "./mark-anim"; -export * from "./mark-shape"; -export * from "./shell"; -export * from "./harness"; -export { - mountProductHost, - operatorResultFromSelection, - permissionChoices, - type ProductHost, - type ProductHostConfig, - type ProductHostDeliver, - type ProductHostInterrupt, - type ProductHostModelOption, - type ProductHostSend, -} from "./product-host"; diff --git a/src/tui/keybindings.test.ts b/src/tui/keybindings.test.ts index b71445776..c00599222 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -19,37 +19,38 @@ import { describe, expect, test } from "bun:test"; import { PROMPT_KEY_BINDINGS } from "./prompt-input.js"; import { helpItems, SHELL_SHORTCUTS } from "./keybindings.js"; import { createHarness, withTestRenderer, type Harness } from "./harness.js"; -import { mountRunnerHost } from "./runner-host.js"; +import { mountRunnerHost } from "./runner/host.js"; import { openCommandSurface } from "./command-surfaces.js"; import { focusOwner } from "./focus/focus-state.js"; -import { setChromeZones } from "./shell.js"; import { - addPendingAttachment, + setChromeZones, appendStreamRow, - applyShellInterrupt, - createAppShell, + setShellRunState, + shellFocusPrompt, + shellFocusTranscript, + truncateStreamRows, +} from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; +import { isSlashPopupOpen, - leaveSubagentObserve, - openHelpOverlay, setMentionSuggestionSource, - setPaletteCatalog, setPaletteOnObserveRequest, setPromptImageSource, - setSentMessageHistory, setShellBridgeHooks, setShellExitHandler, setEffortCycleHandler, clearShellBridgeHooks, - setShellRunState, - shellFocusPrompt, - shellFocusTranscript, - streamRowAt, - streamRowCount, - submitPrompt, - truncateStreamRows, type AppShell, -} from "./shell.js"; - +} from "./shell/internals.js"; +import { leaveSubagentObserve } from "./shell/observe.js"; +import { openHelpOverlay, setPaletteCatalog } from "./shell/palette.js"; +import { + addPendingAttachment, + applyShellInterrupt, + setSentMessageHistory, + submitPrompt, +} from "./shell/prompt.js"; +import { streamRowAt, streamRowCount } from "./shell/transcript.js"; /* --------------------------------------------------------------------- */ /* Chord string → the bytes a terminal actually writes */ /* --------------------------------------------------------------------- */ diff --git a/src/tui/landing.test.ts b/src/tui/landing.test.ts index a548f6890..88bdf9ffc 100644 --- a/src/tui/landing.test.ts +++ b/src/tui/landing.test.ts @@ -6,25 +6,23 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { CapturedSpan } from "@opentui/core"; import { rgbToHex } from "@opentui/core"; -import { withTestRenderer, type Harness } from "./harness"; +import { makePermissionItems, withTestRenderer, type Harness } from "./harness"; import { appendStreamRow, applyLandingSuggestion, - createAppShell, LANDING_IDLE_REPAINT_INTERVAL_MS, noticeText, paintChrome, setChromeZones, setPluginNeedsAttention, - setPromptModelLabel, - setPromptWorkspace, - isLanding, paintLanding, - streamRowCount, - surfaceSystemNotice, toggleTasksPanel, -} from "./shell"; -import { makePermissionItems, openPermissionsOverlay } from "./overlays"; +} from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import { isLanding } from "./shell/internals"; +import { setPromptModelLabel, setPromptWorkspace, surfaceSystemNotice } from "./shell/prompt"; +import { streamRowCount } from "./shell/transcript"; +import { openPermissionsOverlay } from "./overlays"; import { LANDING_HINTS, LANDING_SUGGESTIONS, diff --git a/src/tui/list-modal.ts b/src/tui/list-modal.ts index df98d1b67..31fbbfa17 100644 --- a/src/tui/list-modal.ts +++ b/src/tui/list-modal.ts @@ -12,12 +12,10 @@ import { residualListFromCatalog, type ResidualCatalogEntry, } from "./residuals.js"; -import { - appendStreamRow, - createAppShell, - openListOverlay, - type PrimaryOverlayKind, -} from "./shell.js"; +import { appendStreamRow } from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; +import type { PrimaryOverlayKind } from "./shell/internals.js"; +import { openListOverlay } from "./shell/overlay-host.js"; export interface ListModalConfig { /** Overlay title (also the shell header base title). */ diff --git a/src/tui/list-viewport.test.ts b/src/tui/list-viewport.test.ts deleted file mode 100644 index e16567b01..000000000 --- a/src/tui/list-viewport.test.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - createListViewport, - jump, - keepActiveVisible, - moveActive, - page, - setCount, - setHeight, - visibleSlice, - type ListViewportState, -} from "./list-viewport.js"; - -function windowOf(state: ListViewportState): number[] { - const { start, end } = visibleSlice(state); - const indices: number[] = []; - for (let i = start; i < end; i++) indices.push(i); - return indices; -} - -describe("createListViewport", () => { - test("empty list", () => { - const s = createListViewport({ count: 0, height: 5 }); - expect(s).toEqual({ count: 0, height: 5, offset: 0, activeIndex: 0 }); - expect(visibleSlice(s)).toEqual({ start: 0, end: 0, activeIndex: 0 }); - }); - - test("short list fits entirely", () => { - const s = createListViewport({ count: 3, height: 10 }); - expect(s.offset).toBe(0); - expect(s.activeIndex).toBe(0); - expect(visibleSlice(s)).toEqual({ start: 0, end: 3, activeIndex: 0 }); - }); - - test("tall list starts at top", () => { - const s = createListViewport({ count: 30, height: 5 }); - expect(s.offset).toBe(0); - expect(windowOf(s)).toEqual([0, 1, 2, 3, 4]); - }); - - test("initial activeIndex near bottom scrolls window down", () => { - const s = createListViewport({ count: 30, height: 5, activeIndex: 20 }); - expect(s.activeIndex).toBe(20); - // active must be visible: offset = 20 - 5 + 1 = 16 - expect(s.offset).toBe(16); - expect(windowOf(s)).toEqual([16, 17, 18, 19, 20]); - }); - - test("clamps negative and oversized activeIndex", () => { - expect(createListViewport({ count: 10, height: 3, activeIndex: -5 }).activeIndex).toBe(0); - expect(createListViewport({ count: 10, height: 3, activeIndex: 99 }).activeIndex).toBe(9); - }); -}); - -describe("keep-active-visible", () => { - test("scrolls down when active falls below the window", () => { - const base: ListViewportState = { - count: 20, - height: 4, - offset: 0, - activeIndex: 10, - }; - const s = keepActiveVisible(base); - expect(s.offset).toBe(10 - 4 + 1); // 7 - expect(s.activeIndex).toBe(10); - expect(windowOf(s)).toContain(10); - }); - - test("scrolls up when active is above the window", () => { - const base: ListViewportState = { - count: 20, - height: 4, - offset: 10, - activeIndex: 2, - }; - const s = keepActiveVisible(base); - expect(s.offset).toBe(2); - expect(windowOf(s)).toContain(2); - }); - - test("leaves offset alone when active already visible", () => { - const base: ListViewportState = { - count: 20, - height: 5, - offset: 3, - activeIndex: 5, - }; - const s = keepActiveVisible(base); - expect(s.offset).toBe(3); - }); - - test("clamps offset when list is shorter than height", () => { - const s = keepActiveVisible({ - count: 3, - height: 10, - offset: 50, - activeIndex: 1, - }); - expect(s.offset).toBe(0); - expect(visibleSlice(s).end).toBe(3); - }); -}); - -describe("moveActive", () => { - test("moves down and keeps active visible", () => { - let s = createListViewport({ count: 20, height: 4 }); - for (let i = 0; i < 6; i++) s = moveActive(s, 1); - expect(s.activeIndex).toBe(6); - expect(s.offset).toBe(6 - 4 + 1); // 3 - expect(windowOf(s)).toEqual([3, 4, 5, 6]); - }); - - test("moves up and keeps active visible", () => { - let s = createListViewport({ count: 20, height: 4, activeIndex: 10 }); - s = moveActive(s, -3); - expect(s.activeIndex).toBe(7); - expect(windowOf(s)).toContain(7); - }); - - test("clamps at ends", () => { - let s = createListViewport({ count: 5, height: 3 }); - s = moveActive(s, -10); - expect(s.activeIndex).toBe(0); - s = moveActive(s, 100); - expect(s.activeIndex).toBe(4); - }); - - test("empty list is a no-op", () => { - const s = moveActive(createListViewport({ count: 0, height: 5 }), 1); - expect(s.activeIndex).toBe(0); - expect(s.offset).toBe(0); - }); -}); - -describe("page", () => { - test("pages down by height - 1", () => { - const s0 = createListViewport({ count: 50, height: 10 }); - const s1 = page(s0, 1); - expect(s1.activeIndex).toBe(9); // 10 - 1 - expect(windowOf(s1)).toContain(9); - }); - - test("pages up by height - 1", () => { - let s = createListViewport({ count: 50, height: 10, activeIndex: 20 }); - s = page(s, -1); - expect(s.activeIndex).toBe(11); // 20 - 9 - expect(windowOf(s)).toContain(11); - }); - - test("height 1 pages by 1", () => { - let s = createListViewport({ count: 10, height: 1 }); - s = page(s, 1); - expect(s.activeIndex).toBe(1); - expect(s.offset).toBe(1); - }); - - test("page does not overshoot ends", () => { - let s = createListViewport({ count: 12, height: 5 }); - s = page(s, 1); - s = page(s, 1); - s = page(s, 1); - expect(s.activeIndex).toBe(11); - s = page(s, -1); - s = page(s, -1); - s = page(s, -1); - expect(s.activeIndex).toBe(0); - }); -}); - -describe("jump", () => { - test("jumps mid-list and re-windows", () => { - const s = jump(createListViewport({ count: 40, height: 6 }), 25); - expect(s.activeIndex).toBe(25); - expect(s.offset).toBe(25 - 6 + 1); - expect(windowOf(s)).toContain(25); - }); - - test("clamps out-of-range jump", () => { - expect(jump(createListViewport({ count: 10, height: 3 }), -3).activeIndex).toBe(0); - expect(jump(createListViewport({ count: 10, height: 3 }), 99).activeIndex).toBe(9); - }); -}); - -describe("setHeight / height resize", () => { - test("shrinking height keeps active visible", () => { - let s = createListViewport({ count: 30, height: 10, activeIndex: 8 }); - expect(s.offset).toBe(0); - s = setHeight(s, 4); - expect(s.height).toBe(4); - expect(s.activeIndex).toBe(8); - // 8 was visible at offset 0 with height 10; with height 4 it is not → offset = 5 - expect(s.offset).toBe(8 - 4 + 1); - expect(windowOf(s)).toEqual([5, 6, 7, 8]); - }); - - test("growing height may lower offset only via clamp", () => { - let s = createListViewport({ count: 20, height: 3, activeIndex: 18 }); - expect(s.offset).toBe(16); - s = setHeight(s, 10); - expect(s.height).toBe(10); - // max offset = 20 - 10 = 10; active 18 still visible in [10,20) - expect(s.offset).toBe(10); - expect(windowOf(s)).toContain(18); - }); - - test("height zero yields empty slice", () => { - const s = setHeight(createListViewport({ count: 10, height: 5 }), 0); - expect(visibleSlice(s)).toEqual({ start: 0, end: 0, activeIndex: 0 }); - }); -}); - -describe("setCount", () => { - test("shrinking list clamps active and offset", () => { - let s = createListViewport({ count: 30, height: 5, activeIndex: 25 }); - s = setCount(s, 8); - expect(s.count).toBe(8); - expect(s.activeIndex).toBe(7); - expect(s.offset).toBe(maxOffsetLike(8, 5)); - expect(windowOf(s)).toContain(7); - }); -}); - -function maxOffsetLike(count: number, height: number): number { - return Math.max(0, count - height); -} - -describe("visibleSlice", () => { - test("end is exclusive", () => { - const s = createListViewport({ count: 10, height: 3 }); - const slice = visibleSlice(s); - expect(slice.end - slice.start).toBe(3); - expect(slice.end).toBe(3); - }); - - test("short list end equals count", () => { - const s = createListViewport({ count: 2, height: 8 }); - expect(visibleSlice(s)).toEqual({ start: 0, end: 2, activeIndex: 0 }); - }); -}); diff --git a/src/tui/list-viewport.ts b/src/tui/list-viewport.ts deleted file mode 100644 index c7a75ad4a..000000000 --- a/src/tui/list-viewport.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Pure list windowing kit: visible window, keep-active-visible, page/jump. - * Consumers (permissions, models, agents, settings) pass item count + viewport - * height in rows — no paint, no OpenTUI/Ink imports. - */ - -export interface ListViewportState { - /** Total number of items in the list. */ - count: number; - /** Visible row capacity (viewport height). */ - height: number; - /** Index of the first visible item. */ - offset: number; - /** Index of the active/highlighted item. */ - activeIndex: number; -} - -export interface CreateListViewportArgs { - count: number; - height: number; - activeIndex?: number; -} - -export interface VisibleSlice { - /** Inclusive start index into the full list. */ - start: number; - /** Exclusive end index into the full list. */ - end: number; - activeIndex: number; -} - -function clamp(n: number, min: number, max: number): number { - if (n < min) return min; - if (n > max) return max; - return n; -} - -function maxOffset(count: number, height: number): number { - return Math.max(0, count - height); -} - -function normalizeCount(count: number): number { - return Math.max(0, Math.floor(count)); -} - -function normalizeHeight(height: number): number { - return Math.max(0, Math.floor(height)); -} - -function clampActive(count: number, activeIndex: number): number { - if (count === 0) return 0; - return clamp(Math.floor(activeIndex), 0, count - 1); -} - -/** - * Adjust `offset` so `activeIndex` lies in the visible window, then clamp - * offset to `[0, max(0, count - height)]`. - * - * Rules: - * - if active < offset → offset = active - * - if active >= offset + height → offset = active - height + 1 - */ -export function keepActiveVisible(state: ListViewportState): ListViewportState { - const count = normalizeCount(state.count); - const height = normalizeHeight(state.height); - const activeIndex = clampActive(count, state.activeIndex); - - if (height <= 0 || count === 0) { - return { count, height, offset: 0, activeIndex }; - } - - let offset = Math.floor(state.offset); - - if (activeIndex < offset) { - offset = activeIndex; - } else if (activeIndex >= offset + height) { - offset = activeIndex - height + 1; - } - - offset = clamp(offset, 0, maxOffset(count, height)); - return { count, height, offset, activeIndex }; -} - -/** Create a viewport with offset 0, then keep-active-visible. */ -export function createListViewport(args: CreateListViewportArgs): ListViewportState { - const count = normalizeCount(args.count); - const height = normalizeHeight(args.height); - const activeIndex = args.activeIndex === undefined ? 0 : args.activeIndex; - return keepActiveVisible({ count, height, offset: 0, activeIndex }); -} - -/** Move active by `delta` (clamped), then keep-active-visible. */ -export function moveActive(state: ListViewportState, delta: number): ListViewportState { - const count = normalizeCount(state.count); - if (count === 0) { - return keepActiveVisible({ ...state, count, activeIndex: 0 }); - } - const activeIndex = clampActive(count, state.activeIndex + delta); - return keepActiveVisible({ ...state, count, activeIndex }); -} - -/** - * Page up (`dir = -1`) or down (`dir = 1`). - * Step is `height - 1` when height > 1 (one row of context), else 1. - */ -export function page(state: ListViewportState, dir: -1 | 1): ListViewportState { - const height = normalizeHeight(state.height); - const step = height > 1 ? height - 1 : 1; - return moveActive(state, dir * step); -} - -/** Jump active to `index` (clamped), then keep-active-visible. */ -export function jump(state: ListViewportState, index: number): ListViewportState { - return keepActiveVisible({ ...state, activeIndex: index }); -} - -/** Visible window as half-open `[start, end)` plus the (clamped) active index. */ -export function visibleSlice(state: ListViewportState): VisibleSlice { - const count = normalizeCount(state.count); - const height = normalizeHeight(state.height); - const activeIndex = clampActive(count, state.activeIndex); - const start = clamp(Math.floor(state.offset), 0, maxOffset(count, height)); - const end = height <= 0 ? start : Math.min(count, start + height); - return { start, end, activeIndex }; -} - -/** Update viewport height (e.g. terminal resize) and re-window. */ -export function setHeight(state: ListViewportState, height: number): ListViewportState { - return keepActiveVisible({ ...state, height: normalizeHeight(height) }); -} - -/** Update item count (list length change) and re-window. */ -export function setCount(state: ListViewportState, count: number): ListViewportState { - return keepActiveVisible({ ...state, count: normalizeCount(count) }); -} diff --git a/src/tui/log-sink.test.ts b/src/tui/log-sink.test.ts index 7556f5fe9..c6e5eff56 100644 --- a/src/tui/log-sink.test.ts +++ b/src/tui/log-sink.test.ts @@ -13,7 +13,7 @@ import { join } from "node:path"; import { getLogger } from "@intx/log"; import { installFileLogSink } from "../logging/sink.js"; -import { createAppShell } from "./shell.js"; +import { createAppShell } from "./shell/index.js"; import { withTestRenderer } from "./harness.js"; describe("log sink during a live TUI session", () => { diff --git a/src/tui/margins.test.ts b/src/tui/margins.test.ts index 07c503731..6fe4766bf 100644 --- a/src/tui/margins.test.ts +++ b/src/tui/margins.test.ts @@ -15,7 +15,8 @@ import { resolveGeometry, } from "./geometry/index.js"; import { withTestRenderer, type Harness } from "./harness"; -import { appendStreamRow, createAppShell } from "./shell"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; async function settle(h: Harness): Promise { await h.renderOnce(); diff --git a/src/tui/markdown-rows.test.ts b/src/tui/markdown-rows.test.ts index 02fe03542..eba32e726 100644 --- a/src/tui/markdown-rows.test.ts +++ b/src/tui/markdown-rows.test.ts @@ -6,12 +6,8 @@ import { describe, expect, test } from "bun:test"; import { MarkdownRenderable, BoxRenderable, type CapturedSpan } from "@opentui/core"; import { withTestRenderer, type Harness } from "./harness"; -import { - appendStreamRow, - createAppShell, - createStreamRowRenderable, - replaceStreamRowAt, -} from "./shell"; +import { appendStreamRow, createStreamRowRenderable, replaceStreamRowAt } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; import { splitAtSettledHeading } from "./markdown-parser"; import { isMarkdownRow } from "./stream"; diff --git a/src/tui/mcp-copy-failure.test.ts b/src/tui/mcp-copy-failure.test.ts new file mode 100644 index 000000000..015d3c6a4 --- /dev/null +++ b/src/tui/mcp-copy-failure.test.ts @@ -0,0 +1,71 @@ +/** + * Regression: the MCP auth copy-URL path must surface a failure flash when + * both clipboard legs fail, never an unhandled rejection that would route to + * handleFatal and exit the process. + */ +import { describe, expect, test } from "bun:test"; +import { openCommandSurface, type McpEntry } from "./command-surfaces"; +import { withTestRenderer } from "./harness"; +import { createAppShell } from "./shell/index"; +import type { AppShell } from "./shell/internals"; +import { acceptOverlaySelection, closeInsetOverlay } from "./shell/overlay-host"; +import { moveOverlaySelection } from "./shell/overlay-list"; + +const entries: readonly McpEntry[] = [ + { name: "notion", state: "needs-auth", authURL: "https://notion.test/auth" }, +]; + +async function withShell(fn: (shell: AppShell) => Promise | void): Promise { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + }); + try { + await fn(shell); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); +} + +describe("mcp auth copy failure", () => { + test("both clipboard legs failing flashes copy failed instead of crashing", async () => { + await withShell(async (shell) => { + const clip = { writeText: () => Promise.reject(new Error("both legs failed")) }; + (shell as unknown as { clipboard: typeof clip }).clipboard = clip; + openCommandSurface(shell, "mcp", { + notify: () => {}, + mcp: { list: () => entries, openAuthURL: () => {} }, + }); + moveOverlaySelection(shell, 0); + acceptOverlaySelection(shell); + await Promise.resolve(); + await Promise.resolve(); + // The rejection must resolve into the writeClipboard failure flash — an + // unhandled rejection here would take the whole process down. + expect(shell.statusFlash).toContain("copy failed"); + closeInsetOverlay(shell); + }); + }); + + test("a successful copy flashes that the link was copied", async () => { + await withShell(async (shell) => { + const clip = { writeText: () => Promise.resolve() }; + (shell as unknown as { clipboard: typeof clip }).clipboard = clip; + openCommandSurface(shell, "mcp", { + notify: () => {}, + mcp: { list: () => entries, openAuthURL: () => {} }, + }); + moveOverlaySelection(shell, 0); + acceptOverlaySelection(shell); + await Promise.resolve(); + await Promise.resolve(); + expect(shell.statusFlash).toContain("link copied"); + closeInsetOverlay(shell); + }); + }); +}); diff --git a/src/tui/mcp-view.test.ts b/src/tui/mcp-view.test.ts index 6c9b60802..fbd6d602e 100644 --- a/src/tui/mcp-view.test.ts +++ b/src/tui/mcp-view.test.ts @@ -9,7 +9,8 @@ import { extractMcpRecord, extractMcpRecords } from "./mcp-result-format.js"; import { toolCallRow } from "./diff"; import { withTestRenderer, type Harness } from "./harness"; import { mcpStructuredView, toolResultRow } from "./mcp-view"; -import { appendStreamRow, createAppShell } from "./shell"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; import { isCollapsibleRow, isMarkdownRow, isStructuredRow, type StreamRow } from "./stream"; const WIDE = { width: 100, height: 24 } as const; diff --git a/src/tui/mention-popup.test.ts b/src/tui/mention-popup.test.ts index 87df1de8d..a02b96d46 100644 --- a/src/tui/mention-popup.test.ts +++ b/src/tui/mention-popup.test.ts @@ -10,17 +10,15 @@ import type { KeyEvent } from "@opentui/core"; import { wireGates } from "./gate-wire"; import { withTestRenderer } from "./harness"; +import { createAppShell } from "./shell/index"; +import { setMentionSuggestionSource, type AppShell } from "./shell/internals"; +import { acceptOverlaySelection, closeInsetOverlay } from "./shell/overlay-host"; import { - acceptOverlaySelection, - closeInsetOverlay, closeMentionPopup, - createAppShell, handleMentionPopupKey, isMentionPopupOpen, openAtMentionSuggestions, - setMentionSuggestionSource, - type AppShell, -} from "./shell"; +} from "./shell/palette"; const TREE: Readonly> = { "": ["AGENTS.md", "README.md", "session-notes.md", "src/"], diff --git a/src/tui/model-catalog.ts b/src/tui/model-catalog.ts index 279cbf247..9da7e460d 100644 --- a/src/tui/model-catalog.ts +++ b/src/tui/model-catalog.ts @@ -14,7 +14,7 @@ import { getActivePricingCache } from "../cost/cost-visibility.js"; import { lookupModelPricing, type PricingCache } from "../cost/pricing-fetcher.js"; import { contextWindowFor, hasContextWindowFor } from "../provider/context-window.js"; import { modelReasoningCapability } from "../provider/reasoning-effort.js"; -import type { ItemDescription } from "./shell.js"; +import type { ItemDescription } from "./shell/internals.js"; export type ModelCatalogSection = "recent" | "favorites" | "provider"; diff --git a/src/tui/mouse-reporting-disabled.test.ts b/src/tui/mouse-reporting-disabled.test.ts index 4a5568093..8bcf47577 100644 --- a/src/tui/mouse-reporting-disabled.test.ts +++ b/src/tui/mouse-reporting-disabled.test.ts @@ -43,7 +43,7 @@ afterEach(() => { }); const { runListModal } = await import("./list-modal.js"); -const { runProviderSetup } = await import("./provider-setup.js"); +const { runProviderSetup } = await import("./provider/setup.js"); async function waitForMount(): Promise { for (let i = 0; i < 100 && capturedOptions.length === 0; i++) { diff --git a/src/tui/observe-live.test.ts b/src/tui/observe-live.test.ts index 77e1bc8e1..17d803b0d 100644 --- a/src/tui/observe-live.test.ts +++ b/src/tui/observe-live.test.ts @@ -14,15 +14,10 @@ import { import type { ObserveSession } from "./residuals.js"; import type { StreamRow } from "./stream.js"; import { createStreamMapContext } from "./stream-event-map.js"; -import { - appendObserveStreamRow, - appendStreamRow, - createAppShell, - enterSubagentObserve, - getPaletteOnObserveRequest, - leaveSubagentObserve, - setPaletteOnObserveRequest, -} from "./shell.js"; +import { appendObserveStreamRow, appendStreamRow } from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; +import { getPaletteOnObserveRequest, setPaletteOnObserveRequest } from "./shell/internals.js"; +import { enterSubagentObserve, leaveSubagentObserve } from "./shell/observe.js"; function liveChildSession( lines: readonly StreamRow[], diff --git a/src/tui/onboarding.test.ts b/src/tui/onboarding.test.ts index 2a9e00f91..4ddc86224 100644 --- a/src/tui/onboarding.test.ts +++ b/src/tui/onboarding.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { Config, UnconfiguredConfig } from "../config/index.js"; -import type { ProviderSetupConfig } from "./provider-setup.js"; +import type { ProviderSetupConfig } from "./provider/types.js"; import type { WelcomeConfig } from "./welcome.js"; import { withMockedModule } from "../../tests/helpers/mock-module.js"; @@ -29,8 +29,8 @@ await withMockedModule( }), ); await withMockedModule( - import.meta.resolve("./provider-setup.js"), - (real: typeof import("./provider-setup.js")) => ({ + import.meta.resolve("./provider/setup.js"), + (real: typeof import("./provider/setup.js")) => ({ ...real, runProviderSetup: async (config: ProviderSetupConfig) => { callOrder.push("setup"); @@ -40,8 +40,8 @@ await withMockedModule( }), ); await withMockedModule( - import.meta.resolve("./runner.js"), - (real: typeof import("./runner.js")) => ({ + import.meta.resolve("./runner/index.js"), + (real: typeof import("./runner/index.js")) => ({ ...real, runTUI: async (config: Config) => { tuiConfig = config; diff --git a/src/tui/onboarding.ts b/src/tui/onboarding.ts index eb19dc555..3709db748 100644 --- a/src/tui/onboarding.ts +++ b/src/tui/onboarding.ts @@ -1,5 +1,5 @@ -import { runTUI } from "./runner.js"; -import { buildProviderSubmitHandler } from "./provider-setup-submit.js"; +import { runTUI } from "./runner/index.js"; +import { buildProviderSubmitHandler } from "./provider/submit.js"; import { loadConfig, type UnconfiguredConfig } from "../config/index.js"; import { globalSettingsPath, @@ -8,7 +8,7 @@ import { resolveLocalSettingsPath, } from "../config/settings.js"; import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/first-run.js"; -import { runProviderSetup } from "./provider-setup.js"; +import { runProviderSetup } from "./provider/setup.js"; import { runWelcome } from "./welcome.js"; export async function runOnboarding(config: UnconfiguredConfig): Promise { diff --git a/src/tui/overlay-body-cache-staleness.test.ts b/src/tui/overlay-body-cache-staleness.test.ts index 5ac7a0b6e..405523ff0 100644 --- a/src/tui/overlay-body-cache-staleness.test.ts +++ b/src/tui/overlay-body-cache-staleness.test.ts @@ -5,15 +5,13 @@ * `applyOverlayBodyText` in shell.ts). */ import { describe, expect, test } from "bun:test"; -import { withTestRenderer } from "./harness.js"; -import { - createAppShell, - appendStreamRow, - closeInsetOverlay, - openPalette, - 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 { closeInsetOverlay } from "./shell/overlay-host.js"; +import { openPalette } from "./shell/palette.js"; +import { openPermissionsOverlay } from "./overlays.js"; import { DECISION_CHOICE_ROWS } from "./overlay-body.js"; function primeSession(shell: AppShell): void { diff --git a/src/tui/overlay-body.test.ts b/src/tui/overlay-body.test.ts index 14a6ad7e6..271cc02db 100644 --- a/src/tui/overlay-body.test.ts +++ b/src/tui/overlay-body.test.ts @@ -4,21 +4,17 @@ import { OVERLAY_MAX_FRACTION, PROMPT_BASE_ROWS } from "./geometry/index.js"; import { withTestRenderer } from "./harness.js"; import { composeDecisionBody, - decisionChoiceRows, - decisionChoiceRowCount, decisionContextBudget, describeZoneLines, - DECISION_ACTIVE_MARK, - DECISION_CHOICE_ROWS, DECISION_DITHER, overlayChoiceText, overlayKindWord, wrapOverlayText, wrapWords, } from "./overlay-body.js"; -import { createAppShell, openListOverlay } from "./shell.js"; +import { createAppShell } from "./shell/index.js"; +import { openListOverlay } from "./shell/overlay-host.js"; import { UI } from "./theme.js"; -import { stringWidth } from "./view/height.js"; const WIDTHS = [72, 60, 48, 40, 24] as const; @@ -139,60 +135,6 @@ describe("composeDecisionBody", () => { } }); -describe("decisionChoiceRows", () => { - const LABEL = "Always allow run_shell in /Users/someone/abklabs/corbits-code (session grant)"; - const SHORT = "Reject"; - - test("every choice occupies the same row count, wrapped or not", () => { - for (const width of [40, 48, 60, 80] as const) { - const rows = decisionChoiceRowCount([SHORT, LABEL], width); - expect(rows).toBeGreaterThanOrEqual(DECISION_CHOICE_ROWS); - expect(decisionChoiceRows(SHORT, true, width, rows)).toHaveLength(rows); - expect(decisionChoiceRows(LABEL, false, width, rows)).toHaveLength(rows); - } - }); - - test("the active choice is marked, and short labels pad to two rows", () => { - const rows = decisionChoiceRows("Reject", true, 60); - expect(rows[0]?.text).toBe(`${DECISION_ACTIVE_MARK} Reject`); - expect(rows[0]?.fg).toBe(UI.text); - expect(rows).toHaveLength(DECISION_CHOICE_ROWS); - expect(rows[1]?.text).toBe(""); - expect(rows[1]?.fg).toBe(UI.textDim); - }); - - test("an inactive choice is dim and unmarked", () => { - const rows = decisionChoiceRows("Reject", false, 60); - expect(rows[0]?.text).toBe(" Reject"); - expect(rows[0]?.fg).toBe(UI.textDim); - }); - - test("a long label wraps on word boundaries and never ellipsizes", () => { - for (const width of [40, 48, 60, 80] as const) { - const rows = decisionChoiceRows(LABEL, false, width); - const joined = rows - .map((r) => r.text) - .join(" ") - .replace(/\s+/g, " "); - expect(joined).not.toContain("..."); - expect(joined).not.toContain("…"); - expect(joined).toContain("session grant"); - expect(rows.length).toBeGreaterThan(1); - for (const row of rows) { - expect(stringWidth(row.text)).toBeLessThanOrEqual(width); - } - } - }); - - for (const width of WIDTHS) { - test(`a long label stays inside ${width} columns`, () => { - const rows = decisionChoiceRows(LABEL, false, width); - for (const row of rows) expect(stringWidth(row.text)).toBeLessThanOrEqual(width); - expect(rows[0]?.text.endsWith("-")).toBe(false); - }); - } -}); - describe("decision overlay paints at narrow widths", () => { for (const width of [40, 48, 60, 80]) { test(`permission overlay rows stay inside the box at ${width} columns`, async () => { @@ -202,11 +144,9 @@ describe("decision overlay paints at narrow widths", () => { openListOverlay(shell, { kind: "permissions", title: "permission", - items: [ - "Always allow run_shell in this workspace (session grant)", - "Reject", - "Accept once", - ], + // Bare action names: labels carry no consequence text, so they + // paint whole at any of these widths. + items: ["Reject", "Accept once", "Always allow"], body: `run_shell\nRun shell command\n1) npm install ${LONG_URL}\ne expand 1 collapsed payload`, }); await h.renderOnce(); @@ -237,15 +177,12 @@ describe("decision overlay paints at narrow widths", () => { .join(" ") .replace(/[│┌┐└┘─]/g, " ") .replace(/\s+/g, " "); - expect(interior).toContain("session grant"); - expect(interior).toContain("Always allow"); + // The first choice is always on screen; how many of the rest fit is a + // height question (the fraction cap + this deliberately tall body), not + // a width one — reachability under clipping is overlay-overflow's file. + expect(interior).toContain("Reject"); const choiceLines = lines.filter( - (l) => - l.includes("Always allow") || - l.includes("session") || - l.includes("grant") || - l.includes("Accept once") || - l.includes("Reject"), + (l) => l.includes("Reject") || l.includes("Accept once") || l.includes("Always allow"), ); expect(choiceLines.length).toBeGreaterThan(0); for (const line of choiceLines) { diff --git a/src/tui/overlay-body.ts b/src/tui/overlay-body.ts index 50e255a7a..2498b4d3a 100644 --- a/src/tui/overlay-body.ts +++ b/src/tui/overlay-body.ts @@ -3,10 +3,11 @@ * operator question. * * This is the one framed surface in the shell and the moment a human is asked - * to authorize something, so it is shaped rather than listed: a dithered header - * carrying the subject, air between the subject and the choices, and two rows - * per choice so a long label wraps instead of clipping and short labels get - * breathing room. + * to authorize something, so its body is shaped rather than listed: a dithered + * header carrying the subject, air between the subject and the context rows, + * and a trailing blank row so the choices never abut the question. Choices + * themselves are bare single-line action names painted by the overlay list — + * all consequence text lives in the body above them. * * Wrapping is on word boundaries. A token longer than the line (a path, a URL) * is broken deliberately — preferring a separator the reader already parses as @@ -19,13 +20,9 @@ import { UI } from "./theme.js"; /** House ordered-dither ramp, sparsest-first, leading the header. */ export const DECISION_DITHER = "░▒▓"; -/** Marker on the active choice. Solid: the densest cell of the same ramp. */ -export const DECISION_ACTIVE_MARK = "█"; - /** - * Display rows every choice occupies at least, wrapped or not. Short labels - * pad to this so the list still breathes; a wrap taller than this raises - * every choice to the same height so list index arithmetic stays a simple + * Display rows every choice occupies, wrapped or not: one label row plus one + * row of air, so the list breathes and list index arithmetic stays a simple * multiple. */ export const DECISION_CHOICE_ROWS = 2; @@ -34,7 +31,6 @@ export const DECISION_CHOICE_ROWS = 2; const MIN_WRAP_WIDTH = 4; const HEADER_PREFIX = `${DECISION_DITHER} `; -const CHOICE_INDENT = " "; /** Hanging indent on a wrapped continuation row. */ const CONTINUATION = " "; @@ -208,57 +204,6 @@ export function composeDecisionBody( return rows; } -/** - * Wrap one choice label at the inner width (box minus the marker/indent). - * Continuation hanging indent is applied by `decisionChoiceRows`, not here. - */ -function choiceWrapLines(label: string, width: number): string[] { - const inner = Math.max(1, width - stringWidth(CHOICE_INDENT)); - return wrapWords(label, inner); -} - -/** - * Shared row count for every choice at `width`: at least - * `DECISION_CHOICE_ROWS`, raised to the tallest wrap so nothing is clipped. - */ -export function decisionChoiceRowCount(labels: readonly string[], width: number): number { - let rows = DECISION_CHOICE_ROWS; - for (const label of labels) { - rows = Math.max(rows, choiceWrapLines(label, width).length); - } - return rows; -} - -/** - * Shape one choice into a fixed-height block: the label, marked when active, - * wrapped on word boundaries with a hanging indent. `rowCount` pads shorter - * wraps with empty dim rows so every choice occupies the same height. - */ -export function decisionChoiceRows( - label: string, - active: boolean, - width: number, - rowCount?: number, -): OverlayBodyRow[] { - const fg = active ? UI.text : UI.textDim; - const prefix = active ? `${DECISION_ACTIVE_MARK} ` : CHOICE_INDENT; - const parts = choiceWrapLines(label, width); - const height = Math.max(DECISION_CHOICE_ROWS, parts.length, rowCount ?? 0); - const rows: OverlayBodyRow[] = []; - for (let i = 0; i < height; i++) { - const part = parts[i]; - if (part === undefined) { - rows.push({ text: "", fg: UI.textDim }); - continue; - } - rows.push({ - text: i === 0 ? `${prefix}${part}` : `${CONTINUATION}${part}`, - fg, - }); - } - return rows; -} - /** Below this overlay width the description zone has no room to say anything legible. */ const DESCRIPTION_ZONE_MIN_WIDTH = 16; /** Below this overlay width the zone keeps `what` only and drops `impact`. */ diff --git a/src/tui/overlay-fixture-fallback.test.ts b/src/tui/overlay-fixture-fallback.test.ts index 5c00cfa3f..ff6bd3996 100644 --- a/src/tui/overlay-fixture-fallback.test.ts +++ b/src/tui/overlay-fixture-fallback.test.ts @@ -6,7 +6,9 @@ import { describe, expect, test } from "bun:test"; import { openSettingsSurface, type CommandSurfaceDeps } from "./command-surfaces.js"; import { withTestRenderer } from "./harness.js"; -import { createAppShell } from "./shell.js"; +import { createAppShell } from "./shell/index.js"; +import { closeInsetOverlay } from "./shell/overlay-host.js"; +import { openModelPickerOverlay, openOperatorOverlay, openPermissionsOverlay } from "./overlays.js"; describe("overlay dependency gaps never render fixture content", () => { test("settings surface without a settings dependency shows no fabricated rows", async () => { @@ -32,4 +34,35 @@ describe("overlay dependency gaps never render fixture content", () => { { width: 80, height: 24 }, ); }); + + test("list overlays have no fallback rows — only the caller's items render", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }); + try { + openPermissionsOverlay(shell, { items: ["Allow once", "Deny"] }); + expect(shell.overlayItems).toEqual(["Allow once", "Deny"]); + closeInsetOverlay(shell); + + openModelPickerOverlay(shell, { items: ["grok-3 * [xai]"] }); + expect(shell.overlayItems).toEqual(["grok-3 * [xai]"]); + closeInsetOverlay(shell); + + openOperatorOverlay(shell, { body: "proceed?", choices: ["yes", "no"] }); + expect(shell.overlayItems).toEqual(["yes", "no"]); + + // The deleted demo fixtures must not leak back in anywhere. + expect(shell.overlayItems.some((item) => item.startsWith("Allow tool call #"))).toBe( + false, + ); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); }); diff --git a/src/tui/overlay-float-reset.test.ts b/src/tui/overlay-float-reset.test.ts index acefc2692..1d3ecc8e5 100644 --- a/src/tui/overlay-float-reset.test.ts +++ b/src/tui/overlay-float-reset.test.ts @@ -1,7 +1,9 @@ import { expect, test } from "bun:test"; import { withTestRenderer } from "./harness"; -import { appendStreamRow, closeInsetOverlay, createAppShell } from "./shell"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import { closeInsetOverlay } from "./shell/overlay-host"; import { openModelPickerOverlay } from "./overlays"; import type { PaletteCommand } from "./command-catalog"; diff --git a/src/tui/overlay-list.test.ts b/src/tui/overlay-list.test.ts new file mode 100644 index 000000000..a16c4ed3d --- /dev/null +++ b/src/tui/overlay-list.test.ts @@ -0,0 +1,136 @@ +/** + * Overlay-list navigation and windowing: page/jump/move clamping, the + * end-exclusive visible window, and the setCount/setHeight/reshape logic + * that carries the live selection across a rebuild so a resize or list + * change does not snap the cursor back. + */ +import { describe, expect, test } from "bun:test"; +import { withTestRenderer } from "./harness"; +import { createOverlayList } from "./shell/overlay-list"; + +function windowOf(list: ReturnType): number[] { + const { start, end } = list.visibleRange(); + const indices: number[] = []; + for (let i = start; i < end; i++) indices.push(i); + return indices; +} + +function activeVisible(list: ReturnType): void { + const { start, end } = list.visibleRange(); + expect(list.activeIndex).toBeGreaterThanOrEqual(start); + expect(list.activeIndex).toBeLessThan(end); +} + +async function withList( + opts: { count: number; items: number; activeIndex?: number }, + run: (list: ReturnType) => void, +): Promise { + await withTestRenderer(async (h) => { + run(createOverlayList(h.renderer, opts)); + }); +} + +describe("page / jump", () => { + test("page steps by the window height minus one", async () => { + await withList({ count: 30, items: 5 }, (list) => { + list.page(1); + expect(list.activeIndex).toBe(4); + list.page(-1); + expect(list.activeIndex).toBe(0); + activeVisible(list); + }); + }); + + test("jump clamps into the list", async () => { + await withList({ count: 10, items: 5 }, (list) => { + list.jump(7); + expect(list.activeIndex).toBe(7); + activeVisible(list); + list.jump(-5); + expect(list.activeIndex).toBe(0); + list.jump(99); + expect(list.activeIndex).toBe(9); + }); + }); + + test("empty-list navigation is a no-op", async () => { + await withList({ count: 0, items: 5 }, (list) => { + list.move(1); + list.move(-1); + list.page(1); + list.page(-1); + expect(list.activeIndex).toBe(0); + expect(list.offset).toBe(0); + expect(list.visibleRange()).toEqual({ start: 0, end: 0 }); + }); + }); + + test("move clamps at both ends", async () => { + await withList({ count: 5, items: 3 }, (list) => { + list.move(-10); + expect(list.activeIndex).toBe(0); + list.move(100); + expect(list.activeIndex).toBe(4); + activeVisible(list); + }); + }); + + test("height 1 pages by one row", async () => { + await withList({ count: 10, items: 1 }, (list) => { + list.page(1); + expect(list.activeIndex).toBe(1); + expect(list.offset).toBe(1); + activeVisible(list); + }); + }); + + test("the visible window is end-exclusive and never past the count", async () => { + await withList({ count: 10, items: 3 }, (list) => { + const { start, end } = list.visibleRange(); + expect(end - start).toBe(3); + expect(windowOf(list)).toEqual([0, 1, 2]); + list.jump(9); + const last = list.visibleRange(); + expect(last.end).toBe(10); + expect(last.start).toBeLessThanOrEqual(9); + }); + }); + + test("a short list's window ends at the count", async () => { + await withList({ count: 2, items: 8 }, (list) => { + expect(list.visibleRange()).toEqual({ start: 0, end: 2 }); + expect(windowOf(list)).toEqual([0, 1]); + }); + }); +}); + +describe("setCount / setHeight", () => { + test("shrinking the count clamps the active row", async () => { + await withList({ count: 20, items: 5, activeIndex: 15 }, (list) => { + list.setCount(4); + expect(list.count).toBe(4); + expect(list.activeIndex).toBeLessThan(4); + activeVisible(list); + }); + }); + + test("resizing the height keeps the active row visible", async () => { + await withList({ count: 30, items: 5, activeIndex: 12 }, (list) => { + list.setHeight(3); + expect(list.height).toBe(3); + activeVisible(list); + list.setHeight(30); + expect(windowOf(list)).toContain(12); + }); + }); + + test("two-row-per-item reshape keeps the item capacity", async () => { + await withList({ count: 30, items: 5 }, (list) => { + list.setHeight(5, 2); + expect(windowOf(list).length).toBeLessThanOrEqual(5); + list.move(4); + list.move(1); + activeVisible(list); + }); + }); +}); diff --git a/src/tui/overlay-overflow.test.ts b/src/tui/overlay-overflow.test.ts index e9cbb5c57..79d8e8985 100644 --- a/src/tui/overlay-overflow.test.ts +++ b/src/tui/overlay-overflow.test.ts @@ -10,15 +10,13 @@ import { describe, expect, test } from "bun:test"; import type { PermissionRequest } from "../permission/types.js"; import { withTestRenderer } from "./harness.js"; import { OVERLAY_MAX_FRACTION } from "./geometry/index.js"; -import { - acceptOverlaySelection, - appendStreamRow, - createAppShell, - moveOverlaySelection, - type AppShell, -} from "./shell.js"; -import { visibleSlice } from "./list-viewport.js"; -import { makePermissionItems, openOperatorOverlay, openPermissionsOverlay } from "./overlays.js"; +import { appendStreamRow } from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; +import type { AppShell } from "./shell/internals.js"; +import { acceptOverlaySelection } from "./shell/overlay-host.js"; +import { moveOverlaySelection } from "./shell/overlay-list.js"; +import { makePermissionItems } from "./harness.js"; +import { openOperatorOverlay, openPermissionsOverlay } from "./overlays.js"; import { operatorChoicesFromOptions, permissionBodyFromRequest, @@ -52,7 +50,7 @@ function activeVisible(shell: AppShell): void { const list = shell.overlayList; expect(list).not.toBeNull(); if (!list) return; - const slice = visibleSlice(list); + const slice = list.visibleRange(); expect(list.activeIndex).toBeGreaterThanOrEqual(slice.start); expect(list.activeIndex).toBeLessThan(slice.end); } diff --git a/src/tui/overlay-paint.test.ts b/src/tui/overlay-paint.test.ts index ecf0a1481..1b41f7dfe 100644 --- a/src/tui/overlay-paint.test.ts +++ b/src/tui/overlay-paint.test.ts @@ -8,18 +8,18 @@ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness.js"; +import { appendStreamRow } from "./shell/chrome.js"; +import { enterCopyMode } from "./shell/copy.js"; +import { createAppShell } from "./shell/index.js"; +import type { AppShell } from "./shell/internals.js"; +import { openListOverlay } from "./shell/overlay-host.js"; import { - appendStreamRow, - createAppShell, - enterCopyMode, openHelpOverlay, - openListOverlay, openMentionsOverlay, openPalette, openSettingsOverlay, - setPromptModelLabel, - type AppShell, -} from "./shell.js"; +} from "./shell/palette.js"; +import { setPromptModelLabel } from "./shell/prompt.js"; const MODEL_LABEL = "xai/thegreataxios · grok-4.5"; @@ -108,13 +108,13 @@ describe("overlay host never shares cells with the prompt border", () => { const expected = [ " model · Esc cancel · Enter choose · Alt+A /connect add provider", - ` > ${ITEMS[0]}`, + ` ▶ ${ITEMS[0]}`, ...ITEMS.slice(1).map((i) => ` ${i}`), ]; expectCleanInterior(interior, expected); // The selected row must be intact, not overwritten by the model label. - expect(interior).toContain(` > ${ITEMS[0]}`); + expect(interior).toContain(` ▶ ${ITEMS[0]}`); for (const row of interior) { expect(row.includes(MODEL_LABEL)).toBe(false); expect(row.includes("thegreataxios")).toBe(false); diff --git a/src/tui/overlay-primary-state.test.ts b/src/tui/overlay-primary-state.test.ts index 80db392fc..4bbf9d5c7 100644 --- a/src/tui/overlay-primary-state.test.ts +++ b/src/tui/overlay-primary-state.test.ts @@ -2,18 +2,17 @@ import { describe, expect, test } from "bun:test"; import { focusOwner } from "./focus"; import { withTestRenderer } from "./harness"; +import { createAppShell } from "./shell/index"; +import type { OverlaySelection } from "./shell/internals"; import { acceptOverlaySelection, closeInsetOverlay, closeReplaceableOverlay, - createAppShell, - cycleOverlaySelection, openListOverlay, - openPalette, setOwnedOverlayItems, - toggleOverlayExpand, - type OverlaySelection, -} from "./shell"; +} from "./shell/overlay-host"; +import { cycleOverlaySelection, toggleOverlayExpand } from "./shell/overlay-list"; +import { openPalette } from "./shell/palette"; const catalog = [{ id: "help", label: "/help", keywords: ["help"] }]; diff --git a/src/tui/overlay-reshape-selection.test.ts b/src/tui/overlay-reshape-selection.test.ts new file mode 100644 index 000000000..ded8cfafe --- /dev/null +++ b/src/tui/overlay-reshape-selection.test.ts @@ -0,0 +1,41 @@ +/** + * Regression: a reshape rebuild (resize -> setHeight, or an item-set refresh) + * must carry the live selection index across, clamped to the new item count — + * not snap back to the index the list opened at. + */ +import { describe, expect, test } from "bun:test"; +import { withTestRenderer } from "./harness"; +import { createOverlayList } from "./shell/overlay-list"; + +describe("reshape keeps selection", () => { + test("setHeight rebuild keeps the moved selection in a 30-item list", async () => { + await withTestRenderer((h) => { + const list = createOverlayList(h.renderer, { count: 30, items: 5 }); + for (let i = 0; i < 9; i++) list.move(1); + expect(list.activeIndex).toBe(9); + list.setHeight(10); + expect(list.activeIndex).toBe(9); + list.setHeight(3, 2); + expect(list.activeIndex).toBe(9); + }); + }); + + test("reshape clamps the selection to a shrunken item count", async () => { + await withTestRenderer((h) => { + const list = createOverlayList(h.renderer, { count: 30, items: 5 }); + for (let i = 0; i < 9; i++) list.move(1); + list.setCount(3); + list.setHeight(10); + expect(list.activeIndex).toBe(2); + }); + }); + + test("rows-per-item reshape keeps the selection on the description pair", async () => { + await withTestRenderer((h) => { + const list = createOverlayList(h.renderer, { count: 10, items: 4 }); + for (let i = 0; i < 5; i++) list.move(1); + list.setHeight(4, 2); + expect(list.activeIndex).toBe(5); + }); + }); +}); diff --git a/src/tui/overlay-view.test.ts b/src/tui/overlay-view.test.ts index ee8a3e05a..49193c315 100644 --- a/src/tui/overlay-view.test.ts +++ b/src/tui/overlay-view.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { TextRenderable } from "@opentui/core"; +import { SelectRenderable, TextRenderable } from "@opentui/core"; import { withTestRenderer } from "./harness"; -import { createListViewport } from "./list-viewport"; +import { createOverlayList } from "./shell/overlay-list"; import { createOverlayView, overlayChromeRows, @@ -15,21 +15,31 @@ const palette: OverlayListPresentation = { kind: "palette", items: ["/help", "/model", "/mcp"], paletteCommands: [{ label: "/help" }, { label: "/model" }, { label: "/mcp" }], - viewport: createListViewport({ count: 3, height: 3 }), + list: null, bodyLines: [], bodyFgs: [], answer: null, describe: () => undefined, }; +/** Text rows the body paints itself; the SelectRenderable renders the list. */ function bodyRows(view: ReturnType): string[] { - return view.body.getChildren().map((row) => { - if (!(row instanceof TextRenderable)) throw new Error("expected an overlay text row"); - return row.content.chunks.map((chunk) => chunk.text).join(""); - }); + return view.body + .getChildren() + .filter((row): row is TextRenderable => row instanceof TextRenderable) + .map((row) => row.content.chunks.map((chunk) => chunk.text).join("")); +} + +function bodySelect(view: ReturnType): SelectRenderable { + const found = view.body.getChildren().find((row) => row instanceof SelectRenderable); + if (!(found instanceof SelectRenderable)) throw new Error("expected the overlay list"); + return found; } -async function paletteFrame(width: number, presentation = palette): Promise { +async function paletteFrame( + width: number, + presentation: Omit = palette, +): Promise { return withTestRenderer( async (h) => { const view = createOverlayView(h.renderer); @@ -37,7 +47,16 @@ async function paletteFrame(width: number, presentation = palette): Promise { ...palette, items: [label], paletteCommands: [{ label }], - viewport: createListViewport({ count: 1, height: 1 }), }); const help = rows.find((row) => row.includes("help")); expect(help).toBe(" /help-abcdefghij…"); @@ -78,21 +96,22 @@ describe("overlay view", () => { ...palette, kind: "model_picker", items: ["first"], - viewport: createListViewport({ count: 1, height: 1 }), + list: createOverlayList(h.renderer, { count: 1, items: 1 }), bodyLines: ["context"], answer: { text: "typed", active: true }, describe: () => { - expect(bodyRows(view)).toEqual([" context", " > first", " answer> typed▌"]); + expect(bodyRows(view)).toEqual([" context", " answer> typed▌"]); + expect(bodySelect(view).getSelectedOption()?.name).toBe("first"); return { what: "late description" }; }, }; view.paintList(presentation, 80); expect(bodyRows(view)).toContain(" late description"); view.paintList({ ...presentation, describe: () => null }, 80); - expect(bodyRows(view)).toHaveLength(6); + expect(bodyRows(view)).toHaveLength(5); view.paintList({ ...presentation, describe: () => undefined }, 80); - expect(bodyRows(view)).toEqual([" context", " > first", " answer> typed▌"]); - view.paintList({ ...presentation, viewport: null }, 80); + expect(bodyRows(view)).toEqual([" context", " answer> typed▌"]); + view.paintList({ ...presentation, list: null }, 80); expect(bodyRows(view)).toEqual([]); }); }); @@ -131,7 +150,7 @@ describe("overlay view", () => { expect(chrome).toBe(9); expect(overlayChromeRows("palette", 2, true, true)).toBe(8); expect(overlayChromeRows("model_picker", 2, false, false)).toBe(5); - const perItem = overlayRowsPerItem("model_picker", ["first", "second"], 80); + const perItem = overlayRowsPerItem("model_picker"); expect(perItem).toBe(1); expect(overlayMinHostRows(chrome, perItem, true)).toBe(10); expect(overlayMinHostRows(chrome, perItem, false)).toBe(9); diff --git a/src/tui/overlay-view.ts b/src/tui/overlay-view.ts index 2b51f6061..a74afe06b 100644 --- a/src/tui/overlay-view.ts +++ b/src/tui/overlay-view.ts @@ -1,14 +1,13 @@ import { BoxRenderable, TextRenderable, type RenderContext } from "@opentui/core"; import { middleEllipsis } from "./command-display.js"; import { formatPaletteRows, type PaletteCommand } from "./command-catalog.js"; -import { visibleSlice, type ListViewportState } from "./list-viewport.js"; -import { - decisionChoiceRows, - decisionChoiceRowCount, - describeZoneLines, - DESCRIPTION_ZONE_LINES, -} from "./overlay-body.js"; -import type { ItemDescription, OpenListOverlayOpts, PrimaryOverlayKind } from "./shell.js"; +import type { + OverlayList, + ItemDescription, + OpenListOverlayOpts, + PrimaryOverlayKind, +} from "./shell/internals.js"; +import { DECISION_CHOICE_ROWS, describeZoneLines, DESCRIPTION_ZONE_LINES } from "./overlay-body.js"; import { destroySubtree } from "./teardown.js"; import { UI } from "./theme.js"; @@ -26,7 +25,7 @@ export interface OverlayListPresentation { readonly kind: PrimaryOverlayKind | null; readonly items: readonly string[]; readonly paletteCommands: readonly Pick[]; - readonly viewport: ListViewportState | null; + readonly list: OverlayList | null; readonly bodyLines: readonly string[]; readonly bodyFgs: readonly string[]; readonly answer: { readonly text: string; readonly active: boolean } | null; @@ -65,13 +64,10 @@ export function isDecisionOverlay(kind: PrimaryOverlayKind | null): boolean { } /** Display rows one list item occupies for the open overlay. */ -export function overlayRowsPerItem( - kind: PrimaryOverlayKind | null, - items: readonly string[], - contentWidth: number, -): number { - if (!isDecisionOverlay(kind)) return 1; - return decisionChoiceRowCount(items, overlayRowWidth(contentWidth)); +export function overlayRowsPerItem(kind: PrimaryOverlayKind | null): number { + // Decision rows paint SelectRenderable's fixed name + description pair, so + // the reservation is that pair — a growing budget leaves a blank band. + return isDecisionOverlay(kind) ? DECISION_CHOICE_ROWS : 1; } /** @@ -281,11 +277,12 @@ export function createOverlayView(ctx: RenderContext) { /** * Selection is a text colour, not a marker or a filled band: the highlighted * row already stands out by sitting under the cursor, so a leading `>` and a - * grey block would both be saying the same thing twice. + * grey block would both be saying the same thing twice. The palette keeps + * even the indicator glyph off — its rows are aligned columns. */ function paintPaletteList( commands: OverlayListPresentation["paletteCommands"], - list: ListViewportState, + list: OverlayList, contentWidth: number, ): void { const interior = overlayInteriorWidth(contentWidth); @@ -293,13 +290,10 @@ export function createOverlayView(ctx: RenderContext) { commands.map((command) => command.label), Math.max(4, interior - 1), ); - const slice = visibleSlice(list); - for (let i = slice.start; i < slice.end; i++) { - const line = lines[i] ?? ""; - const active = i === list.activeIndex; - const content = ` ${line}`.padEnd(interior); - addOverlayRow(content, active ? UI.text : UI.textDim); - } + list.setHeight(list.height, 1); + list.select.showSelectionIndicator = false; + list.select.options = lines.map((line) => ({ name: line, description: "" })); + body.add(list.select); } /** Paint the fixed rule + two-line description zone under the list, when `describe` is set. */ @@ -335,8 +329,17 @@ export function createOverlayView(ctx: RenderContext) { addOverlayRow(` ${label}${tail}${ANSWER_CURSOR}`, UI.text); } + /** + * Detach the SelectRenderable before `clearBody` destroys the body's + * children — the list owns it across paints, it only re-homes. + */ + function detachList(list: OverlayList): void { + if (list.select.parent === body) body.remove(list.select); + } + function paintList(presentation: OverlayListPresentation, contentWidth: number): void { - const list = presentation.viewport; + const list = presentation.list; + if (list) detachList(list); clearBody(); if (!list) return; presentation.bodyLines.forEach((line, i) => { @@ -348,20 +351,19 @@ export function createOverlayView(ctx: RenderContext) { return; } const decision = isDecisionOverlay(presentation.kind); - const width = overlayRowWidth(contentWidth); - const perItem = overlayRowsPerItem(presentation.kind, presentation.items, contentWidth); - const slice = visibleSlice(list); - for (let i = slice.start; i < slice.end; i++) { - const label = presentation.items[i] ?? `item ${i}`; - const active = i === list.activeIndex; - if (!decision) { - addOverlayRow(` ${active ? ">" : " "} ${label}`, active ? UI.text : UI.textDim); - continue; - } - for (const row of decisionChoiceRows(label, active, width, perItem)) { - addOverlayRow(` ${row.text}`, row.fg); - } - } + // Choice labels are bare action names (scope hints paint in the body + // above), so each one paints SelectRenderable's name row plus its reserved + // second row of air — nothing wraps, nothing clips. + list.setHeight(list.height, decision ? DECISION_CHOICE_ROWS : 1); + list.select.showSelectionIndicator = true; + list.select.options = presentation.items.map((label) => ({ + name: label, + description: "", + })); + // An empty list renders nothing — the renderable would still claim a row + // for its background, spending layout budget a chooser with no choices did + // not reserve. + if (presentation.items.length > 0) body.add(list.select); paintAnswerRow(presentation.answer, contentWidth); paintDescriptionZone(presentation.describe, contentWidth); } diff --git a/src/tui/overlays.test.ts b/src/tui/overlays.test.ts index 5cdc0ec25..17050d922 100644 --- a/src/tui/overlays.test.ts +++ b/src/tui/overlays.test.ts @@ -5,28 +5,24 @@ import { describe, expect, test } from "bun:test"; import { rgbToHex, type KeyEvent } from "@opentui/core"; import { IDLE_TRANSCRIPT_FLOOR, OVERLAY_TRANSCRIPT_FLOOR } from "./geometry/index"; import { focusOwner, scrollLease } from "./focus/index"; -import { withTestRenderer } from "./harness"; import { makePermissionItems, - openModelPickerOverlay, - openOperatorOverlay, - openPermissionsOverlay, - wrapOverlayBody, -} from "./overlays"; + makeModelPickerItems, + makeOperatorQuestion, + withTestRenderer, +} from "./harness"; +import { openModelPickerOverlay, openOperatorOverlay, openPermissionsOverlay } from "./overlays"; +import { wrapOverlayText } from "./overlay-body"; +import { relayout } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; import { - acceptOverlaySelection, clearShellOverlayHooks, - closeInsetOverlay, - createAppShell, - handleListFilterKey, - moveOverlaySelection, - openListOverlay, - pageOverlaySelection, - relayout, setShellOverlayHooks, type OverlaySelection, -} from "./shell"; -import { visibleSlice } from "./list-viewport"; +} from "./shell/internals"; +import { acceptOverlaySelection, closeInsetOverlay, openListOverlay } from "./shell/overlay-host"; +import { moveOverlaySelection, pageOverlaySelection } from "./shell/overlay-list"; +import { handleListFilterKey } from "./shell/palette"; import { UI } from "./theme"; function colorHex(c: unknown): string { @@ -48,7 +44,7 @@ describe("overlay host chrome", () => { expect(colorHex(shell.overlayHost.borderColor)).toBe(UI.textDim); expect(colorHex(shell.overlayTitle.fg)).toBe(UI.textDim); - openOperatorOverlay(shell); + openOperatorOverlay(shell, makeOperatorQuestion()); expect(colorHex(shell.overlayHost.borderColor)).toBe(UI.textDim); expect(colorHex(shell.overlayTitle.fg)).toBe(UI.textDim); } finally { @@ -60,14 +56,14 @@ describe("overlay host chrome", () => { }); }); -describe("wrapOverlayBody", () => { +describe("wrapOverlayText", () => { test("splits long lines and caps", () => { - const lines = wrapOverlayBody("abcdefghij", 4, 3); + const lines = wrapOverlayText("abcdefghij", 4, 3); expect(lines).toEqual(["abcd", "efgh", "ij"]); }); test("preserves blank lines from newlines", () => { - const lines = wrapOverlayBody("a\n\nb", 40, 8); + const lines = wrapOverlayText("a\n\nb", 40, 8); expect(lines).toEqual(["a", "", "b"]); }); }); @@ -109,7 +105,7 @@ describe("permissions overlay", () => { moveOverlaySelection(shell, 1); } expect(shell.overlayList!.activeIndex).toBe(listH + 5); - const slice = visibleSlice(shell.overlayList!); + const slice = shell.overlayList!.visibleRange(); expect(shell.overlayList!.activeIndex).toBeGreaterThanOrEqual(slice.start); expect(shell.overlayList!.activeIndex).toBeLessThan(slice.end); @@ -192,7 +188,7 @@ describe("permissions overlay", () => { const before = shell.overlayList!.activeIndex; pageOverlaySelection(shell, 1); expect(shell.overlayList!.activeIndex).toBeGreaterThan(before); - const slice = visibleSlice(shell.overlayList!); + const slice = shell.overlayList!.visibleRange(); expect(shell.overlayList!.activeIndex).toBeGreaterThanOrEqual(slice.start); expect(shell.overlayList!.activeIndex).toBeLessThan(slice.end); } finally { @@ -214,7 +210,7 @@ describe("operator question overlay", () => { run: "idle", }); try { - openOperatorOverlay(shell); + openOperatorOverlay(shell, makeOperatorQuestion()); expect(shell.overlayKind).toBe("operator"); expect(shell.layout.overlayMode).toBe("inset"); expect(shell.layout.transcriptHeight).toBeGreaterThanOrEqual(OVERLAY_TRANSCRIPT_FLOOR); @@ -261,7 +257,7 @@ describe("model / provider picker", () => { run: "idle", }); try { - openModelPickerOverlay(shell); + openModelPickerOverlay(shell, { items: makeModelPickerItems() }); expect(shell.overlayKind).toBe("model_picker"); expect(shell.overlayItems.length).toBeGreaterThanOrEqual(5); expect(focusOwner(shell.focus)).toBe("overlay"); @@ -616,7 +612,7 @@ describe("echoChoice defaults to on for callers with no gate policy", () => { wireKeys: false, }); try { - openOperatorOverlay(shell, { choices: ["A", "B"] }); + openOperatorOverlay(shell, { body: "pick one", choices: ["A", "B"] }); const before = shell.streamLog.length; acceptOverlaySelection(shell); expect(shell.streamLog.length - before).toBe(1); diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index cabbed9d3..2c19b97f7 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -3,74 +3,23 @@ * Pure content builders + open helpers on the shared list/focus/geometry kit. */ -import type { AppShell, ItemDescription, OverlaySelection, PrimaryOverlayKind } from "./shell.js"; +import type { + AppShell, + ItemDescription, + OverlaySelection, + PrimaryOverlayKind, +} from "./shell/internals.js"; +import { + closeReplaceableOverlay, + openListOverlay, + reserveOverlayHost, +} from "./shell/overlay-host.js"; import type { KeyEvent } from "@opentui/core"; -import { wrapOverlayText } from "./overlay-body.js"; -import { closeReplaceableOverlay, openListOverlay, reserveOverlayHost } from "./shell.js"; - export type { OverlaySelection, PrimaryOverlayKind }; -/** Fixture: 30 permission options (acceptance scenario 2). */ -export function makePermissionItems(count = 30): readonly string[] { - const n = Math.max(1, Math.floor(count)); - return Array.from({ length: n }, (_, i) => { - if (i === 0) return "Allow once"; - if (i === 1) return "Allow session"; - if (i === 2) return "Always allow this tool"; - if (i === 3) return "Deny"; - return `Allow tool call #${i - 3}`; - }); -} - -/** Fixture: long operator question + many choices (acceptance scenario 3). */ -export function makeOperatorQuestion(): { - readonly body: string; - readonly choices: readonly string[]; -} { - const body = [ - "The agent wants to run a destructive command on the working tree.", - "Review the plan carefully — this cannot be undone from the TUI.", - "", - "Proposed: git reset --hard origin/main && rm -rf node_modules", - "Files at risk: 128 modified, 12 untracked.", - "Continue only if you accept discarding local work.", - ].join("\n"); - const choices = [ - "Cancel — keep working tree", - "Allow this once", - "Allow for this session", - "Always allow git reset", - "Open diff first", - "Ask again later", - "Switch to dry-run", - "Abort agent run", - ]; - return { body, choices }; -} - -/** Fixture: model/provider picker list. */ -export function makeModelPickerItems(): readonly string[] { - return [ - "claude-sonnet-4 * [anthropic]", - "claude-opus-4 * [anthropic]", - "gpt-5 * [openai]", - "gpt-5-mini * [openai]", - "gemini-2.5-pro * [google]", - "gemini-2.5-flash * [google]", - "grok-3 * [xai]", - "ollama-llama3.3 * [local]", - "o3 * [codex]", - "o4-mini * [codex]", - ]; -} - -/** Wrap overlay body text to terminal width on word boundaries (no paint). */ -export function wrapOverlayBody(text: string, width: number, maxLines = 8): readonly string[] { - return wrapOverlayText(text, width, maxLines); -} - export interface OpenPermissionsOpts { - readonly items?: readonly string[]; + /** Choices to offer; there is no fallback list — callers supply their own. */ + readonly items: readonly string[]; /** Stable ids aligned with `items` (e.g. ApprovalScope.id). */ readonly itemIds?: readonly string[]; readonly activeIndex?: number; @@ -93,12 +42,11 @@ export interface OpenPermissionsOpts { readonly echoChoice?: boolean; } -export function openPermissionsOverlay(shell: AppShell, opts?: OpenPermissionsOpts): void { - const items = opts?.items ?? makePermissionItems(30); +export function openPermissionsOverlay(shell: AppShell, opts: OpenPermissionsOpts): void { openListOverlay(shell, { kind: "permissions", title: "permissions", - items, + items: opts.items, activeIndex: opts?.activeIndex ?? 0, frameId: "overlay-permissions", ...(opts?.body !== undefined ? { body: opts.body } : {}), @@ -112,8 +60,10 @@ export function openPermissionsOverlay(shell: AppShell, opts?: OpenPermissionsOp } export interface OpenOperatorOpts { - readonly body?: string; - readonly choices?: readonly string[]; + /** Question text painted above the choices. */ + readonly body: string; + /** Choices to offer; there is no fallback list — callers supply their own. */ + readonly choices: readonly string[]; readonly itemIds?: readonly string[]; readonly activeIndex?: number; /** Per-open accept; host binds OperatorResult mapping. */ @@ -141,16 +91,13 @@ export interface OpenOperatorOpts { const NO_WAY_TO_ANSWER = "No options were offered and this question takes no typed answer. Press Esc to cancel it."; -export function openOperatorOverlay(shell: AppShell, opts?: OpenOperatorOpts): void { - const fixture = makeOperatorQuestion(); - const choices = opts?.choices ?? fixture.choices; - const body = opts?.body ?? fixture.body; - const stranded = choices.length === 0 && opts?.onTextAnswer === undefined; +export function openOperatorOverlay(shell: AppShell, opts: OpenOperatorOpts): void { + const stranded = opts.choices.length === 0 && opts.onTextAnswer === undefined; openListOverlay(shell, { kind: "operator", title: "", - body: stranded ? `${body}\n\n${NO_WAY_TO_ANSWER}` : body, - items: choices, + body: stranded ? `${opts.body}\n\n${NO_WAY_TO_ANSWER}` : opts.body, + items: opts.choices, activeIndex: opts?.activeIndex ?? 0, frameId: "overlay-operator", // Chat-first: keep the transcript visible while the operator answers. @@ -164,7 +111,8 @@ export function openOperatorOverlay(shell: AppShell, opts?: OpenOperatorOpts): v } export interface OpenModelPickerOpts { - readonly items?: readonly string[]; + /** Models to list; there is no fallback list — callers supply their own. */ + readonly items: readonly string[]; /** Stable model/provider ids aligned with `items`. */ readonly itemIds?: readonly string[]; readonly activeIndex?: number; @@ -187,14 +135,14 @@ export interface OpenModelPickerOpts { readonly setDefaultHint?: boolean; } -export function openModelPickerOverlay(shell: AppShell, opts?: OpenModelPickerOpts): void { +export function openModelPickerOverlay(shell: AppShell, opts: OpenModelPickerOpts): void { const release = reserveOverlayHost(shell); try { closeReplaceableOverlay(shell); openListOverlay(shell, { kind: "model_picker", title: "model / provider", - items: opts?.items ?? makeModelPickerItems(), + items: opts.items, activeIndex: opts?.activeIndex ?? 0, frameId: "overlay-model", ...(opts?.itemIds !== undefined ? { itemIds: opts.itemIds } : {}), diff --git a/src/tui/palette-paint.test.ts b/src/tui/palette-paint.test.ts index 85828a8e8..21dbcec22 100644 --- a/src/tui/palette-paint.test.ts +++ b/src/tui/palette-paint.test.ts @@ -8,14 +8,11 @@ import type { KeyEvent } from "@opentui/core"; import { withTestRenderer } from "./harness"; import type { PaletteCommand } from "./command-catalog"; -import { - acceptOverlaySelection, - createAppShell, - handlePaletteFilterKey, - moveOverlaySelection, - openPalette, - type AppShell, -} from "./shell"; +import { createAppShell } from "./shell/index"; +import type { AppShell } from "./shell/internals"; +import { acceptOverlaySelection } from "./shell/overlay-host"; +import { moveOverlaySelection } from "./shell/overlay-list"; +import { handlePaletteFilterKey, openPalette } from "./shell/palette"; const CATALOG: readonly PaletteCommand[] = [ { id: "help", label: "/help", keywords: ["help", "show keymap help"] }, diff --git a/src/tui/plugin-diagnostics-sink.test.ts b/src/tui/plugin-diagnostics-sink.test.ts index 87820248c..3b4c6fd37 100644 --- a/src/tui/plugin-diagnostics-sink.test.ts +++ b/src/tui/plugin-diagnostics-sink.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { fileURLToPath } from "node:url"; import { createPluginLoadDiagnostics, emitPluginWarningLog, @@ -252,14 +253,20 @@ describe("plugin warnings route to plugin ! / /plugins, not startup notices", () // Product lock: discovery / tool-plugin / profile skill-miss summaries must // never become fire-and-forget surfaceSystemNotice chatter. They drive // standingPluginWarnings → setPluginNeedsAttention + /plugins instead. - const src = await Bun.file(new URL("./runner.ts", import.meta.url)).text(); - expect(src).toContain("standingPluginWarnings"); - expect(src).toContain("setPluginNeedsAttention"); - expect(src).not.toMatch( + // CL-6791 phase 4 split runner.ts into runner/*; the lock spans the + // directory. + const runnerDir = fileURLToPath(new URL("./runner/", import.meta.url)); + const src = Array.from(new Bun.Glob("*.ts").scanSync({ cwd: runnerDir })) + .filter((f) => !f.endsWith(".test.ts")) + .map(async (f) => await Bun.file(`${runnerDir}${f}`).text()); + const sources = (await Promise.all(src)).join("\n"); + expect(sources).toContain("standingPluginWarnings"); + expect(sources).toContain("setPluginNeedsAttention"); + expect(sources).not.toMatch( /startupPluginNotices\.push\(\s*(discoveryNotice|toolPluginNotice|profileNotice)/, ); // Unverified provider-key notice is still allowed on the startup path. - expect(src).toMatch(/startupPluginNotices\.push\([\s\S]*couldn't confirm your/); + expect(sources).toMatch(/startupPluginNotices\.push\([\s\S]*couldn't confirm your/); }); }); diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index 8c6f5e04f..dac561bc5 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -8,16 +8,12 @@ import type { KeyEvent } from "@opentui/core"; import type { PermissionRequest } from "../permission/types.js"; import { AGENTS_PANEL_LINGER_MS } from "./chrome-state.js"; import { createHarness } from "./harness.js"; -import { - acceptOverlaySelection, - handleListFilterKey, - moveOverlaySelection, - runOverlayAction, -} from "./shell.js"; +import { acceptOverlaySelection } from "./shell/overlay-host.js"; +import { moveOverlaySelection, runOverlayAction } from "./shell/overlay-list.js"; +import { handleListFilterKey } from "./shell/palette.js"; import { mountProductHost, operatorResultFromSelection, - permissionChoices, type ProductHostConfig, } from "./product-host.js"; import { buildModelsFirstCatalog, modelOptionId } from "./model-catalog.js"; @@ -79,66 +75,6 @@ async function mountHeadless(overrides: Partial = {}): Promis }; } -function makeRequest(scopes: PermissionRequest["scopes"] = []): PermissionRequest { - return { - tool: "bash", - action: "run", - subject: "ls -la", - scopes, - }; -} - -describe("permissionChoices", () => { - test("always offers Reject + Accept once with stable itemIds", () => { - const { items, itemIds, outcomes } = permissionChoices(makeRequest()); - expect(items).toEqual(["Reject", "Accept once"]); - expect(itemIds).toEqual(["__deny__", "__once__"]); - expect(outcomes).toEqual([{ allow: false }, { allow: true }]); - expect(items).toHaveLength(itemIds.length); - expect(items).toHaveLength(outcomes.length); - }); - - test("appends scopes with hint labels and persist when pattern set", () => { - const scope = { - id: "session-bash", - label: "Allow bash for session", - pattern: "bash:*", - hint: "session", - grant: "session" as const, - }; - const { items, itemIds, outcomes } = permissionChoices(makeRequest([scope])); - expect(items[2]).toBe("Allow bash for session (session)"); - expect(itemIds[2]).toBe("session-bash"); - expect(outcomes[2]).toEqual({ allow: true, persist: scope }); - }); - - test("scope with null pattern allows without persist", () => { - const scope = { - id: "once-path", - label: "This path only", - pattern: null, - }; - const { outcomes, itemIds } = permissionChoices(makeRequest([scope])); - expect(itemIds[2]).toBe("once-path"); - expect(outcomes[2]).toEqual({ allow: true }); - expect("persist" in (outcomes[2] ?? {})).toBe(false); - }); - - test("selection index maps to correct outcome (deny / once / scope)", () => { - const scope = { - id: "proj", - label: "Project", - pattern: "read:*", - }; - const { outcomes } = permissionChoices(makeRequest([scope])); - expect(outcomes[0]).toEqual({ allow: false }); - expect(outcomes[1]).toEqual({ allow: true }); - expect(outcomes[2]).toEqual({ allow: true, persist: scope }); - // out-of-range fallback used by host - expect(outcomes[99] ?? { allow: false }).toEqual({ allow: false }); - }); -}); - describe("operatorResultFromSelection", () => { test("valid index → { kind: option, index }", () => { expect(operatorResultFromSelection({ index: 0 }, 3)).toEqual({ diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 07893aa4f..6cf65a1fe 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -6,7 +6,6 @@ import { EventEmitter } from "node:events"; import { createCliRenderer, type CliRenderer } from "@opentui/core"; -import type { ApprovalOutcome, ApprovalScope, PermissionRequest } from "../permission/types.js"; import type { OperatorResult } from "../agent/tools.js"; import { createLiveSessionPort } from "./live-session-port.js"; import { checkWidthContract, widthContractNotice } from "./width-contract.js"; @@ -41,22 +40,23 @@ import { appendObserveStreamRow, appendStreamRow, clearTranscript, - createAppShell, - isAddProviderShortcutKey, paintChrome, setChromeZones, setHeader, - setPaletteCatalog, - setPaletteOnCommand, setMcpNeedsAuth, - setOwnedOverlayItems, setStatusFlash, - surfaceSystemNotice, +} from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; +import { + setPaletteOnCommand, type AppShell, type ItemDescription, type OverlaySelection, type PaletteOnObserveRequest, -} from "./shell.js"; +} from "./shell/internals.js"; +import { setOwnedOverlayItems } from "./shell/overlay-host.js"; +import { isAddProviderShortcutKey, setPaletteCatalog } from "./shell/palette.js"; +import { surfaceSystemNotice } from "./shell/prompt.js"; import type { QueueKind } from "./session-queue.js"; import { hydrateHistoryRows } from "./history-hydrate.js"; import type { StreamRow } from "./stream.js"; @@ -224,37 +224,6 @@ export interface ProductHost { ) => void; } -/** Build permission overlay rows + ApprovalOutcome table (pure; testable). */ -export function permissionChoices(request: PermissionRequest): { - items: string[]; - itemIds: string[]; - outcomes: ApprovalOutcome[]; -} { - const items: string[] = []; - const itemIds: string[] = []; - const outcomes: ApprovalOutcome[] = []; - - items.push("Reject"); - itemIds.push("__deny__"); - outcomes.push({ allow: false }); - - items.push("Accept once"); - itemIds.push("__once__"); - outcomes.push({ allow: true }); - - for (const scope of request.scopes) { - const label = scope.hint ? `${scope.label} (${scope.hint})` : scope.label; - items.push(label); - itemIds.push(scope.id); - outcomes.push({ - allow: true, - ...(scope.pattern !== null ? { persist: scope as ApprovalScope } : {}), - }); - } - - return { items, itemIds, outcomes }; -} - /** * Map an overlay accept selection to OperatorResult. * Out-of-range index → cancel (Esc-equivalent / bad selection). @@ -304,7 +273,7 @@ export async function mountProductHost(config: ProductHostConfig): Promise renderer.useMouse, set: (enabled: boolean) => { @@ -375,9 +344,15 @@ export async function mountProductHost(config: ProductHostConfig): Promise { if (disposed) return; try { - paintChrome(shell); + // The poll's own state is sticky alone: paintChrome's compose gate makes + // an unchanged pass free, but do not even schedule it while idle — the + // whole point of the poll is the strip, not the chrome. True→false and + // false→true edges both paint via the stickyWasNeeded latch below. const stickyNeeded = chromeState !== null && agentsChromeNeedsSticky(chromeState.agents, Date.now()); + if (stickyNeeded || stickyWasNeeded) { + paintChrome(shell); + } // While the agents strip owns live clocks / linger, skip transcript // syncAgentProgress rewrites — spawn/final/fail anchors still arrive via // event paths; only the sticky clock tick is frozen here. diff --git a/src/tui/prompt-box.test.ts b/src/tui/prompt-box.test.ts index 9d3aaa40a..80a5f2d4f 100644 --- a/src/tui/prompt-box.test.ts +++ b/src/tui/prompt-box.test.ts @@ -9,13 +9,10 @@ import { withTestRenderer, type Harness } from "./harness"; import { PROMPT_BASE_ROWS, PROMPT_CAP_FRACTION, PROMPT_IDLE_ROWS } from "./geometry/index.js"; import { focusOwner } from "./focus/index.js"; import { promptCaretRow, promptRowCount } from "./prompt-input.js"; -import { - appendStreamRow, - closeInsetOverlay, - createAppShell, - toggleShellFocus, - type AppShell, -} from "./shell"; +import { appendStreamRow, toggleShellFocus } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import type { AppShell } from "./shell/internals"; +import { closeInsetOverlay } from "./shell/overlay-host"; import { openPermissionsOverlay } from "./overlays"; function withShell( @@ -225,7 +222,7 @@ describe("openers toggle their surface shut", () => { test("an opener cannot dismiss an approval overlay", async () => { await withShell({ columns: 80, rows: 30 }, (shell, h) => { - openPermissionsOverlay(shell); + openPermissionsOverlay(shell, { items: ["Allow once", "Deny"] }); expect(shell.overlayKind).toBe("permissions"); // A decision surface leaves by a choice or Esc, never because some other diff --git a/src/tui/prompt-chrome.test.ts b/src/tui/prompt-chrome.test.ts index b64c94685..0de8465ef 100644 --- a/src/tui/prompt-chrome.test.ts +++ b/src/tui/prompt-chrome.test.ts @@ -4,18 +4,19 @@ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness"; import { - createAppShell, noticeText, - setPromptCostContext, - setPromptModelLabel, - setPromptWorkspace, setMcpNeedsAuth, setPluginNeedsAttention, - setShellBridgeHooks, - setShellExitHandler, setStatusFlash, +} from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import { setShellBridgeHooks, setShellExitHandler } from "./shell/internals"; +import { + setPromptCostContext, + setPromptModelLabel, + setPromptWorkspace, submitPrompt, -} from "./shell"; +} from "./shell/prompt"; import { RUNTIME_FLASH_MS } from "./runtime-notices"; import { UI } from "./theme"; diff --git a/src/tui/prompt-features.test.ts b/src/tui/prompt-features.test.ts index 415daf943..01aa5a0a8 100644 --- a/src/tui/prompt-features.test.ts +++ b/src/tui/prompt-features.test.ts @@ -7,22 +7,24 @@ import { describe, expect, test } from "bun:test"; import type { PendingImageAttachment } from "./image-attachments.js"; import { withTestRenderer, type Harness } from "./harness"; +import { noticeText } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; import { - acceptOverlaySelection, - attachClipboardImage, - clearPendingAttachments, - createAppShell, - moveOverlaySelection, - noticeText, - openAtMentionSuggestions, setMentionSuggestionSource, setPromptImageSource, - setSentMessageHistory, setShellBridgeHooks, - submitPrompt, type AppShell, type FlashSchedule, -} from "./shell"; +} from "./shell/internals"; +import { acceptOverlaySelection } from "./shell/overlay-host"; +import { moveOverlaySelection } from "./shell/overlay-list"; +import { openAtMentionSuggestions } from "./shell/palette"; +import { + attachClipboardImage, + clearPendingAttachments, + setSentMessageHistory, + submitPrompt, +} from "./shell/prompt"; import { RUNTIME_FLASH_MS } from "./runtime-notices"; const CLIP: PendingImageAttachment = { diff --git a/src/tui/prompt-highlight.test.ts b/src/tui/prompt-highlight.test.ts index 80e12e0c5..2ebfa0e89 100644 --- a/src/tui/prompt-highlight.test.ts +++ b/src/tui/prompt-highlight.test.ts @@ -5,12 +5,9 @@ import { describe, expect, test } from "bun:test"; import { RGBA } from "@opentui/core"; import { withTestRenderer, type Harness } from "./harness"; -import { - createAppShell, - setPromptRecognitionSource, - syncPromptHighlights, - type AppShell, -} from "./shell"; +import { createAppShell } from "./shell/index"; +import { setPromptRecognitionSource, type AppShell } from "./shell/internals"; +import { syncPromptHighlights } from "./shell/prompt"; import { UI } from "./theme"; const ACTION_FG = RGBA.fromHex(UI.action); diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index 2f9041246..1e94f2680 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -10,19 +10,11 @@ import { join } from "node:path"; import { withTestRenderer } from "./harness"; import type { PaletteCommand } from "./command-catalog"; import type { PendingImageAttachment } from "./image-attachments.js"; -import { - CTRL_C_EXIT_WINDOW_MS, - addPendingAttachment, - clearPendingAttachments, - createAppShell, - handleCtrlC, - isSlashPopupOpen, - noticeText, - setShellExitHandler, - setShellRunState, - setStatusFlash, - type AppShell, -} from "./shell"; +import { noticeText, setShellRunState, setStatusFlash } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import { isSlashPopupOpen, setShellExitHandler, type AppShell } from "./shell/internals"; +import { CTRL_C_EXIT_WINDOW_MS, handleCtrlC } from "./shell/keys"; +import { addPendingAttachment, clearPendingAttachments } from "./shell/prompt"; import { RUNTIME_FLASH_MS } from "./runtime-notices"; const CATALOG: readonly PaletteCommand[] = [ diff --git a/src/tui/provider-connect.test.ts b/src/tui/provider-connect.test.ts index 71030416b..4ba5f7f3d 100644 --- a/src/tui/provider-connect.test.ts +++ b/src/tui/provider-connect.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createHarness, type Harness } from "./harness.js"; -import { connectProviderInline } from "./provider-connect.js"; +import { connectProviderInline } from "./provider/connect.js"; import { loadSettings } from "../config/settings.js"; // The mid-session "connect a new provider" flow shares its persistence and diff --git a/src/tui/provider-failure-attempt.test.ts b/src/tui/provider-failure-attempt.test.ts index 2540143ae..5113c2992 100644 --- a/src/tui/provider-failure-attempt.test.ts +++ b/src/tui/provider-failure-attempt.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createProviderFailureAttemptTracker } from "./provider-failure-attempt.js"; +import { createProviderFailureAttemptTracker } from "./provider/failure-attempt.js"; describe("provider failure attempt tracker", () => { test("does not carry a settled attempt's diagnostic into the next attempt", () => { diff --git a/src/tui/provider-setup-submit.test.ts b/src/tui/provider-setup-submit.test.ts index 139e9ce18..55e959fc5 100644 --- a/src/tui/provider-setup-submit.test.ts +++ b/src/tui/provider-setup-submit.test.ts @@ -33,12 +33,12 @@ await withMockedModule( }), ); -const { buildProviderSubmitHandler } = await import("./provider-setup-submit.js"); +const { buildProviderSubmitHandler } = await import("./provider/submit.js"); const { createGlobalSettingsWriter, persistGlobalHTTPMCPServer } = await import("../mcp/add-server.js"); const { loadLocalSettings, loadSettings, localSettingsPath, resolveLocalSettingsPath } = await import("../config/settings.js"); -import type { OAuthResult, ProviderFormValues, SubmitPhase } from "./provider-setup.js"; +import type { OAuthResult, ProviderFormValues, SubmitPhase } from "./provider/types.js"; const noopSetPhase = (_phase: SubmitPhase): void => {}; const stagedCodexTokens = { diff --git a/src/tui/provider-setup.test.ts b/src/tui/provider-setup.test.ts index 9720755c0..7c8cb74ba 100644 --- a/src/tui/provider-setup.test.ts +++ b/src/tui/provider-setup.test.ts @@ -18,34 +18,40 @@ import { addProviderSelectorChoices, connectedAccountCount, CUSTOM_CHOICE_ID, - failureGuidance, instanceSlugsForKind, - LOGIN_CANCELLED_MESSAGE, - LOGIN_TIMEOUT_MESSAGE, - maskEcho, - maskSecret, modelChoiceRows, modelFromRowId, providerChoiceById, providerChoiceRows, providerChoices, resolveApiKeyInstanceName, - runProviderSetup, + TYPE_MODEL_ID, +} from "./provider/choices.js"; +import { + failureGuidance, + maskEcho, + maskSecret, secretFromMaskedEdit, stepHeadline, stepReady, - stepsFor, - suggestOAuthProfileSlug, summaryRows, - TYPE_MODEL_ID, +} from "./provider/form.js"; +import { + LOGIN_CANCELLED_MESSAGE, + LOGIN_TIMEOUT_MESSAGE, + suggestOAuthProfileSlug, validateOAuthProfileSlug, - type OAuthLoginStart, - type OAuthLoginStarter, - type OAuthProfileLister, - type ProviderFormValues, - type ProviderSetupSubmit, - type SubmitOpts, -} from "./provider-setup.js"; +} from "./provider/oauth.js"; +import { runProviderSetup } from "./provider/setup.js"; +import { stepsFor } from "./provider/steps.js"; +import type { + OAuthLoginStart, + OAuthLoginStarter, + OAuthProfileLister, + ProviderFormValues, + ProviderSetupSubmit, + SubmitOpts, +} from "./provider/types.js"; const EMPTY: ProviderFormValues = { name: "", diff --git a/src/tui/provider-setup.ts b/src/tui/provider-setup.ts deleted file mode 100644 index 8141da8f7..000000000 --- a/src/tui/provider-setup.ts +++ /dev/null @@ -1,1947 +0,0 @@ -/** - * First-run provider setup on OpenTUI. - * - * Selection first: the operator picks a known provider from the first-class - * catalog (which prefills base URL and models), types only the API key, then - * picks a model. "Custom" falls back to the full manual form for endpoints the - * catalog does not know. - * - * The surface owns paint + input only; the caller owns the connection test and - * the settings write via `onSubmit`. - */ - -import { - BoxRenderable, - createCliRenderer, - InputRenderable, - InputRenderableEvents, - TextRenderable, - type CliRenderer, - type KeyEvent, -} from "@opentui/core"; - -import { - FIRST_CLASS_PROVIDERS, - firstClassPathAsProvider, - type FirstClassOAuthProvider, - type FirstClassProviderDef, -} from "../../packages/first-class-providers/src/index.js"; -import { CODEX_BASE_URL, CODEX_DEFAULT_MODELS } from "../auth/codex/constants.js"; -import { isOAuthProviderScopeError } from "../auth/oauth-scope-check.js"; -import type { CodexTokens } from "../auth/codex/store.js"; -import type { AuthProfile } from "../auth/oauth/store.js"; -import { XAI_BASE_URL, XAI_DEFAULT_MODELS } from "../auth/xai/constants.js"; -import type { XaiTokens } from "../auth/xai/store.js"; -import { PRODUCT_NAME } from "../branding.js"; -import { - discoverOllamaModels as discoverOllamaModelsRequest, - isOllamaProviderId, - ollamaDiscoveryFailureLine, - type OllamaDiscoveryState, -} from "../provider/ollama.js"; -import { - prefetchGoModels as prefetchGoModelsRequest, - selectableGoModelIds, -} from "../provider/opencode-go-models.js"; -import { codexProviderName } from "../config/codex-providers.js"; -import { xaiProviderName } from "../config/xai-providers.js"; -import { TELEMETRY_NOTICE } from "../telemetry/index.js"; -import { wrapLines } from "./view/height.js"; -import { resolveSideMargin } from "./geometry/margins.js"; -import { - createListViewport, - moveActive, - visibleSlice, - type ListViewportState, -} from "./list-viewport.js"; -import { buildModelsFirstCatalog } from "./model-catalog.js"; -import { rampFor, rampLine } from "./ramp.js"; -import { - residualIdFromSelection, - residualListFromCatalog, - type ResidualCatalogEntry, -} from "./residuals.js"; -import { destroySubtree } from "./teardown.js"; -import { UI } from "./theme.js"; - -export type ProviderField = "name" | "baseURL" | "apiKey" | "model"; - -/** Placeholder shown in the text input for each free-text step. */ -const PROVIDER_FIELD_HINTS: Record = { - name: "openai, anthropic, ollama, …", - baseURL: "https://api.openai.com/v1", - apiKey: "sk-… (blank for keyless/local)", - model: "gpt-4o", -}; - -/** Placeholder for the OAuth account-name step, which edits `oauthProfile`. */ -const OAUTH_PROFILE_HINT = "default, personal, work, …"; - -export interface ProviderFormValues { - name: string; - baseURL: string; - apiKey: string; - model: string; - /** - * Pre-login / pre-key account slug for multi-instance paths (e.g. "personal"). - * Kept apart from `name`, which is only written once the account is settled - * and then carries the compound catalog name (`codex/personal`, - * `openai/work`) — reusing it for the slug would make the field mean two - * different things depending on where the operator is in the flow. Shared - * by OAuth and first-class API-key multi-instance connects; Custom still - * edits `name` free-form. - */ - oauthProfile: string; -} - -/** One screen of the flow. `provider` and `model` can be pick-lists. */ -export type SetupStep = "provider" | "name" | "baseURL" | "apiKey" | "model" | "login"; - -/** Known-provider path: pick, name the instance, paste key, pick model. */ -export const PRESET_STEPS: readonly SetupStep[] = ["provider", "name", "apiKey", "model"]; - -/** Ollama is keyless and keeps its editable root URL visible before discovery. */ -export const OLLAMA_STEPS: readonly SetupStep[] = ["provider", "name", "baseURL", "model"]; - -/** - * Subscription path: pick, name the account (a suggested slug is prefilled; - * reusing an existing name asks for confirmation before re-authorizing it), - * sign in through the browser, pick a model. - */ -export const OAUTH_STEPS: readonly SetupStep[] = ["provider", "name", "login", "model"]; - -/** Unknown endpoint: the full manual form, still preceded by the pick-list. */ -export const CUSTOM_STEPS: readonly SetupStep[] = [ - "provider", - "name", - "baseURL", - "apiKey", - "model", -]; - -const STEP_LABELS: Record = { - provider: "provider", - name: "provider name", - baseURL: "base url", - apiKey: "api key", - model: "model", - login: "sign in", -}; - -const STEP_PROMPTS: Record = { - provider: "pick the provider you have a key or subscription for", - name: "name this provider — you will see it in /model", - baseURL: "paste the provider url — Ollama uses the server root; others may include /v1", - apiKey: "paste the api key — leave blank for a keyless local endpoint", - model: "pick the model to start with", - login: "authorize in the browser — this window waits for you", -}; - -/** Instruction for the multi-instance "name" step (OAuth and API-key). */ -function accountNamePrompt(choice: ProviderChoice): string { - if (choice.oauth != null) { - return `name this account — stored as ${choice.oauth}/, and used again if you reconnect it`; - } - return `name this instance — stored as ${choice.id}/, and used again if you reconnect it`; -} - -// "testing" covers the connection-check call against the entered credentials; -// "saving" covers the settings write that follows once the test succeeds. -export type SubmitPhase = "testing" | "saving"; - -const SUBMIT_PHASE_LABEL: Record = { - testing: "testing connection", - saving: "writing settings", -}; - -/** Catalog id for the manual path. Never written to settings as a name. */ -export const CUSTOM_CHOICE_ID = "custom"; - -/** Pick-list row that drops the model step back to free text. */ -export const TYPE_MODEL_ID = "__type_model__"; - -/** - * A selectable provider. Preset rows carry everything the settings write needs - * except the key; the custom row carries nothing and opens the manual form. - */ -export interface ProviderChoice { - readonly id: string; - readonly label: string; - readonly baseURL: string; - readonly models: readonly string[]; - readonly defaultModel: string; - readonly hint: string; - /** Anthropic Messages protocol rather than OpenAI-compatible chat. */ - readonly anthropic: boolean; - /** OpenCode Go subscription routing. */ - readonly opencodeGo: boolean; - readonly custom: boolean; - /** Browser sign-in flow to run instead of asking for a key. */ - readonly oauth: OAuthKind | null; -} - -export type OAuthKind = FirstClassOAuthProvider; - -/** - * What a signed-in subscription provider resolves to. The endpoint and model - * list are the same constants the auth stack projects into the catalog, so a - * first run and a later `/model` connect land on the same provider entry. - */ -const OAUTH_SURFACES: Record< - OAuthKind, - { - readonly baseURL: string; - readonly models: readonly string[]; - readonly hint: string; - readonly providerName: (profile: string) => string; - } -> = { - codex: { - baseURL: CODEX_BASE_URL, - models: CODEX_DEFAULT_MODELS, - hint: "ChatGPT Plus/Pro subscription", - providerName: codexProviderName, - }, - xai: { - baseURL: XAI_BASE_URL, - models: XAI_DEFAULT_MODELS, - hint: "SuperGrok or X Premium+ subscription", - providerName: xaiProviderName, - }, -}; - -/** Settings/catalog provider name a profile of `kind` is stored under. */ -export function oauthProviderName(kind: OAuthKind, profile: string): string { - return OAUTH_SURFACES[kind].providerName(profile); -} - -/** Longest slug the name step accepts, after normalization. */ -const OAUTH_PROFILE_MAX_LENGTH = 64; - -const OAUTH_PROFILE_CHARS = /^[a-z0-9._-]+$/; -const OAUTH_PROFILE_EDGE_SEPARATOR = /^[._-]|[._-]$/; - -export type OAuthProfileValidation = - { readonly ok: true; readonly slug: string } | { readonly ok: false; readonly error: string }; - -/** - * Validate and lowercase-normalize an operator-entered account slug. This is - * the constraint owner for the slug shape — the auth store and the catalog - * projection (`oauthProviderName`) trust whatever they are handed, since a - * "/" here would silently join into the compound catalog name they build. - */ -export function validateOAuthProfileSlug(raw: string): OAuthProfileValidation { - const slug = raw.trim().toLowerCase(); - if (slug.length === 0) return { ok: false, error: "name cannot be empty" }; - if (slug.length > OAUTH_PROFILE_MAX_LENGTH) { - return { - ok: false, - error: `name must be ${String(OAUTH_PROFILE_MAX_LENGTH)} characters or fewer`, - }; - } - if (!OAUTH_PROFILE_CHARS.test(slug)) { - return { ok: false, error: "use only lowercase letters, numbers, and . _ -" }; - } - if (OAUTH_PROFILE_EDGE_SEPARATOR.test(slug)) { - return { ok: false, error: "name cannot start or end with . _ or -" }; - } - return { ok: true, slug }; -} - -/** - * A slug that does not collide with `existing`, so a first sign-in can - * default to something usable without asking the operator to invent a name. - * "default" first, then "default-2", "default-3", … on collision. - */ -export function suggestOAuthProfileSlug(existing: readonly string[]): string { - const taken = new Set(existing); - if (!taken.has("default")) return "default"; - let n = 2; - while (taken.has(`default-${String(n)}`)) n += 1; - return `default-${String(n)}`; -} - -/** Fetches the names of already-authorized profiles for a provider kind. */ -export type OAuthProfileLister = (kind: OAuthKind) => Promise; - -/** - * Real lister, imported lazily per kind so mounting the surface never touches - * the auth-store files in a test that injects its own lister. - */ -const defaultProfileLister: OAuthProfileLister = async (kind) => { - if (kind === "codex") { - const { listCodexProfiles } = await import("../auth/codex/store.js"); - return (await listCodexProfiles()).map((p) => p.name); - } - const { listXaiProfiles } = await import("../auth/xai/store.js"); - return (await listXaiProfiles()).map((p) => p.name); -}; - -function oauthChoice(id: string, label: string, kind: OAuthKind): ProviderChoice | null { - const surface = OAUTH_SURFACES[kind]; - const defaultModel = surface.models[0]; - if (defaultModel === undefined) return null; - return { - id, - label, - baseURL: surface.baseURL, - models: surface.models, - defaultModel, - hint: surface.hint, - anthropic: false, - opencodeGo: false, - custom: false, - oauth: kind, - }; -} - -const CUSTOM_CHOICE: ProviderChoice = { - id: CUSTOM_CHOICE_ID, - label: "Custom — any OpenAI-compatible endpoint", - baseURL: "", - models: [], - defaultModel: "", - hint: "you supply the name, base url and model", - anthropic: false, - opencodeGo: false, - custom: true, - oauth: null, -}; - -function choiceFromDef(def: FirstClassProviderDef): ProviderChoice | null { - if (def.auth !== "api-key" && def.auth !== "keyless") return null; - if (def.baseURL === undefined || def.models === undefined) return null; - const models = def.opencodeGo === true ? selectableGoModelIds() : def.models; - const defaultModel = def.defaultModel ?? models[0]; - if (defaultModel === undefined) return null; - return { - id: def.id, - label: def.label, - baseURL: def.baseURL, - models, - defaultModel, - hint: def.authHint ?? "", - anthropic: def.anthropic === true, - opencodeGo: def.opencodeGo === true, - custom: false, - oauth: null, - }; -} - -/** - * The pick-list, derived from the shared first-class catalog so onboarding and - * `/model` connect never drift. Subscription providers are listed alongside the - * key-based ones: their step is a browser sign-in rather than a paste, but a - * first run must be able to start there. - */ -export function providerChoices(): readonly ProviderChoice[] { - const out: ProviderChoice[] = []; - for (const def of FIRST_CLASS_PROVIDERS) { - if (def.auth === "chooser") { - for (const path of def.paths ?? []) { - if (path.auth === "oauth" && path.oauth !== undefined) { - // The path label alone ("ChatGPT — …") drops the vendor, so the - // parent label carries it into a row read out of context. - const choice = oauthChoice( - path.providerId ?? def.id, - `${def.label} ${path.label}`, - path.oauth, - ); - if (choice !== null) out.push(choice); - continue; - } - if (path.auth !== "api-key") continue; - const seeded = firstClassPathAsProvider(def, path.id); - if (seeded === undefined) continue; - const choice = choiceFromDef(seeded); - if (choice !== null) out.push(choice); - } - continue; - } - if (def.auth === "oauth" && def.oauth !== undefined) { - const choice = oauthChoice(def.id, def.label, def.oauth); - if (choice !== null) out.push(choice); - continue; - } - const choice = choiceFromDef(def); - if (choice !== null) out.push(choice); - } - out.push(CUSTOM_CHOICE); - return out; -} - -export function providerChoiceById(id: string): ProviderChoice | undefined { - return providerChoices().find((c) => c.id === id); -} - -/** - * How many connected accounts `choice` has in `providers`. Both OAuth and - * first-class API-key kinds store instances as `kind/` (plus a legacy - * bare `kind` key for the original single-instance connect), so prefix - * matching is required. Custom is free-form and never counted here. - */ -export function connectedAccountCount( - choice: ProviderChoice, - providers: readonly { readonly name: string }[], -): number { - if (choice.custom) return 0; - const prefix = `${choice.id}/`; - return providers.filter((p) => p.name === choice.id || p.name.startsWith(prefix)).length; -} - -/** - * Instance slugs already claimed for `kind` in the settings catalog. A legacy - * bare `kind` key counts as the slug `"default"` so reconnecting the original - * single-instance row still hits the confirm path. - */ -export function instanceSlugsForKind( - kind: string, - existingNames: readonly string[], -): readonly string[] { - const prefix = `${kind}/`; - const slugs: string[] = []; - for (const name of existingNames) { - if (name === kind) slugs.push("default"); - else if (name.startsWith(prefix)) { - const slug = name.slice(prefix.length); - if (slug.length > 0) slugs.push(slug); - } - } - return slugs; -} - -/** - * Catalog key an API-key instance of `kind`/`slug` is stored under. Reuses a - * legacy bare `kind` key when the slug is `"default"` and that bare key still - * exists; otherwise always writes the compound form so siblings coexist. - */ -export function resolveApiKeyInstanceName( - kind: string, - slug: string, - existingNames: readonly string[], -): string { - const compound = `${kind}/${slug}`; - if (existingNames.includes(compound)) return compound; - if (slug === "default" && existingNames.includes(kind)) return kind; - return compound; -} - -/** - * Rows for the model picker's Alt+A add-provider selector. Every first-class - * kind is included, including Custom — filtering Custom out made free-form - * endpoints unreachable from Alt+A even though onboarding still offered them. - * Account counts use the same rules as the onboarding list. - */ -export function addProviderSelectorChoices( - choices: readonly ProviderChoice[], - providers: readonly { readonly name: string }[], -): readonly { - readonly id: string; - readonly label: string; - readonly hint: string; - readonly accountCount: number; -}[] { - return choices.map((choice) => ({ - id: choice.id, - label: choice.label, - hint: choice.hint, - accountCount: connectedAccountCount(choice, providers), - })); -} - -/** Pick-list rows for the provider step. */ -export function providerChoiceRows( - choices: readonly ProviderChoice[] = providerChoices(), -): readonly ResidualCatalogEntry[] { - return choices.map((c) => ({ - id: c.id, - label: c.hint.length > 0 ? `${c.label} — ${c.hint}` : c.label, - })); -} - -/** - * Pick-list rows for the model step, built from the shared models-first - * catalog so the labels match the `/model` picker (including its cross-product - * billing warnings). A trailing row escapes to free text for a model id the - * seeded list does not carry yet. - */ -export function modelChoiceRows(choice: ProviderChoice): readonly ResidualCatalogEntry[] { - const catalog = buildModelsFirstCatalog({ - providers: [ - { - name: choice.id, - label: choice.label, - models: choice.models, - baseURL: choice.baseURL, - opencodeGo: choice.opencodeGo, - }, - ], - }); - return [ - ...catalog.map((option) => ({ - id: option.id, - label: option.label, - })), - { id: TYPE_MODEL_ID, label: "type a model id instead" }, - ]; -} - -/** `provider:model` → `model`, for a row id produced by the model catalog. */ -export function modelFromRowId(providerId: string, rowId: string): string { - const prefix = `${providerId}:`; - return rowId.startsWith(prefix) ? rowId.slice(prefix.length) : rowId; -} - -export function stepsFor(choice: ProviderChoice | null): readonly SetupStep[] { - if (choice === null) return PRESET_STEPS; - if (choice.custom) return CUSTOM_STEPS; - if (isOllamaProviderId(choice.id)) return OLLAMA_STEPS; - return choice.oauth !== null ? OAUTH_STEPS : PRESET_STEPS; -} - -const MASK_CHAR = "●"; -const MASK_CAP = 16; - -/** - * Bullet-render a secret for the read-only summary rows, capped so a long key - * does not blow out the row width. - */ -export function maskSecret(value: string): string { - return MASK_CHAR.repeat(Math.min([...value].length, MASK_CAP)); -} - -/** - * Bullet-render a secret for the live input echo. - * - * Uncapped, unlike `maskSecret`: the echo is what `secretFromMaskedEdit` reads - * back, so a capped echo would silently discard everything past the cap. - */ -export function maskEcho(value: string): string { - return MASK_CHAR.repeat([...value].length); -} - -/** apiKey is optional — blank means a keyless local provider (e.g. Ollama). */ -export function stepReady(step: SetupStep, value: string): boolean { - return step === "apiKey" || value.trim().length > 0; -} - -/** - * Fold an edit of the masked apiKey display back into the real secret. - * - * The input never holds the key: every keystroke is mirrored back as bullets, - * so an edit arrives as bullets plus whatever was just typed. Appends and - * end-of-line deletes round-trip exactly; mid-string edits fall back to - * truncation, which is why the field is re-typed rather than patched. - */ -export function secretFromMaskedEdit(secret: string, displayed: string): string { - const chars = [...displayed]; - const typed = chars.filter((c) => c !== MASK_CHAR); - const keptLength = chars.length - typed.length; - return [...secret].slice(0, keptLength).join("") + typed.join(""); -} - -// The "name" step names a whole provider on the custom path but a single -// account/instance on multi-instance first-class kinds (OAuth and API-key). -function stepLabel(step: SetupStep, choice: ProviderChoice | null): string { - if (step === "name" && choice !== null && !choice.custom) return "account name"; - return STEP_LABELS[step]; -} - -/** `step 2 of 4 · api key` — always says where the operator is and what is left. */ -export function stepHeadline( - steps: readonly SetupStep[], - index: number, - choice: ProviderChoice | null = null, -): string { - const step = steps[Math.min(Math.max(index, 0), steps.length - 1)]; - if (step === undefined) return ""; - return `step ${index + 1} of ${steps.length} · ${stepLabel(step, choice)}`; -} - -export interface SummaryRow { - readonly label: string; - readonly value: string; - readonly state: "done" | "current" | "pending"; -} - -/** One row per step: settled rows show the value, later rows a dash. */ -export function summaryRows( - steps: readonly SetupStep[], - index: number, - values: ProviderFormValues, - choice: ProviderChoice | null, -): readonly SummaryRow[] { - return steps.map((step, i) => { - const state = i < index ? "done" : i === index ? "current" : "pending"; - return { - label: stepLabel(step, choice), - value: state === "done" ? settledValue(step, values, choice) : "—", - state, - }; - }); -} - -function settledValue( - step: SetupStep, - values: ProviderFormValues, - choice: ProviderChoice | null, -): string { - if (step === "provider") return choice?.label ?? values.name; - if (step === "login") return values.name.length > 0 ? values.name : "signed in"; - if (step === "apiKey") { - return values.apiKey.length > 0 ? maskSecret(values.apiKey) : "keyless"; - } - if (step === "name") { - return choice !== null && !choice.custom ? values.oauthProfile : values.name; - } - if (step === "baseURL") return values.baseURL; - return values.model; -} - -/** Render a summary row at a fixed label column. */ -export function summaryLine(row: SummaryRow): string { - const marker = row.state === "current" ? "›" : " "; - return `${marker} ${row.label.padEnd(14)}${row.state === "current" ? "" : row.value}`; -} - -export function summaryColor(row: SummaryRow): string { - if (row.state === "done") return UI.done; - if (row.state === "current") return UI.text; - return UI.textFaint; -} - -/** - * What the operator should do about a failure. A bare error message leaves a - * first-run user stuck, so every failure names the field to fix. - */ -export function failureGuidance( - phase: SubmitPhase, - choice: ProviderChoice | null, - offerSaveAnyway = true, -): string { - if (phase === "saving") { - return "settings could not be written — check disk permissions, enter to retry"; - } - if (!offerSaveAnyway) { - return choice !== null && !choice.custom - ? "the account cannot be saved — esc to reconnect or enter to retry" - : "check the base url and key — esc to go back, enter to retry"; - } - return choice !== null && !choice.custom - ? "the key was rejected or unreachable — esc to re-enter it, enter to retry, ctrl+s to save anyway" - : "check the base url and key — esc to go back, enter to retry, ctrl+s to save anyway"; -} - -/** How long a sign-in may wait on the browser before it gives the screen back. */ -export const LOGIN_TIMEOUT_MS = 3 * 60 * 1000; - -export const LOGIN_TIMEOUT_MESSAGE = "sign-in timed out"; - -/** Set when the operator escapes a sign-in that was still outstanding. */ -export const LOGIN_CANCELLED_MESSAGE = "sign-in cancelled"; - -/** What the operator should do about a sign-in that did not complete. */ -export function loginGuidance(): string { - return "enter to try signing in again · esc to pick a different provider"; -} - -/** What the operator should do after abandoning a sign-in. */ -export function loginCancelGuidance(): string { - return "nothing was saved — pick a provider to start over"; -} - -/** Status line while the browser round-trip is outstanding. */ -export const LOGIN_WAITING_LABEL = "waiting for browser sign-in"; - -export interface SubmitOpts { - // True when the operator chose to save despite a failed connection test — - // some providers speak chat completions but not /models, so validation - // cannot be a hard gate. - readonly skipValidation: boolean; - /** - * Catalog metadata for the picked provider. Absent on the custom path. Lets - * the caller persist the full seeded model list and the protocol flags the - * four form values cannot express. - */ - readonly preset?: ProviderPreset; - /** Present when the operator exchanged OAuth credentials during setup. */ - readonly oauth?: OAuthResult; -} - -export interface OAuthResult { - readonly kind: OAuthKind; - readonly tokens: CodexTokens | XaiTokens; - readonly commit: () => Promise; - /** Settings/catalog name the stored profile projects to. */ - readonly providerName: string; -} - -export interface ProviderPreset { - readonly id: string; - readonly models: readonly string[]; - readonly anthropic: boolean; - readonly opencodeGo: boolean; -} - -export type ProviderSetupSubmit = ( - values: ProviderFormValues, - setPhase: (phase: SubmitPhase) => void, - opts: SubmitOpts, -) => Promise; - -/** A login in flight: where to authorize, when it finished, how to abandon it. */ -export interface OAuthLoginStart { - readonly authorizeUrl: string; - readonly completed: Promise<{ - readonly profile: AuthProfile; - readonly commit: () => Promise; - }>; - readonly cancel: () => void; -} - -export type OAuthLoginStarter = (input: { - readonly kind: OAuthKind; - readonly profile: string; - readonly signal: AbortSignal; -}) => Promise; - -/** - * Real login: PKCE plus a loopback callback server, per provider. Imported - * lazily so mounting the surface never binds a port in a test that has - * injected its own starter. - */ -const defaultLoginStarter: OAuthLoginStarter = async ({ kind, profile, signal }) => { - if (kind === "codex") { - const { startCodexLogin } = await import("../auth/codex/login.js"); - return startCodexLogin({ profile, signal }); - } - const { startXaiLogin } = await import("../auth/xai/login.js"); - return startXaiLogin({ profile, signal }); -}; - -export interface ProviderSetupConfig { - readonly onSubmit: ProviderSetupSubmit; - /** - * One-time telemetry disclosure. Shown here so a brand-new install sees it - * on the same launch the first telemetry event fires, not on a later run. - */ - readonly showTelemetryNotice: boolean; - /** Renderer factory override for headless mounting in tests. */ - readonly createRenderer?: () => Promise; - /** Login driver override so tests need neither a browser nor a port. */ - readonly startLogin?: OAuthLoginStarter; - /** Profile lister override so tests need no auth-store files on disk. */ - readonly listOAuthProfiles?: OAuthProfileLister; - /** Sign-in deadline override, in milliseconds. */ - readonly loginTimeoutMs?: number; - /** Ollama discovery override for deterministic setup tests. */ - readonly discoverOllamaModels?: typeof discoverOllamaModelsRequest; - /** Go catalog prefetch override so setup tests stay off the network. */ - readonly prefetchGoModels?: typeof prefetchGoModelsRequest; - /** - * Skip the provider pick-list and start directly on that provider's first - * form step (account name for multi-instance kinds, or the custom name - * field) — the inline connect path from the model picker's add-provider - * selector already knows which provider it wants. - */ - readonly initialProviderId?: string; - /** - * Catalog keys already present in global settings. Used by the API-key - * multi-instance name step for suggested slugs and collision confirms. - * OAuth still reads live profiles from the auth store. - */ - readonly existingProviderNames?: readonly string[]; -} - -const SUMMARY_SLOTS = CUSTOM_STEPS.length; -/** Wrapped rows reserved for the authorize URL and its instruction. */ -const LOGIN_ROWS = 4; -// The whole first-class catalog plus the custom row fits without scrolling on a -// standard terminal: a first run should see every option it could pick. -const LIST_ROWS_MAX = 10; -const LIST_ROWS_MIN = 3; -const TELEMETRY_ROWS = 3; -/** - * Input capacity. The renderable defaults to 1000 characters and truncates a - * longer paste silently, which a first run would read as "paste is broken"; - * long-lived service-account keys and JWT-shaped tokens clear that default. - */ -const FIELD_MAX_LENGTH = 16_384; -/** Ramp animation tick. Fast enough to read as motion at 30fps paint. */ -const RAMP_TICK_MS = 120; - -/** - * Mount the setup surface. Resolves true once `onSubmit` completes, false when - * the operator cancels (Ctrl+C / Ctrl+D) without a successful submit. - */ -export async function runProviderSetup(config: ProviderSetupConfig): Promise { - // A caller-supplied renderer (a headless test harness, or a live session's - // renderer reused for a mid-session reconnect) is owned by that caller — - // teardown here must not destroy it out from under them. - const externalRenderer = config.createRenderer !== undefined; - const renderer = config.createRenderer - ? await config.createRenderer() - : await createCliRenderer({ - exitOnCtrlC: false, - targetFps: 30, - // Reporting stays off during onboarding, unlike the main shell, so - // the terminal owns drag-select and its own copy here. - useMouse: false, - enableMouseMovement: false, - }); - - const choices = providerChoices(); - const existingProviderNames = config.existingProviderNames ?? []; - const values: ProviderFormValues = { - name: "", - baseURL: "", - apiKey: "", - model: "", - oauthProfile: "", - }; - let choice: ProviderChoice | null = null; - let stepIndex = 0; - // Set when the operator escapes the model pick-list into free text. - let typedModel = false; - let submitting = false; - let submitPhase: SubmitPhase = "testing"; - let submitError: string | null = null; - let saveAnywayOffered = false; - let rampTimer: ReturnType | null = null; - - const startLogin = config.startLogin ?? defaultLoginStarter; - const listOAuthProfiles = config.listOAuthProfiles ?? defaultProfileLister; - const loginTimeoutMs = config.loginTimeoutMs ?? LOGIN_TIMEOUT_MS; - let loginStatus: "idle" | "pending" | "failed" | "done" = "idle"; - let loginURL: string | null = null; - let loginError: string | null = null; - let loginResult: OAuthResult | null = null; - let loginAbort: AbortController | null = null; - let loginHandle: OAuthLoginStart | null = null; - let loginTimer: ReturnType | null = null; - // Carried back to the provider step so an abandoned sign-in says so there - // rather than dropping the operator on a silent list. - let loginCancelled = false; - // Bumped on every start and every abandon, so a late resolution from a - // cancelled or superseded attempt can never move the screen. - let loginAttempt = 0; - - // The OAuth "name" step's own state: an inline error from the last - // validation, and a pending re-authorize confirmation for a name that - // collided with an existing profile. `confirmedSlug` is the exact slug the - // confirmation applies to, so an edit to the field (which invalidates it) - // is detected by comparison rather than a separate dirty flag. - let oauthProfileError: string | null = null; - let oauthProfileConfirmPending = false; - let confirmedSlug: string | null = null; - // Bumped whenever the name step is (re-)entered, so a profile-list fetch - // left over from a step the operator has since navigated away from can - // never write into the wrong step's state. - let oauthNameAttempt = 0; - - const discoverOllamaModels = config.discoverOllamaModels ?? discoverOllamaModelsRequest; - let ollamaDiscovery: "idle" | "loading" | OllamaDiscoveryState = "idle"; - let ollamaDiscoveryAttempt = 0; - let ollamaDiscoveryAbort: AbortController | null = null; - - const prefetchGoModels = config.prefetchGoModels ?? prefetchGoModelsRequest; - let goPrefetchAttempt = 0; - - if (config.initialProviderId !== undefined) { - const preselected = choices.find((c) => c.id === config.initialProviderId); - if (preselected !== undefined) { - choice = preselected; - stepIndex = 1; - values.name = preselected.label; - values.baseURL = preselected.baseURL; - values.model = preselected.defaultModel; - } - } - - const margin = resolveSideMargin(renderer.width || 80); - - let listRows: readonly ResidualCatalogEntry[] = providerChoiceRows(choices); - let list: ListViewportState = createListViewport({ - count: listRows.length, - height: listHeight(), - }); - - function listHeight(): number { - // This budget is a guess, not a derivation: it runs before `root` is - // even constructed below, so there has been no layout pass yet and - // nothing in OpenTUI to measure — Renderable.height and scrollHeight - // only reflect the last completed layout, populated post-mount. -14 - // is a hand count of the chrome rows above and below the list (header, - // intro, step, instruction, summary, statusLine, guidance, footer, and - // padding) with slack for a wrapped label; it goes stale if that chrome - // changes and nothing here will catch it. A shared, derived chrome - // budget for this and shell.ts's picker is tracked separately. - const rows = renderer.height || 24; - return Math.max(LIST_ROWS_MIN, Math.min(LIST_ROWS_MAX, rows - 14)); - } - - const steps = (): readonly SetupStep[] => stepsFor(choice); - const currentStep = (): SetupStep => steps()[stepIndex] ?? ("provider" as SetupStep); - const isOllamaModelStep = (): boolean => - currentStep() === "model" && choice !== null && isOllamaProviderId(choice.id); - const isListStep = (): boolean => { - const step = currentStep(); - if (step === "provider") return true; - if (isOllamaModelStep()) { - return typeof ollamaDiscovery === "object" && ollamaDiscovery.status === "models"; - } - return step === "model" && choice !== null && !choice.custom && !typedModel; - }; - // The "name" step means two different things depending on the path: a - // free-text provider name (custom) or a multi-instance account slug (OAuth - // and first-class API-key) with suggestion/collision machinery. Only the - // latter needs this branch. - const isAccountNameStep = (): boolean => - currentStep() === "name" && choice !== null && !choice.custom; - - const root = new BoxRenderable(renderer, { - id: "provider-setup", - width: "100%", - height: "100%", - flexDirection: "column", - backgroundColor: UI.ground, - paddingTop: 1, - paddingLeft: margin, - paddingRight: margin, - }); - - // Every direct child of `root` needs flexShrink: 0, full stop — a plain - // TextRenderable defaults to shrinkable, and a short terminal makes the - // flex algorithm compress unprotected single-line rows into each other - // (garbled overlapping text) instead of clipping the column from the - // bottom. header/intro/step/instruction here, and statusLine/guidance/ - // footer further down, all needed this; it is not specific to one step. - const header = new TextRenderable(renderer, { - id: "provider-setup-header", - content: `${PRODUCT_NAME.toLowerCase()} · setup`, - fg: UI.inFlightBright, - flexShrink: 0, - }); - const intro = new TextRenderable(renderer, { - id: "provider-setup-welcome", - content: "connect an inference provider — switch later with /model", - fg: UI.textDim, - flexShrink: 0, - }); - const step = new TextRenderable(renderer, { - id: "provider-setup-step", - content: "", - fg: UI.action, - flexShrink: 0, - }); - const instruction = new TextRenderable(renderer, { - id: "provider-setup-instruction", - content: "", - fg: UI.text, - flexShrink: 0, - }); - - const summary = new BoxRenderable(renderer, { - id: "provider-setup-summary", - width: "100%", - flexDirection: "column", - flexShrink: 0, - paddingTop: 1, - backgroundColor: UI.ground, - }); - const summarySlots = Array.from( - { length: SUMMARY_SLOTS }, - (_, i) => - new TextRenderable(renderer, { - id: `provider-setup-summary-${String(i)}`, - content: "", - fg: UI.textDim, - }), - ); - for (const row of summarySlots) summary.add(row); - - const listBox = new BoxRenderable(renderer, { - id: "provider-setup-list", - width: "100%", - flexDirection: "column", - flexShrink: 0, - paddingTop: 1, - backgroundColor: UI.ground, - }); - const listSlots = Array.from( - { length: LIST_ROWS_MAX }, - (_, i) => - new TextRenderable(renderer, { - id: `provider-setup-list-${String(i)}`, - content: "", - fg: UI.textDim, - }), - ); - for (const row of listSlots) listBox.add(row); - - const inputFrame = new BoxRenderable(renderer, { - id: "provider-setup-input-frame", - width: "100%", - height: 3, - flexShrink: 0, - border: true, - borderColor: UI.textFaint, - focusedBorderColor: UI.inFlight, - backgroundColor: UI.ground, - paddingLeft: 1, - paddingRight: 1, - }); - const input = new InputRenderable(renderer, { - id: "provider-setup-input", - width: "100%", - maxLength: FIELD_MAX_LENGTH, - placeholder: PROVIDER_FIELD_HINTS.apiKey, - backgroundColor: UI.ground, - focusedBackgroundColor: UI.ground, - textColor: UI.text, - cursorColor: UI.text, - placeholderColor: UI.textFaint, - }); - inputFrame.add(input); - - const loginBox = new BoxRenderable(renderer, { - id: "provider-setup-login", - width: "100%", - flexDirection: "column", - flexShrink: 0, - paddingTop: 1, - backgroundColor: UI.ground, - visible: false, - }); - const loginSlots = Array.from( - { length: LOGIN_ROWS }, - (_, i) => - new TextRenderable(renderer, { - id: `provider-setup-login-${String(i)}`, - content: "", - fg: UI.textDim, - }), - ); - for (const row of loginSlots) loginBox.add(row); - - const statusLine = new TextRenderable(renderer, { - id: "provider-setup-status", - content: "", - fg: UI.textDim, - flexShrink: 0, - }); - const guidance = new TextRenderable(renderer, { - id: "provider-setup-guidance", - content: "", - fg: UI.textDim, - flexShrink: 0, - }); - const telemetry = new BoxRenderable(renderer, { - id: "provider-setup-telemetry", - width: "100%", - flexDirection: "column", - flexShrink: 0, - paddingTop: 1, - backgroundColor: UI.ground, - visible: config.showTelemetryNotice, - }); - const telemetrySlots = Array.from( - { length: TELEMETRY_ROWS }, - (_, i) => - new TextRenderable(renderer, { - id: `provider-setup-telemetry-${String(i)}`, - content: "", - // A disclosure, not fine print: body emphasis, above the footer. - fg: UI.text, - }), - ); - for (const row of telemetrySlots) telemetry.add(row); - if (config.showTelemetryNotice) { - const width = Math.max(20, (renderer.width || 80) - margin * 2); - const lines = wrapLines(TELEMETRY_NOTICE, width).slice(0, TELEMETRY_ROWS); - lines.forEach((line, i) => { - const slot = telemetrySlots[i]; - if (slot !== undefined) slot.content = line; - }); - } - - const footer = new TextRenderable(renderer, { - id: "provider-setup-footer", - content: "", - fg: UI.textFaint, - flexShrink: 0, - }); - - root.add(header); - root.add(intro); - root.add(step); - root.add(instruction); - root.add(summary); - root.add(listBox); - root.add(loginBox); - root.add(inputFrame); - root.add(statusLine); - root.add(guidance); - root.add(telemetry); - root.add(footer); - renderer.root.add(root); - - const paintSummary = (): void => { - const rows = summaryRows(steps(), stepIndex, values, choice); - summarySlots.forEach((slot, i) => { - const row = rows[i]; - if (row === undefined) { - slot.content = ""; - slot.visible = false; - return; - } - slot.visible = true; - slot.content = summaryLine(row); - slot.fg = summaryColor(row); - }); - }; - - const paintList = (): void => { - const showList = isListStep() && !submitting; - listBox.visible = showList; - if (!showList) { - for (const slot of listSlots) { - slot.content = ""; - slot.visible = false; - } - return; - } - const slice = visibleSlice(list); - listSlots.forEach((slot, i) => { - const index = slice.start + i; - const row = index < slice.end ? listRows[index] : undefined; - if (row === undefined) { - slot.content = ""; - slot.visible = false; - return; - } - const active = index === slice.activeIndex; - slot.visible = true; - slot.content = ` ${active ? ">" : " "} ${row.label}`; - slot.fg = active ? UI.text : UI.textDim; - }); - }; - - const isLoginStep = (): boolean => currentStep() === "login"; - - const paintLogin = (): void => { - const show = isLoginStep() && !submitting; - loginBox.visible = show; - const width = Math.max(20, (renderer.width || 80) - margin * 2); - const lines: string[] = - !show || loginURL === null - ? [] - : ["open this url to authorize:", ...wrapLines(loginURL, width)]; - loginSlots.forEach((slot, i) => { - const line = lines[i]; - if (line === undefined) { - slot.content = ""; - slot.visible = false; - return; - } - slot.visible = true; - slot.content = line; - // The url is the one thing to act on here, so it reads above chrome. - slot.fg = i === 0 ? UI.textDim : UI.inFlightBright; - }); - }; - - const paintStatus = (): void => { - if (!submitting && isOllamaModelStep() && ollamaDiscovery !== "idle") { - if (ollamaDiscovery === "loading") { - const ramp = rampFor({ phase: "working", nowMs: Date.now() }); - statusLine.content = rampLine(ramp, "checking installed Ollama models"); - statusLine.fg = ramp.fg; - guidance.content = "esc to edit the Ollama URL"; - return; - } - if (ollamaDiscovery.status !== "models") { - const empty = ollamaDiscovery.status === "empty"; - const malformed = ollamaDiscovery.status === "malformed"; - const ramp = rampFor({ phase: "blocked", nowMs: 0 }); - statusLine.content = rampLine(ramp, ollamaDiscoveryFailureLine(ollamaDiscovery)); - statusLine.fg = ramp.fg; - guidance.content = empty - ? "pull a model, then press enter to retry · esc to edit url" - : malformed - ? "check the Ollama URL, then press enter to retry · esc to edit url" - : "press enter to retry · esc to edit url"; - return; - } - } - if (!submitting && isAccountNameStep()) { - if (oauthProfileError !== null) { - const ramp = rampFor({ phase: "blocked", nowMs: 0 }); - statusLine.content = rampLine(ramp, oauthProfileError); - statusLine.fg = ramp.fg; - guidance.content = "fix the name and press enter"; - guidance.fg = UI.textDim; - return; - } - if (oauthProfileConfirmPending) { - const ramp = rampFor({ phase: "blocked", nowMs: 0 }); - statusLine.content = rampLine( - ramp, - `"${confirmedSlug ?? values.oauthProfile}" is already connected`, - ); - statusLine.fg = ramp.fg; - guidance.content = - choice?.oauth != null - ? "enter again to re-authorize this account · esc to cancel" - : "enter again to replace this instance's key · esc to cancel"; - guidance.fg = UI.textDim; - return; - } - } - if (!submitting && isLoginStep()) { - if (loginStatus === "failed") { - const ramp = rampFor({ phase: "blocked", nowMs: 0 }); - statusLine.content = rampLine(ramp, (loginError ?? "").toLowerCase()); - statusLine.fg = ramp.fg; - guidance.content = loginGuidance(); - guidance.fg = UI.textDim; - return; - } - if (loginStatus === "done") { - const ramp = rampFor({ phase: "done", nowMs: 0 }); - statusLine.content = rampLine( - ramp, - `signed in as ${loginResult?.providerName ?? "the account"}`, - ); - statusLine.fg = ramp.fg; - guidance.content = "enter to pick a model"; - guidance.fg = UI.textDim; - return; - } - const ramp = rampFor({ phase: "working", nowMs: Date.now() }); - statusLine.content = rampLine(ramp, LOGIN_WAITING_LABEL); - statusLine.fg = ramp.fg; - guidance.content = "the browser should have opened — paste the url if not"; - guidance.fg = UI.textDim; - return; - } - if (!submitting && loginCancelled) { - const ramp = rampFor({ phase: "blocked", nowMs: 0 }); - statusLine.content = rampLine(ramp, LOGIN_CANCELLED_MESSAGE); - statusLine.fg = ramp.fg; - guidance.content = loginCancelGuidance(); - guidance.fg = UI.textDim; - return; - } - if (submitting) { - const ramp = rampFor({ phase: "working", nowMs: Date.now() }); - statusLine.content = rampLine(ramp, SUBMIT_PHASE_LABEL[submitPhase]); - statusLine.fg = ramp.fg; - guidance.content = ""; - return; - } - if (submitError !== null) { - const ramp = rampFor({ phase: "blocked", nowMs: 0 }); - statusLine.content = rampLine(ramp, submitError.toLowerCase()); - statusLine.fg = ramp.fg; - guidance.content = failureGuidance(submitPhase, choice, saveAnywayOffered); - guidance.fg = UI.textDim; - return; - } - statusLine.content = ""; - guidance.content = ""; - }; - - const paintFooter = (): void => { - if (submitting) { - footer.content = "ctrl+c cancel"; - return; - } - if (isOllamaModelStep() && !isListStep()) { - footer.content = "enter retry · esc edit url · ctrl+c cancel"; - return; - } - if (isLoginStep()) { - footer.content = - loginStatus === "failed" - ? "enter retry · esc back · ctrl+c cancel" - : loginStatus === "done" - ? "enter continue · esc back · ctrl+c cancel" - : "esc cancel sign-in · ctrl+c quit"; - return; - } - footer.content = isListStep() - ? "↑↓ move · enter choose · ctrl+c cancel" - : stepIndex === 0 - ? "enter confirm · ctrl+c cancel" - : "enter confirm · esc back · ctrl+c cancel"; - }; - - const paint = (): void => { - const active = currentStep(); - step.content = stepHeadline(steps(), stepIndex, choice); - instruction.content = - isAccountNameStep() && choice !== null ? accountNamePrompt(choice) : STEP_PROMPTS[active]; - paintSummary(); - paintList(); - paintLogin(); - const showInput = !isListStep() && !isLoginStep() && !isOllamaModelStep() && !submitting; - inputFrame.visible = showInput; - input.visible = showInput; - paintStatus(); - paintFooter(); - }; - - const showStep = (): void => { - const active = currentStep(); - if (isListStep() || isLoginStep() || isOllamaModelStep()) { - input.blur(); - paint(); - if (isOllamaModelStep() && ollamaDiscovery === "idle") beginOllamaDiscovery(); - // Arriving on the sign-in step is the trigger: there is nothing to type, - // so the flow starts itself rather than waiting for a keystroke. - if (isLoginStep() && loginStatus === "idle") beginLogin(); - return; - } - if (isAccountNameStep()) { - enterAccountNameStep(); - return; - } - const field = active as ProviderField; - input.placeholder = PROVIDER_FIELD_HINTS[field]; - input.value = field === "apiKey" ? maskEcho(values.apiKey) : values[field]; - // Paint first: focus is refused while the input is still hidden. - paint(); - input.focus(); - }; - - /** - * Enter the multi-instance "name" step: reset per-visit state, show whatever - * slug is already typed, then resolve existing instance names to prefill a - * suggested, non-colliding slug when the field is still blank. OAuth reads - * the live auth store; API-key reads the settings catalog snapshot. - */ - const enterAccountNameStep = (): void => { - oauthProfileError = null; - oauthProfileConfirmPending = false; - input.placeholder = OAUTH_PROFILE_HINT; - input.value = values.oauthProfile; - paint(); - input.focus(); - if (choice === null || choice.custom) return; - const attempt = (oauthNameAttempt += 1); - const applySuggestion = (names: readonly string[]): void => { - if (settled || attempt !== oauthNameAttempt) return; - if (values.oauthProfile.trim().length === 0) { - values.oauthProfile = suggestOAuthProfileSlug(names); - input.value = values.oauthProfile; - paint(); - } - }; - if (choice.oauth !== null) { - listOAuthProfiles(choice.oauth) - .catch((): readonly string[] => []) - .then(applySuggestion); - return; - } - applySuggestion(instanceSlugsForKind(choice.id, existingProviderNames)); - }; - - let settled = false; - let resolveDone: (submitted: boolean) => void = () => {}; - const done = new Promise((resolve) => { - resolveDone = resolve; - }); - - const stopRamp = (): void => { - if (rampTimer === null) return; - clearInterval(rampTimer); - rampTimer = null; - }; - - const abandonOllamaDiscovery = (): void => { - ollamaDiscoveryAttempt += 1; - ollamaDiscoveryAbort?.abort(); - ollamaDiscoveryAbort = null; - }; - - const abandonGoPrefetch = (): void => { - goPrefetchAttempt += 1; - }; - - const teardown = (): void => { - stopRamp(); - abandonLogin(); - abandonOllamaDiscovery(); - abandonGoPrefetch(); - renderer.keyInput.off("keypress", onKey); - input.off(InputRenderableEvents.ENTER, onEnter); - input.off(InputRenderableEvents.INPUT, onInput); - try { - renderer.root.remove(root); - destroySubtree(root); - } catch { - // already unmounted - } - if (!externalRenderer) { - try { - renderer.destroy(); - } catch { - // already destroyed - } - } - }; - - const settle = (submitted: boolean): void => { - if (settled) return; - settled = true; - teardown(); - resolveDone(submitted); - }; - - const clearError = (): void => { - submitError = null; - saveAnywayOffered = false; - loginCancelled = false; - }; - - const clearLoginTimer = (): void => { - if (loginTimer === null) return; - clearTimeout(loginTimer); - loginTimer = null; - }; - - /** - * Drop whatever attempt is in flight: stop its deadline, close its callback - * server, and bump the attempt counter so a late resolution is ignored. - */ - const abandonLogin = (): void => { - loginAttempt += 1; - clearLoginTimer(); - loginAbort?.abort(); - loginAbort = null; - loginHandle?.cancel(); - loginHandle = null; - }; - - /** Denial, transport failure, or the deadline — all land the operator here. */ - const failLogin = (attempt: number, message: string): void => { - if (attempt !== loginAttempt) return; - abandonLogin(); - stopRamp(); - loginStatus = "failed"; - loginError = message; - loginURL = null; - paint(); - }; - - const finishLogin = ( - attempt: number, - kind: OAuthKind, - staged: Awaited, - ): void => { - if (attempt !== loginAttempt) return; - clearLoginTimer(); - loginHandle = null; - loginAbort = null; - stopRamp(); - loginStatus = "done"; - loginError = null; - const result: OAuthResult = { - kind, - tokens: staged.profile.tokens, - commit: staged.commit, - providerName: oauthProviderName(kind, staged.profile.name), - }; - loginResult = result; - values.name = result.providerName; - values.apiKey = ""; - stepIndex += 1; - if (isListStep()) enterModelList(); - showStep(); - }; - - const beginLogin = (): void => { - const kind = choice?.oauth ?? null; - if (kind === null) return; - abandonLogin(); - const attempt = loginAttempt; - loginStatus = "pending"; - loginError = null; - loginURL = null; - loginCancelled = false; - const abort = new AbortController(); - loginAbort = abort; - // A browser round-trip that never comes back must still give the screen - // back, so the deadline is armed before the flow is even started. - loginTimer = setTimeout(() => { - failLogin(attempt, LOGIN_TIMEOUT_MESSAGE); - }, loginTimeoutMs); - stopRamp(); - rampTimer = setInterval(paintStatus, RAMP_TICK_MS); - paint(); - - startLogin({ kind, profile: values.oauthProfile, signal: abort.signal }).then( - (handle) => { - if (attempt !== loginAttempt) { - handle.cancel(); - return; - } - loginHandle = handle; - loginURL = handle.authorizeUrl; - paint(); - handle.completed.then( - (result) => { - finishLogin(attempt, kind, result); - }, - (err: unknown) => { - failLogin(attempt, err instanceof Error ? err.message : String(err)); - }, - ); - }, - (err: unknown) => { - failLogin(attempt, err instanceof Error ? err.message : String(err)); - }, - ); - }; - - /** Abandon an outstanding sign-in and return to the provider list. */ - const cancelLogin = (): void => { - const wasPending = loginStatus === "pending"; - abandonLogin(); - stopRamp(); - loginStatus = "idle"; - loginURL = null; - loginError = null; - loginResult = null; - back(); - loginCancelled = wasPending; - paint(); - }; - - const beginOllamaDiscovery = (): void => { - if (!isOllamaModelStep()) return; - abandonOllamaDiscovery(); - const attempt = ollamaDiscoveryAttempt; - const rootURL = values.baseURL; - const abort = new AbortController(); - ollamaDiscoveryAbort = abort; - ollamaDiscovery = "loading"; - stopRamp(); - rampTimer = setInterval(paintStatus, RAMP_TICK_MS); - paint(); - discoverOllamaModels({ rootURL, signal: abort.signal }).then( - (result) => { - if ( - settled || - attempt !== ollamaDiscoveryAttempt || - values.baseURL !== rootURL || - !isOllamaModelStep() - ) { - return; - } - stopRamp(); - ollamaDiscoveryAbort = null; - ollamaDiscovery = result; - if (result.status === "models" && choice !== null) { - values.model = result.models[0] ?? ""; - // Seed the catalog choice so submit persists every installed model, - // not only the one picked on this screen. - choice = { ...choice, models: [...result.models], defaultModel: values.model }; - listRows = modelChoiceRows(choice).filter((row) => row.id !== TYPE_MODEL_ID); - list = createListViewport({ count: listRows.length, height: listHeight() }); - } - paint(); - }, - (err: unknown) => { - if ( - settled || - attempt !== ollamaDiscoveryAttempt || - values.baseURL !== rootURL || - !isOllamaModelStep() - ) { - return; - } - stopRamp(); - ollamaDiscoveryAbort = null; - ollamaDiscovery = { - status: "malformed", - message: err instanceof Error ? err.message : String(err), - }; - paint(); - }, - ); - }; - - const isGoModelListStep = (): boolean => - currentStep() === "model" && - choice !== null && - choice.opencodeGo && - !choice.custom && - !typedModel; - - const beginGoPrefetch = (): void => { - if (!isGoModelListStep()) return; - abandonGoPrefetch(); - const attempt = goPrefetchAttempt; - void prefetchGoModels() - .then((ids) => { - if (settled || attempt !== goPrefetchAttempt || !isGoModelListStep() || choice === null) { - return; - } - const listed = choice.models; - const same = ids.length === listed.length && ids.every((id, i) => id === listed[i]); - if (same) return; - const focusedId = listRows[list.activeIndex]?.id; - choice = { ...choice, models: [...ids] }; - listRows = modelChoiceRows(choice); - const found = - focusedId === undefined ? -1 : listRows.findIndex((row) => row.id === focusedId); - list = createListViewport({ - count: listRows.length, - height: listHeight(), - activeIndex: found >= 0 ? found : 0, - }); - paint(); - }) - .catch(() => { - // Seed list is already on screen; a failed prefetch must not surface. - }); - }; - - const submit = (skipValidation: boolean): void => { - submitting = true; - submitPhase = "testing"; - clearError(); - paint(); - stopRamp(); - rampTimer = setInterval(paintStatus, RAMP_TICK_MS); - - // Track the phase locally so the rejection handler knows whether the - // failure happened during the connection test (retryable and bypassable) - // or during the settings write. - let phase: SubmitPhase = "testing"; - const setPhase = (p: SubmitPhase): void => { - phase = p; - submitPhase = p; - paint(); - }; - - const preset: ProviderPreset | undefined = - choice !== null && !choice.custom - ? { - id: choice.id, - models: choice.models, - anthropic: choice.anthropic, - opencodeGo: choice.opencodeGo, - } - : undefined; - - config - .onSubmit(values, setPhase, { - skipValidation, - ...(preset !== undefined ? { preset } : {}), - ...(loginResult !== null ? { oauth: loginResult } : {}), - }) - .then( - () => settle(true), - (err: unknown) => { - stopRamp(); - submitting = false; - submitPhase = phase; - submitError = err instanceof Error ? err.message : String(err); - saveAnywayOffered = phase === "testing" && !isOAuthProviderScopeError(err); - paint(); - }, - ); - }; - - const chooseProvider = (id: string): void => { - const picked = providerChoiceById(id); - if (picked === undefined) return; - abandonOllamaDiscovery(); - ollamaDiscovery = "idle"; - choice = picked; - values.apiKey = ""; - typedModel = false; - oauthProfileError = null; - oauthProfileConfirmPending = false; - confirmedSlug = null; - if (picked.custom) { - values.name = ""; - values.baseURL = ""; - values.model = ""; - } else { - // Multi-instance first-class kinds (OAuth and API-key): leave the catalog - // name blank until the account/instance slug is settled. See the - // `oauthProfile` doc comment on `ProviderFormValues`. - values.name = ""; - values.baseURL = picked.baseURL; - values.model = picked.defaultModel; - values.oauthProfile = ""; - } - stepIndex += 1; - if (isListStep()) enterModelList(); - }; - - const enterModelList = (): void => { - if (choice === null) return; - listRows = modelChoiceRows(choice); - const active = Math.max( - 0, - listRows.findIndex((row) => modelFromRowId(choice?.id ?? "", row.id) === values.model), - ); - list = createListViewport({ - count: listRows.length, - height: listHeight(), - activeIndex: active, - }); - beginGoPrefetch(); - }; - - const enterProviderList = (): void => { - listRows = providerChoiceRows(choices); - const active = Math.max( - 0, - listRows.findIndex((row) => row.id === choice?.id), - ); - list = createListViewport({ - count: listRows.length, - height: listHeight(), - activeIndex: active, - }); - }; - - const acceptListRow = (): void => { - const { itemIds } = residualListFromCatalog(listRows); - const id = residualIdFromSelection({ index: list.activeIndex }, itemIds); - if (id === undefined) return; - clearError(); - if (currentStep() === "provider") { - chooseProvider(id); - showStep(); - return; - } - if (id === TYPE_MODEL_ID) { - typedModel = true; - values.model = ""; - showStep(); - return; - } - values.model = modelFromRowId(choice?.id ?? "", id); - submit(false); - }; - - const advance = (): void => { - if (isListStep()) { - acceptListRow(); - return; - } - if (isOllamaModelStep()) { - if (ollamaDiscovery !== "loading") beginOllamaDiscovery(); - return; - } - if (isLoginStep()) { - if (loginStatus === "done") { - stepIndex += 1; - if (isListStep()) enterModelList(); - showStep(); - return; - } - // A pending sign-in has nothing to confirm; a failed one retries. - if (loginStatus !== "pending") beginLogin(); - return; - } - if (isAccountNameStep()) { - advanceAccountNameStep(); - return; - } - const field = currentStep() as ProviderField; - if (!stepReady(field, values[field])) return; - - if (stepIndex < steps().length - 1) { - stepIndex += 1; - clearError(); - if (isListStep()) enterModelList(); - showStep(); - return; - } - submit(false); - }; - - /** - * Validate the entered slug, then check collisions against a fresh source - * (auth store for OAuth, settings catalog for API-key). A collision needs - * one more Enter to confirm before the step advances. - */ - const advanceAccountNameStep = (): void => { - if (choice === null || choice.custom) return; - const validated = validateOAuthProfileSlug(values.oauthProfile); - if (!validated.ok) { - oauthProfileError = validated.error; - oauthProfileConfirmPending = false; - confirmedSlug = null; - paint(); - return; - } - const slug = validated.slug; - // Already confirmed this exact slug on the previous Enter — proceed - // without another round-trip. Any edit since then cleared the flag (see - // onInput), so this only fires on a genuine second, unmodified Enter. - if (oauthProfileConfirmPending && confirmedSlug === slug) { - settleAccountNameSlug(slug); - return; - } - const attempt = (oauthNameAttempt += 1); - const handleNames = (names: readonly string[]): void => { - if (settled || attempt !== oauthNameAttempt) return; - if (names.includes(slug)) { - oauthProfileError = null; - oauthProfileConfirmPending = true; - confirmedSlug = slug; - paint(); - return; - } - settleAccountNameSlug(slug); - }; - if (choice.oauth !== null) { - listOAuthProfiles(choice.oauth) - .catch((): readonly string[] => []) - .then(handleNames); - return; - } - handleNames(instanceSlugsForKind(choice.id, existingProviderNames)); - }; - - const settleAccountNameSlug = (slug: string): void => { - values.oauthProfile = slug; - oauthProfileError = null; - oauthProfileConfirmPending = false; - confirmedSlug = null; - if (choice !== null && choice.oauth === null && !choice.custom) { - // API-key multi-instance: catalog key is kind/slug (or legacy bare kind - // when reconnecting the original single-instance "default"). - values.name = resolveApiKeyInstanceName(choice.id, slug, existingProviderNames); - } - stepIndex += 1; - showStep(); - }; - - const back = (): void => { - if (stepIndex === 0) return; - if (isOllamaModelStep()) { - abandonOllamaDiscovery(); - ollamaDiscovery = "idle"; - } - if (isGoModelListStep()) abandonGoPrefetch(); - stepIndex -= 1; - clearError(); - if (currentStep() === "provider") enterProviderList(); - else if (isListStep()) enterModelList(); - showStep(); - }; - - function onInput(next: string): void { - if (submitting || isListStep()) return; - if (isAccountNameStep()) { - values.oauthProfile = next; - // An edit invalidates whatever the last submit attempt found — the - // confirm applies to one exact slug, and any inline error is stale - // the moment the text it described changes. - const hadFeedback = oauthProfileError !== null || oauthProfileConfirmPending; - oauthProfileError = null; - oauthProfileConfirmPending = false; - confirmedSlug = null; - if (hadFeedback) paint(); - return; - } - const field = currentStep() as ProviderField; - if (field === "apiKey") { - values.apiKey = secretFromMaskedEdit(values.apiKey, next); - const masked = maskEcho(values.apiKey); - if (input.value !== masked) input.value = masked; - } else { - values[field] = next; - } - if (submitError !== null) { - clearError(); - paint(); - } - } - - function onEnter(): void { - if (submitting) return; - advance(); - } - - function onKey(key: KeyEvent): void { - if (settled) return; - if (key.ctrl === true && (key.name === "c" || key.name === "d")) { - key.preventDefault(); - settle(false); - return; - } - if (submitting) { - key.preventDefault(); - return; - } - if (key.ctrl === true && key.name === "s") { - if (!saveAnywayOffered) return; - key.preventDefault(); - submit(true); - return; - } - if (key.name === "escape") { - key.preventDefault(); - if (isLoginStep()) { - cancelLogin(); - } else if (isAccountNameStep() && oauthProfileConfirmPending) { - // Cancel the re-authorize confirm without leaving the step — the - // operator is about to edit the name, not abandon the provider. - oauthProfileConfirmPending = false; - confirmedSlug = null; - paint(); - } else { - back(); - } - return; - } - if (isLoginStep()) { - if (key.name === "return" || key.name === "enter") { - key.preventDefault(); - advance(); - } - return; - } - if (isOllamaModelStep() && !isListStep() && (key.name === "return" || key.name === "enter")) { - key.preventDefault(); - advance(); - return; - } - if (!isListStep()) return; - - if (key.name === "up" || key.name === "k") { - key.preventDefault(); - list = moveActive(list, -1); - paint(); - return; - } - if (key.name === "down" || key.name === "j") { - key.preventDefault(); - list = moveActive(list, 1); - paint(); - return; - } - if (key.name === "return" || key.name === "enter") { - key.preventDefault(); - advance(); - } - } - - input.on(InputRenderableEvents.ENTER, onEnter); - input.on(InputRenderableEvents.INPUT, onInput); - renderer.keyInput.on("keypress", onKey); - showStep(); - - return done; -} diff --git a/src/tui/provider/choices.ts b/src/tui/provider/choices.ts new file mode 100644 index 000000000..2205a4cfc --- /dev/null +++ b/src/tui/provider/choices.ts @@ -0,0 +1,358 @@ +/** + * Catalog projection: turns the shared first-class provider catalog (plus the + * subscription surfaces and the manual Custom row) into pick-list choices, so + * onboarding and `/model` connect never drift. + */ + +import { + FIRST_CLASS_PROVIDERS, + firstClassPathAsProvider, + type FirstClassProviderDef, +} from "../../../packages/first-class-providers/src/index.js"; +import { CODEX_BASE_URL, CODEX_DEFAULT_MODELS } from "../../auth/codex/constants.js"; +import { XAI_BASE_URL, XAI_DEFAULT_MODELS } from "../../auth/xai/constants.js"; +import { codexProviderName } from "../../config/codex-providers.js"; +import { xaiProviderName } from "../../config/xai-providers.js"; +import { selectableGoModelIds } from "../../provider/opencode-go-models.js"; +import { buildModelsFirstCatalog } from "../model-catalog.js"; +import type { ResidualCatalogEntry } from "../residuals.js"; +import type { CliRenderer } from "@opentui/core"; +import { createOverlayList } from "../shell/overlay-list.js"; +import type { DiscoveryFlows, OAuthKind, ProviderChoice, SetupState } from "./types.js"; + +/** Hard cap on pick-list rows: the first-class catalog plus Custom fits a standard terminal. */ +export const PROVIDER_LIST_ROWS_MAX = 10; +/** Floor so a short terminal still shows several options instead of one. */ +export const PROVIDER_LIST_ROWS_MIN = 3; + +/** + * List height budget. This budget is a guess, not a derivation: it runs + * before layout, so there has been no layout pass yet and nothing in OpenTUI + * to measure — Renderable.height and scrollHeight only reflect the last + * completed layout, populated post-mount. -14 is a hand count of the chrome + * rows above and below the list (header, intro, step, instruction, summary, + * statusLine, guidance, footer, and padding) with slack for a wrapped label; + * it goes stale if that chrome changes and nothing here will catch it. A + * shared, derived chrome budget for this and shell.ts's picker is tracked + * separately. + */ +export function providerListHeight(renderer: CliRenderer): number { + const rows = renderer.height || 24; + return Math.max(PROVIDER_LIST_ROWS_MIN, Math.min(PROVIDER_LIST_ROWS_MAX, rows - 14)); +} + +/** Catalog id for the manual path. Never written to settings as a name. */ +export const CUSTOM_CHOICE_ID = "custom"; + +/** Pick-list row that drops the model step back to free text. */ +export const TYPE_MODEL_ID = "__type_model__"; + +/** + * What a signed-in subscription provider resolves to. The endpoint and model + * list are the same constants the auth stack projects into the catalog, so a + * first run and a later `/model` connect land on the same provider entry. + */ +export const OAUTH_SURFACES: Record< + OAuthKind, + { + readonly baseURL: string; + readonly models: readonly string[]; + readonly hint: string; + readonly providerName: (profile: string) => string; + } +> = { + codex: { + baseURL: CODEX_BASE_URL, + models: CODEX_DEFAULT_MODELS, + hint: "ChatGPT Plus/Pro subscription", + providerName: codexProviderName, + }, + xai: { + baseURL: XAI_BASE_URL, + models: XAI_DEFAULT_MODELS, + hint: "SuperGrok or X Premium+ subscription", + providerName: xaiProviderName, + }, +}; + +function oauthChoice(id: string, label: string, kind: OAuthKind): ProviderChoice | null { + const surface = OAUTH_SURFACES[kind]; + const defaultModel = surface.models[0]; + if (defaultModel === undefined) return null; + return { + id, + label, + baseURL: surface.baseURL, + models: surface.models, + defaultModel, + hint: surface.hint, + anthropic: false, + opencodeGo: false, + custom: false, + oauth: kind, + }; +} + +const CUSTOM_CHOICE: ProviderChoice = { + id: CUSTOM_CHOICE_ID, + label: "Custom — any OpenAI-compatible endpoint", + baseURL: "", + models: [], + defaultModel: "", + hint: "you supply the name, base url and model", + anthropic: false, + opencodeGo: false, + custom: true, + oauth: null, +}; + +function choiceFromDef(def: FirstClassProviderDef): ProviderChoice | null { + if (def.auth !== "api-key" && def.auth !== "keyless") return null; + if (def.baseURL === undefined || def.models === undefined) return null; + const models = def.opencodeGo === true ? selectableGoModelIds() : def.models; + const defaultModel = def.defaultModel ?? models[0]; + if (defaultModel === undefined) return null; + return { + id: def.id, + label: def.label, + baseURL: def.baseURL, + models, + defaultModel, + hint: def.authHint ?? "", + anthropic: def.anthropic === true, + opencodeGo: def.opencodeGo === true, + custom: false, + oauth: null, + }; +} + +/** + * The pick-list, derived from the shared first-class catalog so onboarding and + * `/model` connect never drift. Subscription providers are listed alongside the + * key-based ones: their step is a browser sign-in rather than a paste, but a + * first run must be able to start there. + */ +export function providerChoices(): readonly ProviderChoice[] { + const out: ProviderChoice[] = []; + for (const def of FIRST_CLASS_PROVIDERS) { + if (def.auth === "chooser") { + for (const path of def.paths ?? []) { + if (path.auth === "oauth" && path.oauth !== undefined) { + // The path label alone ("ChatGPT — …") drops the vendor, so the + // parent label carries it into a row read out of context. + const choice = oauthChoice( + path.providerId ?? def.id, + `${def.label} ${path.label}`, + path.oauth, + ); + if (choice !== null) out.push(choice); + continue; + } + if (path.auth !== "api-key") continue; + const seeded = firstClassPathAsProvider(def, path.id); + if (seeded === undefined) continue; + const choice = choiceFromDef(seeded); + if (choice !== null) out.push(choice); + } + continue; + } + if (def.auth === "oauth" && def.oauth !== undefined) { + const choice = oauthChoice(def.id, def.label, def.oauth); + if (choice !== null) out.push(choice); + continue; + } + const choice = choiceFromDef(def); + if (choice !== null) out.push(choice); + } + out.push(CUSTOM_CHOICE); + return out; +} + +export function providerChoiceById(id: string): ProviderChoice | undefined { + return providerChoices().find((c) => c.id === id); +} + +/** + * How many connected accounts `choice` has in `providers`. Both OAuth and + * first-class API-key kinds store instances as `kind/` (plus a legacy + * bare `kind` key for the original single-instance connect), so prefix + * matching is required. Custom is free-form and never counted here. + */ +export function connectedAccountCount( + choice: ProviderChoice, + providers: readonly { readonly name: string }[], +): number { + if (choice.custom) return 0; + const prefix = `${choice.id}/`; + return providers.filter((p) => p.name === choice.id || p.name.startsWith(prefix)).length; +} + +/** + * Instance slugs already claimed for `kind` in the settings catalog. A legacy + * bare `kind` key counts as the slug `"default"` so reconnecting the original + * single-instance row still hits the confirm path. + */ +export function instanceSlugsForKind( + kind: string, + existingNames: readonly string[], +): readonly string[] { + const prefix = `${kind}/`; + const slugs: string[] = []; + for (const name of existingNames) { + if (name === kind) slugs.push("default"); + else if (name.startsWith(prefix)) { + const slug = name.slice(prefix.length); + if (slug.length > 0) slugs.push(slug); + } + } + return slugs; +} + +/** + * Catalog key an API-key instance of `kind`/`slug` is stored under. Reuses a + * legacy bare `kind` key when the slug is `"default"` and that bare key still + * exists; otherwise always writes the compound form so siblings coexist. + */ +export function resolveApiKeyInstanceName( + kind: string, + slug: string, + existingNames: readonly string[], +): string { + const compound = `${kind}/${slug}`; + if (existingNames.includes(compound)) return compound; + if (slug === "default" && existingNames.includes(kind)) return kind; + return compound; +} + +/** + * Rows for the model picker's Alt+A add-provider selector. Every first-class + * kind is included, including Custom — filtering Custom out made free-form + * endpoints unreachable from Alt+A even though onboarding still offered them. + * Account counts use the same rules as the onboarding list. + */ +export function addProviderSelectorChoices( + choices: readonly ProviderChoice[], + providers: readonly { readonly name: string }[], +): readonly { + readonly id: string; + readonly label: string; + readonly hint: string; + readonly accountCount: number; +}[] { + return choices.map((choice) => ({ + id: choice.id, + label: choice.label, + hint: choice.hint, + accountCount: connectedAccountCount(choice, providers), + })); +} + +/** Pick-list rows for the provider step. */ +export function providerChoiceRows( + choices: readonly ProviderChoice[] = providerChoices(), +): readonly ResidualCatalogEntry[] { + return choices.map((c) => ({ + id: c.id, + label: c.hint.length > 0 ? `${c.label} — ${c.hint}` : c.label, + })); +} + +/** + * Pick-list rows for the model step, built from the shared models-first + * catalog so the labels match the `/model` picker (including its cross-product + * billing warnings). A trailing row escapes to free text for a model id the + * seeded list does not carry yet. + */ +export function modelChoiceRows(choice: ProviderChoice): readonly ResidualCatalogEntry[] { + const catalog = buildModelsFirstCatalog({ + providers: [ + { + name: choice.id, + label: choice.label, + models: choice.models, + baseURL: choice.baseURL, + opencodeGo: choice.opencodeGo, + }, + ], + }); + return [ + ...catalog.map((option) => ({ + id: option.id, + label: option.label, + })), + { id: TYPE_MODEL_ID, label: "type a model id instead" }, + ]; +} + +/** `provider:model` → `model`, for a row id produced by the model catalog. */ +export function modelFromRowId(providerId: string, rowId: string): string { + const prefix = `${providerId}:`; + return rowId.startsWith(prefix) ? rowId.slice(prefix.length) : rowId; +} + +/** + * Accept a picked provider row: reset per-step state, prefill the preset + * fields (Custom clears them instead), and move to the first form step. + */ +export function chooseProviderRow(state: SetupState, id: string, discovery: DiscoveryFlows): void { + const picked = providerChoiceById(id); + if (picked === undefined) return; + discovery.abandonOllamaDiscovery(); + state.ollamaDiscovery = "idle"; + state.choice = picked; + state.values.apiKey = ""; + state.typedModel = false; + state.oauthProfileError = null; + state.oauthProfileConfirmPending = false; + state.confirmedSlug = null; + if (picked.custom) { + state.values.name = ""; + state.values.baseURL = ""; + state.values.model = ""; + } else { + // Multi-instance first-class kinds (OAuth and API-key): leave the catalog + // name blank until the account/instance slug is settled. See the + // `oauthProfile` doc comment on `ProviderFormValues`. + state.values.name = ""; + state.values.baseURL = picked.baseURL; + state.values.model = picked.defaultModel; + state.values.oauthProfile = ""; + } + state.stepIndex += 1; +} + +/** Rebuild the pick-list rows for the model step, then prefetch the Go catalog. */ +export function enterModelListRows( + state: SetupState, + renderer: CliRenderer, + discovery: DiscoveryFlows, +): void { + if (state.choice === null) return; + state.listRows = modelChoiceRows(state.choice); + const active = Math.max( + 0, + state.listRows.findIndex( + (row) => modelFromRowId(state.choice?.id ?? "", row.id) === state.values.model, + ), + ); + state.list = createOverlayList(renderer, { + count: state.listRows.length, + items: providerListHeight(renderer), + activeIndex: active, + }); + discovery.beginGoPrefetch(); +} + +/** Rebuild the pick-list rows for the provider step, keeping the prior pick focused. */ +export function enterProviderRows(state: SetupState, renderer: CliRenderer): void { + state.listRows = providerChoiceRows(state.choices); + const active = Math.max( + 0, + state.listRows.findIndex((row) => row.id === state.choice?.id), + ); + state.list = createOverlayList(renderer, { + count: state.listRows.length, + items: providerListHeight(renderer), + activeIndex: active, + }); +} diff --git a/src/tui/provider-connect.ts b/src/tui/provider/connect.ts similarity index 92% rename from src/tui/provider-connect.ts rename to src/tui/provider/connect.ts index 7667c0ddb..eb61ac0b5 100644 --- a/src/tui/provider-connect.ts +++ b/src/tui/provider/connect.ts @@ -5,12 +5,10 @@ * implemented there) — reused via `initialProviderId`, not reimplemented. */ -import type { Settings } from "../config/settings.js"; -import { - buildProviderSubmitHandler, - type PersistProviderSettings, -} from "./provider-setup-submit.js"; -import { runProviderSetup, type ProviderSetupConfig } from "./provider-setup.js"; +import type { Settings } from "../../config/settings.js"; +import { runProviderSetup } from "./setup.js"; +import type { ProviderSetupConfig } from "./types.js"; +import { buildProviderSubmitHandler, type PersistProviderSettings } from "./submit.js"; export interface ConnectProviderInput { readonly providerId: string; diff --git a/src/tui/provider/discovery.ts b/src/tui/provider/discovery.ts new file mode 100644 index 000000000..7559c2533 --- /dev/null +++ b/src/tui/provider/discovery.ts @@ -0,0 +1,126 @@ +/** + * Background model discovery for the setup surface: Ollama's installed-model + * list (which replaces the seeded pick-list once it resolves) and the OpenCode + * Go catalog prefetch. Both mutate the shared state and repaint; both ignore + * resolutions from superseded attempts. + */ + +import { createOverlayList } from "../shell/overlay-list.js"; +import type { CliRenderer } from "@opentui/core"; +import { modelChoiceRows, providerListHeight, TYPE_MODEL_ID } from "./choices.js"; +import { RAMP_TICK_MS, stopRamp } from "./surface.js"; +import type { DiscoveryFlows, SetupSelectors, SetupState, Surface } from "./types.js"; + +export function createDiscoveryFlows( + state: SetupState, + surface: Surface, + selectors: SetupSelectors, +): DiscoveryFlows { + const abandonOllamaDiscovery = (): void => { + state.ollamaDiscoveryAttempt += 1; + state.ollamaDiscoveryAbort?.abort(); + state.ollamaDiscoveryAbort = null; + }; + + const abandonGoPrefetch = (): void => { + state.goPrefetchAttempt += 1; + }; + + const beginOllamaDiscovery = (): void => { + if (!selectors.isOllamaModelStep()) return; + abandonOllamaDiscovery(); + const attempt = state.ollamaDiscoveryAttempt; + const rootURL = state.values.baseURL; + const abort = new AbortController(); + state.ollamaDiscoveryAbort = abort; + state.ollamaDiscovery = "loading"; + stopRamp(state); + state.rampTimer = setInterval(() => surface.paintStatus(), RAMP_TICK_MS); + surface.paint(); + state.discoverOllamaModels({ rootURL, signal: abort.signal }).then( + (result) => { + if ( + state.settled || + attempt !== state.ollamaDiscoveryAttempt || + state.values.baseURL !== rootURL || + !selectors.isOllamaModelStep() + ) { + return; + } + stopRamp(state); + state.ollamaDiscoveryAbort = null; + state.ollamaDiscovery = result; + if (result.status === "models" && state.choice !== null) { + state.values.model = result.models[0] ?? ""; + // Seed the catalog choice so submit persists every installed model, + // not only the one picked on this screen. + state.choice = { + ...state.choice, + models: [...result.models], + defaultModel: state.values.model, + }; + state.listRows = modelChoiceRows(state.choice).filter((row) => row.id !== TYPE_MODEL_ID); + state.list = createOverlayList(state.renderer as CliRenderer, { + count: state.listRows.length, + items: providerListHeight(state.renderer), + }); + } + surface.paint(); + }, + (err: unknown) => { + if ( + state.settled || + attempt !== state.ollamaDiscoveryAttempt || + state.values.baseURL !== rootURL || + !selectors.isOllamaModelStep() + ) { + return; + } + stopRamp(state); + state.ollamaDiscoveryAbort = null; + state.ollamaDiscovery = { + status: "malformed", + message: err instanceof Error ? err.message : String(err), + }; + surface.paint(); + }, + ); + }; + + const beginGoPrefetch = (): void => { + if (!selectors.isGoModelListStep()) return; + abandonGoPrefetch(); + const attempt = state.goPrefetchAttempt; + void state + .prefetchGoModels() + .then((ids) => { + if ( + state.settled || + attempt !== state.goPrefetchAttempt || + !selectors.isGoModelListStep() || + state.choice === null + ) { + return; + } + const listed = state.choice.models; + const same = ids.length === listed.length && ids.every((id, i) => id === listed[i]); + if (same) return; + const focusedId = state.listRows[state.list.activeIndex]?.id; + state.choice = { ...state.choice, models: [...ids] }; + state.listRows = modelChoiceRows(state.choice); + const found = + focusedId === undefined ? -1 : state.listRows.findIndex((row) => row.id === focusedId); + state.list = createOverlayList(state.renderer as CliRenderer, { + count: state.listRows.length, + items: providerListHeight(state.renderer), + activeIndex: found >= 0 ? found : 0, + }); + surface.paint(); + }) + .catch(() => { + // Seed list is already on screen; a failed prefetch must not surface. + }); + }; + + return { beginOllamaDiscovery, abandonOllamaDiscovery, beginGoPrefetch, abandonGoPrefetch }; +} diff --git a/src/tui/provider-failure-attempt.ts b/src/tui/provider/failure-attempt.ts similarity index 97% rename from src/tui/provider-failure-attempt.ts rename to src/tui/provider/failure-attempt.ts index c817f8e4f..64190c87a 100644 --- a/src/tui/provider-failure-attempt.ts +++ b/src/tui/provider/failure-attempt.ts @@ -1,4 +1,4 @@ -import type { InferenceErrorLike } from "../inference-gateway-error.js"; +import type { InferenceErrorLike } from "../../inference-gateway-error.js"; export interface ProviderFailureAttempt { observed: boolean; diff --git a/src/tui/provider/form.ts b/src/tui/provider/form.ts new file mode 100644 index 000000000..6d2cbaf5a --- /dev/null +++ b/src/tui/provider/form.ts @@ -0,0 +1,145 @@ +/** + * Input masking and summary rendering for the provider setup form: how a typed + * secret is echoed and folded back, and how the per-step summary column reads. + */ + +import { UI } from "../theme.js"; +import { type ProviderField, stepLabel, type SetupStep } from "./steps.js"; +import type { ProviderChoice, ProviderFormValues, SubmitPhase } from "./types.js"; + +/** Placeholder shown in the text input for each free-text step. */ +export const PROVIDER_FIELD_HINTS: Record = { + name: "openai, anthropic, ollama, …", + baseURL: "https://api.openai.com/v1", + apiKey: "sk-… (blank for keyless/local)", + model: "gpt-4o", +}; + +/** Placeholder for the OAuth account-name step, which edits `oauthProfile`. */ +export const OAUTH_PROFILE_HINT = "default, personal, work, …"; + +const MASK_CHAR = "●"; +const MASK_CAP = 16; + +/** + * Bullet-render a secret for the read-only summary rows, capped so a long key + * does not blow out the row width. + */ +export function maskSecret(value: string): string { + return MASK_CHAR.repeat(Math.min([...value].length, MASK_CAP)); +} + +/** + * Bullet-render a secret for the live input echo. + * + * Uncapped, unlike `maskSecret`: the echo is what `secretFromMaskedEdit` reads + * back, so a capped echo would silently discard everything past the cap. + */ +export function maskEcho(value: string): string { + return MASK_CHAR.repeat([...value].length); +} + +/** apiKey is optional — blank means a keyless local provider (e.g. Ollama). */ +export function stepReady(step: SetupStep, value: string): boolean { + return step === "apiKey" || value.trim().length > 0; +} + +/** + * Fold an edit of the masked apiKey display back into the real secret. + * + * The input never holds the key: every keystroke is mirrored back as bullets, + * so an edit arrives as bullets plus whatever was just typed. Appends and + * end-of-line deletes round-trip exactly; mid-string edits fall back to + * truncation, which is why the field is re-typed rather than patched. + */ +export function secretFromMaskedEdit(secret: string, displayed: string): string { + const chars = [...displayed]; + const typed = chars.filter((c) => c !== MASK_CHAR); + const keptLength = chars.length - typed.length; + return [...secret].slice(0, keptLength).join("") + typed.join(""); +} + +/** `step 2 of 4 · api key` — always says where the operator is and what is left. */ +export function stepHeadline( + steps: readonly SetupStep[], + index: number, + choice: ProviderChoice | null = null, +): string { + const step = steps[Math.min(Math.max(index, 0), steps.length - 1)]; + if (step === undefined) return ""; + return `step ${index + 1} of ${steps.length} · ${stepLabel(step, choice)}`; +} + +export interface SummaryRow { + readonly label: string; + readonly value: string; + readonly state: "done" | "current" | "pending"; +} + +/** One row per step: settled rows show the value, later rows a dash. */ +export function summaryRows( + steps: readonly SetupStep[], + index: number, + values: ProviderFormValues, + choice: ProviderChoice | null, +): readonly SummaryRow[] { + return steps.map((step, i) => { + const state = i < index ? "done" : i === index ? "current" : "pending"; + return { + label: stepLabel(step, choice), + value: state === "done" ? settledValue(step, values, choice) : "—", + state, + }; + }); +} + +function settledValue( + step: SetupStep, + values: ProviderFormValues, + choice: ProviderChoice | null, +): string { + if (step === "provider") return choice?.label ?? values.name; + if (step === "login") return values.name.length > 0 ? values.name : "signed in"; + if (step === "apiKey") { + return values.apiKey.length > 0 ? maskSecret(values.apiKey) : "keyless"; + } + if (step === "name") { + return choice !== null && !choice.custom ? values.oauthProfile : values.name; + } + if (step === "baseURL") return values.baseURL; + return values.model; +} + +/** Render a summary row at a fixed label column. */ +export function summaryLine(row: SummaryRow): string { + const marker = row.state === "current" ? "›" : " "; + return `${marker} ${row.label.padEnd(14)}${row.state === "current" ? "" : row.value}`; +} + +export function summaryColor(row: SummaryRow): string { + if (row.state === "done") return UI.done; + if (row.state === "current") return UI.text; + return UI.textFaint; +} + +/** + * What the operator should do about a failure. A bare error message leaves a + * first-run user stuck, so every failure names the field to fix. + */ +export function failureGuidance( + phase: SubmitPhase, + choice: ProviderChoice | null, + offerSaveAnyway = true, +): string { + if (phase === "saving") { + return "settings could not be written — check disk permissions, enter to retry"; + } + if (!offerSaveAnyway) { + return choice !== null && !choice.custom + ? "the account cannot be saved — esc to reconnect or enter to retry" + : "check the base url and key — esc to go back, enter to retry"; + } + return choice !== null && !choice.custom + ? "the key was rejected or unreachable — esc to re-enter it, enter to retry, ctrl+s to save anyway" + : "check the base url and key — esc to go back, enter to retry, ctrl+s to save anyway"; +} diff --git a/src/tui/provider/oauth.ts b/src/tui/provider/oauth.ts new file mode 100644 index 000000000..b438d8b6c --- /dev/null +++ b/src/tui/provider/oauth.ts @@ -0,0 +1,364 @@ +/** + * OAuth profile slugs, login guidance, and the browser sign-in flow the + * subscription step runs — plus the multi-instance account-name step shared by + * OAuth accounts and API-key instances. + */ + +import { instanceSlugsForKind, OAUTH_SURFACES, resolveApiKeyInstanceName } from "./choices.js"; +import { OAUTH_PROFILE_HINT } from "./form.js"; +import { RAMP_TICK_MS, stopRamp } from "./surface.js"; +import type { + AccountNameFlow, + LoginFlow, + OAuthKind, + OAuthLoginStart, + OAuthResult, + SetupFlowHooks, + SetupSelectors, + SetupState, + Surface, +} from "./types.js"; + +/** Settings/catalog provider name a profile of `kind` is stored under. */ +export function oauthProviderName(kind: OAuthKind, profile: string): string { + return OAUTH_SURFACES[kind].providerName(profile); +} + +/** Longest slug the name step accepts, after normalization. */ +const OAUTH_PROFILE_MAX_LENGTH = 64; + +const OAUTH_PROFILE_CHARS = /^[a-z0-9._-]+$/; +const OAUTH_PROFILE_EDGE_SEPARATOR = /^[._-]|[._-]$/; + +export type OAuthProfileValidation = + { readonly ok: true; readonly slug: string } | { readonly ok: false; readonly error: string }; + +/** + * Validate and lowercase-normalize an operator-entered account slug. This is + * the constraint owner for the slug shape — the auth store and the catalog + * projection (`oauthProviderName`) trust whatever they are handed, since a + * "/" here would silently join into the compound catalog name they build. + */ +export function validateOAuthProfileSlug(raw: string): OAuthProfileValidation { + const slug = raw.trim().toLowerCase(); + if (slug.length === 0) return { ok: false, error: "name cannot be empty" }; + if (slug.length > OAUTH_PROFILE_MAX_LENGTH) { + return { + ok: false, + error: `name must be ${String(OAUTH_PROFILE_MAX_LENGTH)} characters or fewer`, + }; + } + if (!OAUTH_PROFILE_CHARS.test(slug)) { + return { ok: false, error: "use only lowercase letters, numbers, and . _ -" }; + } + if (OAUTH_PROFILE_EDGE_SEPARATOR.test(slug)) { + return { ok: false, error: "name cannot start or end with . _ or -" }; + } + return { ok: true, slug }; +} + +/** + * A slug that does not collide with `existing`, so a first sign-in can + * default to something usable without asking the operator to invent a name. + * "default" first, then "default-2", "default-3", … on collision. + */ +export function suggestOAuthProfileSlug(existing: readonly string[]): string { + const taken = new Set(existing); + if (!taken.has("default")) return "default"; + let n = 2; + while (taken.has(`default-${String(n)}`)) n += 1; + return `default-${String(n)}`; +} + +/** + * Real lister, imported lazily per kind so mounting the surface never touches + * the auth-store files in a test that injects its own lister. + */ +export const defaultProfileLister = async (kind: OAuthKind): Promise => { + if (kind === "codex") { + const { listCodexProfiles } = await import("../../auth/codex/store.js"); + return (await listCodexProfiles()).map((p) => p.name); + } + const { listXaiProfiles } = await import("../../auth/xai/store.js"); + return (await listXaiProfiles()).map((p) => p.name); +}; + +/** How long a sign-in may wait on the browser before it gives the screen back. */ +export const LOGIN_TIMEOUT_MS = 3 * 60 * 1000; + +export const LOGIN_TIMEOUT_MESSAGE = "sign-in timed out"; + +/** Set when the operator escapes a sign-in that was still outstanding. */ +export const LOGIN_CANCELLED_MESSAGE = "sign-in cancelled"; + +/** What the operator should do about a sign-in that did not complete. */ +export function loginGuidance(): string { + return "enter to try signing in again · esc to pick a different provider"; +} + +/** What the operator should do after abandoning a sign-in. */ +export function loginCancelGuidance(): string { + return "nothing was saved — pick a provider to start over"; +} + +/** Status line while the browser round-trip is outstanding. */ +export const LOGIN_WAITING_LABEL = "waiting for browser sign-in"; + +/** + * Real login: PKCE plus a loopback callback server, per provider. Imported + * lazily so mounting the surface never binds a port in a test that has + * injected its own starter. + */ +export const defaultLoginStarter = async ({ + kind, + profile, + signal, +}: { + readonly kind: OAuthKind; + readonly profile: string; + readonly signal: AbortSignal; +}) => { + if (kind === "codex") { + const { startCodexLogin } = await import("../../auth/codex/login.js"); + return startCodexLogin({ profile, signal }); + } + const { startXaiLogin } = await import("../../auth/xai/login.js"); + return startXaiLogin({ profile, signal }); +}; + +/** + * Browser sign-in state machine for the `login` step: arms the deadline before + * starting the flow, ignores late resolutions from superseded attempts, and + * hands the screen back on denial, transport failure, or timeout. + */ +export function createLoginFlow( + state: SetupState, + surface: Surface, + selectors: SetupSelectors, + hooks: SetupFlowHooks, +): LoginFlow { + const clearLoginTimer = (): void => { + if (state.loginTimer === null) return; + clearTimeout(state.loginTimer); + state.loginTimer = null; + }; + + /** + * Drop whatever attempt is in flight: stop its deadline, close its callback + * server, and bump the attempt counter so a late resolution is ignored. + */ + const abandonLogin = (): void => { + state.loginAttempt += 1; + clearLoginTimer(); + state.loginAbort?.abort(); + state.loginAbort = null; + state.loginHandle?.cancel(); + state.loginHandle = null; + }; + + /** Denial, transport failure, or the deadline — all land the operator here. */ + const failLogin = (attempt: number, message: string): void => { + if (attempt !== state.loginAttempt) return; + abandonLogin(); + stopRamp(state); + state.loginStatus = "failed"; + state.loginError = message; + state.loginURL = null; + surface.paint(); + }; + + const finishLogin = ( + attempt: number, + kind: OAuthKind, + staged: Awaited, + ): void => { + if (attempt !== state.loginAttempt) return; + clearLoginTimer(); + state.loginHandle = null; + state.loginAbort = null; + stopRamp(state); + state.loginStatus = "done"; + state.loginError = null; + const result: OAuthResult = { + kind, + tokens: staged.profile.tokens, + commit: staged.commit, + providerName: oauthProviderName(kind, staged.profile.name), + }; + state.loginResult = result; + state.values.name = result.providerName; + state.values.apiKey = ""; + state.stepIndex += 1; + if (selectors.isListStep()) hooks.enterModelList(); + hooks.showStep(); + }; + + const beginLogin = (): void => { + const kind = state.choice?.oauth ?? null; + if (kind === null) return; + abandonLogin(); + const attempt = state.loginAttempt; + state.loginStatus = "pending"; + state.loginError = null; + state.loginCancelled = false; + const abort = new AbortController(); + state.loginAbort = abort; + // A browser round-trip that never comes back must still give the screen + // back, so the deadline is armed before the flow is even started. + state.loginTimer = setTimeout(() => { + failLogin(attempt, LOGIN_TIMEOUT_MESSAGE); + }, state.loginTimeoutMs); + stopRamp(state); + state.rampTimer = setInterval(() => surface.paintStatus(), RAMP_TICK_MS); + surface.paint(); + + state.startLogin({ kind, profile: state.values.oauthProfile, signal: abort.signal }).then( + (handle) => { + if (attempt !== state.loginAttempt) { + handle.cancel(); + return; + } + state.loginHandle = handle; + state.loginURL = handle.authorizeUrl; + surface.paint(); + handle.completed.then( + (result) => { + finishLogin(attempt, kind, result); + }, + (err: unknown) => { + failLogin(attempt, err instanceof Error ? err.message : String(err)); + }, + ); + }, + (err: unknown) => { + failLogin(attempt, err instanceof Error ? err.message : String(err)); + }, + ); + }; + + /** Abandon an outstanding sign-in and return to the provider list. */ + const cancelLogin = (): void => { + const wasPending = state.loginStatus === "pending"; + abandonLogin(); + stopRamp(state); + state.loginStatus = "idle"; + state.loginURL = null; + state.loginError = null; + state.loginResult = null; + hooks.back(); + state.loginCancelled = wasPending; + surface.paint(); + }; + + return { beginLogin, abandonLogin, cancelLogin }; +} + +/** + * The multi-instance "name" step: an inline error from the last validation, + * a suggested non-colliding slug prefilled on entry, and a collision confirm + * (one more Enter) before the slug is settled and the flow advances. + */ +export function createAccountNameFlow( + state: SetupState, + surface: Surface, + hooks: SetupFlowHooks, +): AccountNameFlow { + /** + * Enter the step: reset per-visit state, show whatever slug is already + * typed, then resolve existing instance names to prefill a suggested, + * non-colliding slug when the field is still blank. OAuth reads the live + * auth store; API-key reads the settings catalog snapshot. + */ + const enter = (): void => { + state.oauthProfileError = null; + state.oauthProfileConfirmPending = false; + surface.input.placeholder = OAUTH_PROFILE_HINT; + surface.input.value = state.values.oauthProfile; + surface.paint(); + surface.input.focus(); + if (state.choice === null || state.choice.custom) return; + const attempt = (state.oauthNameAttempt += 1); + const applySuggestion = (names: readonly string[]): void => { + if (state.settled || attempt !== state.oauthNameAttempt) return; + if (state.values.oauthProfile.trim().length === 0) { + state.values.oauthProfile = suggestOAuthProfileSlug(names); + surface.input.value = state.values.oauthProfile; + surface.paint(); + } + }; + if (state.choice.oauth !== null) { + state + .listOAuthProfiles(state.choice.oauth) + .catch((): readonly string[] => []) + .then(applySuggestion); + return; + } + applySuggestion(instanceSlugsForKind(state.choice.id, state.existingProviderNames)); + }; + + const settleAccountNameSlug = (slug: string): void => { + state.values.oauthProfile = slug; + state.oauthProfileError = null; + state.oauthProfileConfirmPending = false; + state.confirmedSlug = null; + if (state.choice !== null && state.choice.oauth === null && !state.choice.custom) { + // API-key multi-instance: catalog key is kind/slug (or legacy bare kind + // when reconnecting the original single-instance "default"). + state.values.name = resolveApiKeyInstanceName( + state.choice.id, + slug, + state.existingProviderNames, + ); + } + state.stepIndex += 1; + hooks.showStep(); + }; + + /** + * Validate the entered slug, then check collisions against a fresh source + * (auth store for OAuth, settings catalog for API-key). A collision needs + * one more Enter to confirm before the step advances. + */ + const advance = (): void => { + if (state.choice === null || state.choice.custom) return; + const validated = validateOAuthProfileSlug(state.values.oauthProfile); + if (!validated.ok) { + state.oauthProfileError = validated.error; + state.oauthProfileConfirmPending = false; + state.confirmedSlug = null; + surface.paint(); + return; + } + const slug = validated.slug; + // Already confirmed this exact slug on the previous Enter — proceed + // without another round-trip. Any edit since then cleared the flag (see + // the input handler), so this only fires on a genuine second, unmodified + // Enter. + if (state.oauthProfileConfirmPending && state.confirmedSlug === slug) { + settleAccountNameSlug(slug); + return; + } + const attempt = (state.oauthNameAttempt += 1); + const handleNames = (names: readonly string[]): void => { + if (state.settled || attempt !== state.oauthNameAttempt) return; + if (names.includes(slug)) { + state.oauthProfileError = null; + state.oauthProfileConfirmPending = true; + state.confirmedSlug = slug; + surface.paint(); + return; + } + settleAccountNameSlug(slug); + }; + if (state.choice.oauth !== null) { + state + .listOAuthProfiles(state.choice.oauth) + .catch((): readonly string[] => []) + .then(handleNames); + return; + } + handleNames(instanceSlugsForKind(state.choice.id, state.existingProviderNames)); + }; + + return { enter, advance }; +} diff --git a/src/tui/provider/setup.ts b/src/tui/provider/setup.ts new file mode 100644 index 000000000..8115981d1 --- /dev/null +++ b/src/tui/provider/setup.ts @@ -0,0 +1,463 @@ +/** + * First-run provider setup on OpenTUI: the `runProviderSetup` surface + * assembly and step navigation. + * + * Selection first: the operator picks a known provider from the first-class + * catalog (which prefills base URL and models), types only the API key, then + * picks a model. "Custom" falls back to the full manual form for endpoints the + * catalog does not know. + * + * The surface owns paint + input only; the caller owns the connection test and + * the settings write via `onSubmit`. Painting lives in surface.ts, the browser + * sign-in and account-name flows in oauth.ts, model discovery in discovery.ts. + */ + +import { + createCliRenderer, + InputRenderableEvents, + type CliRenderer, + type KeyEvent, +} from "@opentui/core"; + +import { isOAuthProviderScopeError } from "../../auth/oauth-scope-check.js"; +import { + discoverOllamaModels as discoverOllamaModelsRequest, + isOllamaProviderId, +} from "../../provider/ollama.js"; +import { prefetchGoModels as prefetchGoModelsRequest } from "../../provider/opencode-go-models.js"; +import { resolveSideMargin } from "../geometry/margins.js"; +import { residualIdFromSelection, residualListFromCatalog } from "../residuals.js"; +import { createOverlayList } from "../shell/overlay-list.js"; +import { + chooseProviderRow, + enterModelListRows, + enterProviderRows, + modelFromRowId, + providerChoiceRows, + providerChoices, + providerListHeight, + TYPE_MODEL_ID, +} from "./choices.js"; +import { createDiscoveryFlows } from "./discovery.js"; +import { maskEcho, PROVIDER_FIELD_HINTS, secretFromMaskedEdit, stepReady } from "./form.js"; +import { + createAccountNameFlow, + createLoginFlow, + defaultLoginStarter, + defaultProfileLister, + LOGIN_TIMEOUT_MS, +} from "./oauth.js"; +import { createSurface, RAMP_TICK_MS, stopRamp, teardownSurface } from "./surface.js"; +import { stepsFor, type ProviderField, type SetupStep } from "./steps.js"; +import type { + ProviderPreset, + ProviderSetupConfig, + SetupSelectors, + SetupState, + SubmitPhase, +} from "./types.js"; + +/** + * Mount the setup surface. Resolves true once `onSubmit` completes, false when + * the operator cancels (Ctrl+C / Ctrl+D) without a successful submit. + */ +export async function runProviderSetup(config: ProviderSetupConfig): Promise { + // A caller-supplied renderer (a headless test harness, or a live session's + // renderer reused for a mid-session reconnect) is owned by that caller — + // teardown here must not destroy it out from under them. + const externalRenderer = config.createRenderer !== undefined; + const renderer = config.createRenderer + ? await config.createRenderer() + : await createCliRenderer({ + exitOnCtrlC: false, + targetFps: 30, + // Reporting stays off during onboarding, unlike the main shell, so + // the terminal owns drag-select and its own copy here. + useMouse: false, + enableMouseMovement: false, + }); + + const choices = providerChoices(); + const initialRows = providerChoiceRows(choices); + const state: SetupState = { + config, + renderer, + externalRenderer, + choices, + existingProviderNames: config.existingProviderNames ?? [], + values: { + name: "", + baseURL: "", + apiKey: "", + model: "", + oauthProfile: "", + }, + margin: resolveSideMargin(renderer.width || 80), + choice: null, + stepIndex: 0, + typedModel: false, + submitting: false, + submitPhase: "testing", + submitError: null, + saveAnywayOffered: false, + rampTimer: null, + startLogin: config.startLogin ?? defaultLoginStarter, + listOAuthProfiles: config.listOAuthProfiles ?? defaultProfileLister, + loginTimeoutMs: config.loginTimeoutMs ?? LOGIN_TIMEOUT_MS, + loginStatus: "idle", + loginURL: null, + loginError: null, + loginResult: null, + loginAbort: null, + loginHandle: null, + loginTimer: null, + loginCancelled: false, + loginAttempt: 0, + oauthProfileError: null, + oauthProfileConfirmPending: false, + confirmedSlug: null, + oauthNameAttempt: 0, + discoverOllamaModels: config.discoverOllamaModels ?? discoverOllamaModelsRequest, + ollamaDiscovery: "idle", + ollamaDiscoveryAttempt: 0, + ollamaDiscoveryAbort: null, + prefetchGoModels: config.prefetchGoModels ?? prefetchGoModelsRequest, + goPrefetchAttempt: 0, + listRows: initialRows, + list: createOverlayList(renderer as CliRenderer, { + count: initialRows.length, + items: providerListHeight(renderer), + }), + settled: false, + resolveDone: () => {}, + }; + + if (config.initialProviderId !== undefined) { + const preselected = state.choices.find((c) => c.id === config.initialProviderId); + if (preselected !== undefined) { + state.choice = preselected; + state.stepIndex = 1; + state.values.name = preselected.label; + state.values.baseURL = preselected.baseURL; + state.values.model = preselected.defaultModel; + } + } + + const steps = (): readonly SetupStep[] => stepsFor(state.choice); + const currentStep = (): SetupStep => steps()[state.stepIndex] ?? ("provider" as SetupStep); + const isOllamaModelStep = (): boolean => + currentStep() === "model" && state.choice !== null && isOllamaProviderId(state.choice.id); + const isListStep = (): boolean => { + const step = currentStep(); + if (step === "provider") return true; + if (isOllamaModelStep()) { + return typeof state.ollamaDiscovery === "object" && state.ollamaDiscovery.status === "models"; + } + return step === "model" && state.choice !== null && !state.choice.custom && !state.typedModel; + }; + // The "name" step means two different things depending on the path: a + // free-text provider name (custom) or a multi-instance account slug (OAuth + // and first-class API-key) with suggestion/collision machinery. Only the + // latter needs this branch. + const isAccountNameStep = (): boolean => + currentStep() === "name" && state.choice !== null && !state.choice.custom; + const isGoModelListStep = (): boolean => + currentStep() === "model" && + state.choice !== null && + state.choice.opencodeGo && + !state.choice.custom && + !state.typedModel; + const selectors: SetupSelectors = { + steps, + currentStep, + isOllamaModelStep, + isListStep, + isAccountNameStep, + isGoModelListStep, + }; + const isLoginStep = (): boolean => currentStep() === "login"; + + const surface = createSurface(state, selectors); + const login = createLoginFlow(state, surface, selectors, { showStep, back, enterModelList }); + const discovery = createDiscoveryFlows(state, surface, selectors); + const accountName = createAccountNameFlow(state, surface, { showStep, back, enterModelList }); + + const done = new Promise((resolve) => { + state.resolveDone = resolve; + }); + + const teardown = (): void => { + teardownSurface(state, surface, login, discovery, onKey, onEnter, onInput); + }; + + const settle = (submitted: boolean): void => { + if (state.settled) return; + state.settled = true; + teardown(); + state.resolveDone(submitted); + }; + + const clearError = (): void => { + state.submitError = null; + state.saveAnywayOffered = false; + state.loginCancelled = false; + }; + + function showStep(): void { + const active = currentStep(); + if (isListStep() || isLoginStep() || isOllamaModelStep()) { + surface.input.blur(); + surface.paint(); + if (isOllamaModelStep() && state.ollamaDiscovery === "idle") discovery.beginOllamaDiscovery(); + // Arriving on the sign-in step is the trigger: there is nothing to type, + // so the flow starts itself rather than waiting for a keystroke. + if (isLoginStep() && state.loginStatus === "idle") login.beginLogin(); + return; + } + if (isAccountNameStep()) { + accountName.enter(); + return; + } + const field = active as ProviderField; + surface.input.placeholder = PROVIDER_FIELD_HINTS[field]; + surface.input.value = field === "apiKey" ? maskEcho(state.values.apiKey) : state.values[field]; + // Paint first: focus is refused while the input is still hidden. + surface.paint(); + surface.input.focus(); + } + + function submit(skipValidation: boolean): void { + state.submitting = true; + state.submitPhase = "testing"; + clearError(); + surface.paint(); + stopRamp(state); + state.rampTimer = setInterval(() => surface.paintStatus(), RAMP_TICK_MS); + + // Track the phase locally so the rejection handler knows whether the + // failure happened during the connection test (retryable and bypassable) + // or during the settings write. + let phase: SubmitPhase = "testing"; + const setPhase = (p: SubmitPhase): void => { + phase = p; + state.submitPhase = p; + surface.paint(); + }; + + const preset: ProviderPreset | undefined = + state.choice !== null && !state.choice.custom + ? { + id: state.choice.id, + models: state.choice.models, + anthropic: state.choice.anthropic, + opencodeGo: state.choice.opencodeGo, + } + : undefined; + + config + .onSubmit(state.values, setPhase, { + skipValidation, + ...(preset !== undefined ? { preset } : {}), + ...(state.loginResult !== null ? { oauth: state.loginResult } : {}), + }) + .then( + () => settle(true), + (err: unknown) => { + stopRamp(state); + state.submitting = false; + state.submitPhase = phase; + state.submitError = err instanceof Error ? err.message : String(err); + state.saveAnywayOffered = phase === "testing" && !isOAuthProviderScopeError(err); + surface.paint(); + }, + ); + } + + const chooseProvider = (id: string): void => { + chooseProviderRow(state, id, discovery); + if (isListStep()) enterModelList(); + }; + + function enterModelList(): void { + enterModelListRows(state, renderer, discovery); + } + + const enterProviderList = (): void => { + enterProviderRows(state, renderer); + }; + + const acceptListRow = (): void => { + const { itemIds } = residualListFromCatalog(state.listRows); + const id = residualIdFromSelection({ index: state.list.activeIndex }, itemIds); + if (id === undefined) return; + clearError(); + if (currentStep() === "provider") { + chooseProvider(id); + showStep(); + return; + } + if (id === TYPE_MODEL_ID) { + state.typedModel = true; + state.values.model = ""; + showStep(); + return; + } + state.values.model = modelFromRowId(state.choice?.id ?? "", id); + submit(false); + }; + + const advance = (): void => { + if (isListStep()) { + acceptListRow(); + return; + } + if (isOllamaModelStep()) { + if (state.ollamaDiscovery !== "loading") discovery.beginOllamaDiscovery(); + return; + } + if (isLoginStep()) { + if (state.loginStatus === "done") { + state.stepIndex += 1; + if (isListStep()) enterModelList(); + showStep(); + return; + } + // A pending sign-in has nothing to confirm; a failed one retries. + if (state.loginStatus !== "pending") login.beginLogin(); + return; + } + if (isAccountNameStep()) { + accountName.advance(); + return; + } + const field = currentStep() as ProviderField; + if (!stepReady(field, state.values[field])) return; + + if (state.stepIndex < steps().length - 1) { + state.stepIndex += 1; + clearError(); + if (isListStep()) enterModelList(); + showStep(); + return; + } + submit(false); + }; + + function back(): void { + if (state.stepIndex === 0) return; + if (isOllamaModelStep()) { + discovery.abandonOllamaDiscovery(); + state.ollamaDiscovery = "idle"; + } + if (isGoModelListStep()) discovery.abandonGoPrefetch(); + state.stepIndex -= 1; + clearError(); + if (currentStep() === "provider") enterProviderList(); + else if (isListStep()) enterModelList(); + showStep(); + } + + function onInput(next: string): void { + if (state.submitting || isListStep()) return; + if (isAccountNameStep()) { + state.values.oauthProfile = next; + // An edit invalidates whatever the last submit attempt found — the + // confirm applies to one exact slug, and any inline error is stale + // the moment the text it described changes. + const hadFeedback = state.oauthProfileError !== null || state.oauthProfileConfirmPending; + state.oauthProfileError = null; + state.oauthProfileConfirmPending = false; + state.confirmedSlug = null; + if (hadFeedback) surface.paint(); + return; + } + const field = currentStep() as ProviderField; + if (field === "apiKey") { + state.values.apiKey = secretFromMaskedEdit(state.values.apiKey, next); + const masked = maskEcho(state.values.apiKey); + if (surface.input.value !== masked) surface.input.value = masked; + } else { + state.values[field] = next; + } + if (state.submitError !== null) { + clearError(); + surface.paint(); + } + } + + function onEnter(): void { + if (state.submitting) return; + advance(); + } + + function onKey(key: KeyEvent): void { + if (state.settled) return; + if (key.ctrl === true && (key.name === "c" || key.name === "d")) { + key.preventDefault(); + settle(false); + return; + } + if (state.submitting) { + key.preventDefault(); + return; + } + if (key.ctrl === true && key.name === "s") { + if (!state.saveAnywayOffered) return; + key.preventDefault(); + submit(true); + return; + } + if (key.name === "escape") { + key.preventDefault(); + if (isLoginStep()) { + login.cancelLogin(); + } else if (isAccountNameStep() && state.oauthProfileConfirmPending) { + // Cancel the re-authorize confirm without leaving the step — the + // operator is about to edit the name, not abandon the provider. + state.oauthProfileConfirmPending = false; + state.confirmedSlug = null; + surface.paint(); + } else { + back(); + } + return; + } + if (isLoginStep()) { + if (key.name === "return" || key.name === "enter") { + key.preventDefault(); + advance(); + } + return; + } + if (isOllamaModelStep() && !isListStep() && (key.name === "return" || key.name === "enter")) { + key.preventDefault(); + advance(); + return; + } + if (!isListStep()) return; + + if (key.name === "up" || key.name === "k") { + key.preventDefault(); + state.list.move(-1); + surface.paint(); + return; + } + if (key.name === "down" || key.name === "j") { + key.preventDefault(); + state.list.move(1); + surface.paint(); + return; + } + if (key.name === "return" || key.name === "enter") { + key.preventDefault(); + advance(); + } + } + + surface.input.on(InputRenderableEvents.ENTER, onEnter); + surface.input.on(InputRenderableEvents.INPUT, onInput); + renderer.keyInput.on("keypress", onKey); + showStep(); + + return done; +} diff --git a/src/tui/provider/steps.ts b/src/tui/provider/steps.ts new file mode 100644 index 000000000..df884b06d --- /dev/null +++ b/src/tui/provider/steps.ts @@ -0,0 +1,74 @@ +/** + * Step tables, labels, and prompts for the provider setup flow: which screens + * each provider path walks through and the copy shown on them. + */ + +import { isOllamaProviderId } from "../../provider/ollama.js"; +import type { ProviderChoice } from "./types.js"; + +export type ProviderField = "name" | "baseURL" | "apiKey" | "model"; + +/** One screen of the flow. `provider` and `model` can be pick-lists. */ +export type SetupStep = "provider" | "name" | "baseURL" | "apiKey" | "model" | "login"; + +/** Known-provider path: pick, name the instance, paste key, pick model. */ +export const PRESET_STEPS: readonly SetupStep[] = ["provider", "name", "apiKey", "model"]; + +/** Ollama is keyless and keeps its editable root URL visible before discovery. */ +export const OLLAMA_STEPS: readonly SetupStep[] = ["provider", "name", "baseURL", "model"]; + +/** + * Subscription path: pick, name the account (a suggested slug is prefilled; + * reusing an existing name asks for confirmation before re-authorizing it), + * sign in through the browser, pick a model. + */ +export const OAUTH_STEPS: readonly SetupStep[] = ["provider", "name", "login", "model"]; + +/** Unknown endpoint: the full manual form, still preceded by the pick-list. */ +export const CUSTOM_STEPS: readonly SetupStep[] = [ + "provider", + "name", + "baseURL", + "apiKey", + "model", +]; + +export const STEP_LABELS: Record = { + provider: "provider", + name: "provider name", + baseURL: "base url", + apiKey: "api key", + model: "model", + login: "sign in", +}; + +export const STEP_PROMPTS: Record = { + provider: "pick the provider you have a key or subscription for", + name: "name this provider — you will see it in /model", + baseURL: "paste the provider url — Ollama uses the server root; others may include /v1", + apiKey: "paste the api key — leave blank for a keyless local endpoint", + model: "pick the model to start with", + login: "authorize in the browser — this window waits for you", +}; + +/** Instruction for the multi-instance "name" step (OAuth and API-key). */ +export function accountNamePrompt(choice: ProviderChoice): string { + if (choice.oauth != null) { + return `name this account — stored as ${choice.oauth}/, and used again if you reconnect it`; + } + return `name this instance — stored as ${choice.id}/, and used again if you reconnect it`; +} + +// The "name" step names a whole provider on the custom path but a single +// account/instance on multi-instance first-class kinds (OAuth and API-key). +export function stepLabel(step: SetupStep, choice: ProviderChoice | null): string { + if (step === "name" && choice !== null && !choice.custom) return "account name"; + return STEP_LABELS[step]; +} + +export function stepsFor(choice: ProviderChoice | null): readonly SetupStep[] { + if (choice === null) return PRESET_STEPS; + if (choice.custom) return CUSTOM_STEPS; + if (isOllamaProviderId(choice.id)) return OLLAMA_STEPS; + return choice.oauth !== null ? OAUTH_STEPS : PRESET_STEPS; +} diff --git a/src/tui/provider-setup-submit.ts b/src/tui/provider/submit.ts similarity index 96% rename from src/tui/provider-setup-submit.ts rename to src/tui/provider/submit.ts index 9f43e35fe..fed63da8d 100644 --- a/src/tui/provider-setup-submit.ts +++ b/src/tui/provider/submit.ts @@ -1,21 +1,21 @@ import { - OAuthProviderScopeError, - checkOAuthProviderScope, - isBlockingOAuthScopeCheckResult, -} from "../auth/oauth-scope-check.js"; + isOllamaProviderId, + normalizeOllamaRootURL, + ollamaOpenAIBaseURL, +} from "../../provider/ollama.js"; +import { validateProviderConnection } from "../../provider/validate-connection.js"; import { mergeProviderIntoSettings, saveGlobalSettings, saveLocalSettings, type Settings, -} from "../config/settings.js"; +} from "../../config/settings.js"; import { - isOllamaProviderId, - normalizeOllamaRootURL, - ollamaOpenAIBaseURL, -} from "../provider/ollama.js"; -import { validateProviderConnection } from "../provider/validate-connection.js"; -import type { ProviderSetupSubmit } from "./provider-setup.js"; + OAuthProviderScopeError, + checkOAuthProviderScope, + isBlockingOAuthScopeCheckResult, +} from "../../auth/oauth-scope-check.js"; +import type { ProviderSetupSubmit } from "./types.js"; /** * Persist the project-local provider/model selection after a successful diff --git a/src/tui/provider/surface.ts b/src/tui/provider/surface.ts new file mode 100644 index 000000000..6b437d0c7 --- /dev/null +++ b/src/tui/provider/surface.ts @@ -0,0 +1,513 @@ +/** + * Renderable tree and paint pipeline for the provider setup screen. Built + * once per mount; every paint reads the shared setup state, so the flows in + * oauth/discovery/setup only mutate state and call `paint`/`paintStatus`. + */ + +import { + BoxRenderable, + InputRenderable, + InputRenderableEvents, + TextRenderable, + type KeyEvent, +} from "@opentui/core"; + +import { PRODUCT_NAME } from "../../branding.js"; +import { ollamaDiscoveryFailureLine } from "../../provider/ollama.js"; +import { TELEMETRY_NOTICE } from "../../telemetry/index.js"; +import { wrapLines } from "../view/height.js"; +import { rampFor, rampLine } from "../ramp.js"; +import { destroySubtree } from "../teardown.js"; +import { UI } from "../theme.js"; +import { + failureGuidance, + PROVIDER_FIELD_HINTS, + stepHeadline, + summaryColor, + summaryLine, + summaryRows, +} from "./form.js"; +import { + LOGIN_CANCELLED_MESSAGE, + loginCancelGuidance, + loginGuidance, + LOGIN_WAITING_LABEL, +} from "./oauth.js"; +import { accountNamePrompt, CUSTOM_STEPS, STEP_PROMPTS } from "./steps.js"; +import { PROVIDER_LIST_ROWS_MAX } from "./choices.js"; +import type { + DiscoveryFlows, + LoginFlow, + SetupSelectors, + SetupState, + SubmitPhase, + Surface, +} from "./types.js"; + +const SUMMARY_SLOTS = CUSTOM_STEPS.length; +/** Wrapped rows reserved for the authorize URL and its instruction. */ +const LOGIN_ROWS = 4; +const TELEMETRY_ROWS = 3; +/** + * Input capacity. The renderable defaults to 1000 characters and truncates a + * longer paste silently, which a first run would read as "paste is broken"; + * long-lived service-account keys and JWT-shaped tokens clear that default. + */ +const FIELD_MAX_LENGTH = 16_384; +/** Ramp animation tick. Fast enough to read as motion at 30fps paint. */ +export const RAMP_TICK_MS = 120; + +// "testing" covers the connection-check call against the entered credentials; +// "saving" covers the settings write that follows once the test succeeds. +const SUBMIT_PHASE_LABEL: Record = { + testing: "testing connection", + saving: "writing settings", +}; + +/** Stop the shared ramp animation timer, if one is running. */ +export function stopRamp(state: SetupState): void { + if (state.rampTimer === null) return; + clearInterval(state.rampTimer); + state.rampTimer = null; +} + +/** + * Unmount the surface: stop every in-flight flow, detach the input handlers, + * and destroy the renderable tree. The renderer itself is destroyed only when + * this mount created it — a caller-supplied renderer is owned by that caller. + */ +export function teardownSurface( + state: SetupState, + surface: Surface, + login: LoginFlow, + discovery: DiscoveryFlows, + onKey: (key: KeyEvent) => void, + onEnter: () => void, + onInput: (next: string) => void, +): void { + stopRamp(state); + login.abandonLogin(); + discovery.abandonOllamaDiscovery(); + discovery.abandonGoPrefetch(); + state.renderer.keyInput.off("keypress", onKey); + surface.input.off(InputRenderableEvents.ENTER, onEnter); + surface.input.off(InputRenderableEvents.INPUT, onInput); + try { + state.renderer.root.remove(surface.root); + destroySubtree(surface.root); + } catch { + // already unmounted + } + if (!state.externalRenderer) { + try { + state.renderer.destroy(); + } catch { + // already destroyed + } + } +} + +export function createSurface(state: SetupState, selectors: SetupSelectors): Surface { + const { renderer, margin, config } = state; + + const root = new BoxRenderable(renderer, { + id: "provider-setup", + width: "100%", + height: "100%", + flexDirection: "column", + backgroundColor: UI.ground, + paddingTop: 1, + paddingLeft: margin, + paddingRight: margin, + }); + + // Every direct child of `root` needs flexShrink: 0, full stop — a plain + // TextRenderable defaults to shrinkable, and a short terminal makes the + // flex algorithm compress unprotected single-line rows into each other + // (garbled overlapping text) instead of clipping the column from the + // bottom. header/intro/step/instruction here, and statusLine/guidance/ + // footer further down, all needed this; it is not specific to one step. + const header = new TextRenderable(renderer, { + id: "provider-setup-header", + content: `${PRODUCT_NAME.toLowerCase()} · setup`, + fg: UI.inFlightBright, + flexShrink: 0, + }); + const intro = new TextRenderable(renderer, { + id: "provider-setup-welcome", + content: "connect an inference provider — switch later with /model", + fg: UI.textDim, + flexShrink: 0, + }); + const step = new TextRenderable(renderer, { + id: "provider-setup-step", + content: "", + fg: UI.action, + flexShrink: 0, + }); + const instruction = new TextRenderable(renderer, { + id: "provider-setup-instruction", + content: "", + fg: UI.text, + flexShrink: 0, + }); + + const summary = new BoxRenderable(renderer, { + id: "provider-setup-summary", + width: "100%", + flexDirection: "column", + flexShrink: 0, + paddingTop: 1, + backgroundColor: UI.ground, + }); + const summarySlots = Array.from( + { length: SUMMARY_SLOTS }, + (_, i) => + new TextRenderable(renderer, { + id: `provider-setup-summary-${String(i)}`, + content: "", + fg: UI.textDim, + }), + ); + for (const row of summarySlots) summary.add(row); + + const listBox = new BoxRenderable(renderer, { + id: "provider-setup-list", + width: "100%", + flexDirection: "column", + flexShrink: 0, + paddingTop: 1, + backgroundColor: UI.ground, + }); + const listSlots = Array.from( + { length: PROVIDER_LIST_ROWS_MAX }, + (_, i) => + new TextRenderable(renderer, { + id: `provider-setup-list-${String(i)}`, + content: "", + fg: UI.textDim, + }), + ); + for (const row of listSlots) listBox.add(row); + + const inputFrame = new BoxRenderable(renderer, { + id: "provider-setup-input-frame", + width: "100%", + height: 3, + flexShrink: 0, + border: true, + borderColor: UI.textFaint, + focusedBorderColor: UI.inFlight, + backgroundColor: UI.ground, + paddingLeft: 1, + paddingRight: 1, + }); + const input = new InputRenderable(renderer, { + id: "provider-setup-input", + width: "100%", + maxLength: FIELD_MAX_LENGTH, + placeholder: PROVIDER_FIELD_HINTS.apiKey, + backgroundColor: UI.ground, + focusedBackgroundColor: UI.ground, + textColor: UI.text, + cursorColor: UI.text, + placeholderColor: UI.textFaint, + }); + inputFrame.add(input); + + const loginBox = new BoxRenderable(renderer, { + id: "provider-setup-login", + width: "100%", + flexDirection: "column", + flexShrink: 0, + paddingTop: 1, + backgroundColor: UI.ground, + visible: false, + }); + const loginSlots = Array.from( + { length: LOGIN_ROWS }, + (_, i) => + new TextRenderable(renderer, { + id: `provider-setup-login-${String(i)}`, + content: "", + fg: UI.textDim, + }), + ); + for (const row of loginSlots) loginBox.add(row); + + const statusLine = new TextRenderable(renderer, { + id: "provider-setup-status", + content: "", + fg: UI.textDim, + flexShrink: 0, + }); + const guidance = new TextRenderable(renderer, { + id: "provider-setup-guidance", + content: "", + fg: UI.textDim, + flexShrink: 0, + }); + const telemetry = new BoxRenderable(renderer, { + id: "provider-setup-telemetry", + width: "100%", + flexDirection: "column", + flexShrink: 0, + paddingTop: 1, + backgroundColor: UI.ground, + visible: config.showTelemetryNotice, + }); + const telemetrySlots = Array.from( + { length: TELEMETRY_ROWS }, + (_, i) => + new TextRenderable(renderer, { + id: `provider-setup-telemetry-${String(i)}`, + content: "", + // A disclosure, not fine print: body emphasis, above the footer. + fg: UI.text, + }), + ); + for (const row of telemetrySlots) telemetry.add(row); + if (config.showTelemetryNotice) { + const width = Math.max(20, (renderer.width || 80) - margin * 2); + const lines = wrapLines(TELEMETRY_NOTICE, width).slice(0, TELEMETRY_ROWS); + lines.forEach((line, i) => { + const slot = telemetrySlots[i]; + if (slot !== undefined) slot.content = line; + }); + } + + const footer = new TextRenderable(renderer, { + id: "provider-setup-footer", + content: "", + fg: UI.textFaint, + flexShrink: 0, + }); + + root.add(header); + root.add(intro); + root.add(step); + root.add(instruction); + root.add(summary); + root.add(listBox); + root.add(loginBox); + root.add(inputFrame); + root.add(statusLine); + root.add(guidance); + root.add(telemetry); + root.add(footer); + renderer.root.add(root); + + const paintSummary = (): void => { + const rows = summaryRows(selectors.steps(), state.stepIndex, state.values, state.choice); + summarySlots.forEach((slot, i) => { + const row = rows[i]; + if (row === undefined) { + slot.content = ""; + slot.visible = false; + return; + } + slot.visible = true; + slot.content = summaryLine(row); + slot.fg = summaryColor(row); + }); + }; + + const paintList = (): void => { + const showList = selectors.isListStep() && !state.submitting; + listBox.visible = showList; + if (!showList) { + for (const slot of listSlots) { + slot.content = ""; + slot.visible = false; + } + return; + } + const slice = state.list.visibleRange(); + listSlots.forEach((slot, i) => { + const index = slice.start + i; + const row = index < slice.end ? state.listRows[index] : undefined; + if (row === undefined) { + slot.content = ""; + slot.visible = false; + return; + } + const active = index === state.list.activeIndex; + slot.visible = true; + slot.content = ` ${active ? ">" : " "} ${row.label}`; + slot.fg = active ? UI.text : UI.textDim; + }); + }; + + const isLoginStep = (): boolean => selectors.currentStep() === "login"; + + const paintLogin = (): void => { + const show = isLoginStep() && !state.submitting; + loginBox.visible = show; + const width = Math.max(20, (renderer.width || 80) - margin * 2); + const lines: string[] = + !show || state.loginURL === null + ? [] + : ["open this url to authorize:", ...wrapLines(state.loginURL, width)]; + loginSlots.forEach((slot, i) => { + const line = lines[i]; + if (line === undefined) { + slot.content = ""; + slot.visible = false; + return; + } + slot.visible = true; + slot.content = line; + // The url is the one thing to act on here, so it reads above chrome. + slot.fg = i === 0 ? UI.textDim : UI.inFlightBright; + }); + }; + + const paintStatus = (): void => { + if (!state.submitting && selectors.isOllamaModelStep() && state.ollamaDiscovery !== "idle") { + if (state.ollamaDiscovery === "loading") { + const ramp = rampFor({ phase: "working", nowMs: Date.now() }); + statusLine.content = rampLine(ramp, "checking installed Ollama models"); + statusLine.fg = ramp.fg; + guidance.content = "esc to edit the Ollama URL"; + return; + } + if (state.ollamaDiscovery.status !== "models") { + const empty = state.ollamaDiscovery.status === "empty"; + const malformed = state.ollamaDiscovery.status === "malformed"; + const ramp = rampFor({ phase: "blocked", nowMs: 0 }); + statusLine.content = rampLine(ramp, ollamaDiscoveryFailureLine(state.ollamaDiscovery)); + statusLine.fg = ramp.fg; + guidance.content = empty + ? "pull a model, then press enter to retry · esc to edit url" + : malformed + ? "check the Ollama URL, then press enter to retry · esc to edit url" + : "press enter to retry · esc to edit url"; + return; + } + } + if (!state.submitting && selectors.isAccountNameStep()) { + if (state.oauthProfileError !== null) { + const ramp = rampFor({ phase: "blocked", nowMs: 0 }); + statusLine.content = rampLine(ramp, state.oauthProfileError); + statusLine.fg = ramp.fg; + guidance.content = "fix the name and press enter"; + guidance.fg = UI.textDim; + return; + } + if (state.oauthProfileConfirmPending) { + const ramp = rampFor({ phase: "blocked", nowMs: 0 }); + statusLine.content = rampLine( + ramp, + `"${state.confirmedSlug ?? state.values.oauthProfile}" is already connected`, + ); + statusLine.fg = ramp.fg; + guidance.content = + state.choice?.oauth != null + ? "enter again to re-authorize this account · esc to cancel" + : "enter again to replace this instance's key · esc to cancel"; + guidance.fg = UI.textDim; + return; + } + } + if (!state.submitting && isLoginStep()) { + if (state.loginStatus === "failed") { + const ramp = rampFor({ phase: "blocked", nowMs: 0 }); + statusLine.content = rampLine(ramp, (state.loginError ?? "").toLowerCase()); + statusLine.fg = ramp.fg; + guidance.content = loginGuidance(); + guidance.fg = UI.textDim; + return; + } + if (state.loginStatus === "done") { + const ramp = rampFor({ phase: "done", nowMs: 0 }); + statusLine.content = rampLine( + ramp, + `signed in as ${state.loginResult?.providerName ?? "the account"}`, + ); + statusLine.fg = ramp.fg; + guidance.content = "enter to pick a model"; + guidance.fg = UI.textDim; + return; + } + const ramp = rampFor({ phase: "working", nowMs: Date.now() }); + statusLine.content = rampLine(ramp, LOGIN_WAITING_LABEL); + statusLine.fg = ramp.fg; + guidance.content = "the browser should have opened — paste the url if not"; + guidance.fg = UI.textDim; + return; + } + if (!state.submitting && state.loginCancelled) { + const ramp = rampFor({ phase: "blocked", nowMs: 0 }); + statusLine.content = rampLine(ramp, LOGIN_CANCELLED_MESSAGE); + statusLine.fg = ramp.fg; + guidance.content = loginCancelGuidance(); + guidance.fg = UI.textDim; + return; + } + if (state.submitting) { + const ramp = rampFor({ phase: "working", nowMs: Date.now() }); + statusLine.content = rampLine(ramp, SUBMIT_PHASE_LABEL[state.submitPhase]); + statusLine.fg = ramp.fg; + guidance.content = ""; + return; + } + if (state.submitError !== null) { + const ramp = rampFor({ phase: "blocked", nowMs: 0 }); + statusLine.content = rampLine(ramp, state.submitError.toLowerCase()); + statusLine.fg = ramp.fg; + guidance.content = failureGuidance(state.submitPhase, state.choice, state.saveAnywayOffered); + guidance.fg = UI.textDim; + return; + } + statusLine.content = ""; + guidance.content = ""; + }; + + const paintFooter = (): void => { + if (state.submitting) { + footer.content = "ctrl+c cancel"; + return; + } + if (selectors.isOllamaModelStep() && !selectors.isListStep()) { + footer.content = "enter retry · esc edit url · ctrl+c cancel"; + return; + } + if (isLoginStep()) { + footer.content = + state.loginStatus === "failed" + ? "enter retry · esc back · ctrl+c cancel" + : state.loginStatus === "done" + ? "enter continue · esc back · ctrl+c cancel" + : "esc cancel sign-in · ctrl+c quit"; + return; + } + footer.content = selectors.isListStep() + ? "↑↓ move · enter choose · ctrl+c cancel" + : state.stepIndex === 0 + ? "enter confirm · ctrl+c cancel" + : "enter confirm · esc back · ctrl+c cancel"; + }; + + const paint = (): void => { + const active = selectors.currentStep(); + step.content = stepHeadline(selectors.steps(), state.stepIndex, state.choice); + instruction.content = + selectors.isAccountNameStep() && state.choice !== null + ? accountNamePrompt(state.choice) + : STEP_PROMPTS[active]; + paintSummary(); + paintList(); + paintLogin(); + const showInput = + !selectors.isListStep() && + !isLoginStep() && + !selectors.isOllamaModelStep() && + !state.submitting; + inputFrame.visible = showInput; + input.visible = showInput; + paintStatus(); + paintFooter(); + }; + + return { root, input, paint, paintStatus }; +} diff --git a/src/tui/provider/types.ts b/src/tui/provider/types.ts new file mode 100644 index 000000000..2d6ac9579 --- /dev/null +++ b/src/tui/provider/types.ts @@ -0,0 +1,267 @@ +/** + * Contract types shared across the provider setup split: the form/submit + * surface other modules (and tests) program against, plus the mutable state + * bag `runProviderSetup` threads through the paint, login, and discovery + * flows. Lives in its own leaf module because submit/connect must not import + * setup (that would be a cycle), yet need the same contracts setup defines. + */ + +import type { BoxRenderable, CliRenderer, InputRenderable } from "@opentui/core"; + +import type { FirstClassOAuthProvider } from "../../../packages/first-class-providers/src/index.js"; +import type { CodexTokens } from "../../auth/codex/store.js"; +import type { AuthProfile } from "../../auth/oauth/store.js"; +import type { XaiTokens } from "../../auth/xai/store.js"; +import type { + discoverOllamaModels as discoverOllamaModelsRequest, + OllamaDiscoveryState, +} from "../../provider/ollama.js"; +import type { prefetchGoModels as prefetchGoModelsRequest } from "../../provider/opencode-go-models.js"; +import type { ResidualCatalogEntry } from "../residuals.js"; +import type { OverlayList } from "../shell/internals.js"; +import type { SetupStep } from "./steps.js"; + +export type OAuthKind = FirstClassOAuthProvider; + +/** + * A selectable provider. Preset rows carry everything the settings write needs + * except the key; the custom row carries nothing and opens the manual form. + */ +export interface ProviderChoice { + readonly id: string; + readonly label: string; + readonly baseURL: string; + readonly models: readonly string[]; + readonly defaultModel: string; + readonly hint: string; + /** Anthropic Messages protocol rather than OpenAI-compatible chat. */ + readonly anthropic: boolean; + /** OpenCode Go subscription routing. */ + readonly opencodeGo: boolean; + readonly custom: boolean; + /** Browser sign-in flow to run instead of asking for a key. */ + readonly oauth: OAuthKind | null; +} + +export interface ProviderFormValues { + name: string; + baseURL: string; + apiKey: string; + model: string; + /** + * Pre-login / pre-key account slug for multi-instance paths (e.g. "personal"). + * Kept apart from `name`, which is only written once the account is settled + * and then carries the compound catalog name (`codex/personal`, + * `openai/work`) — reusing it for the slug would make the field mean two + * different things depending on where the operator is in the flow. Shared + * by OAuth and first-class API-key multi-instance connects; Custom still + * edits `name` free-form. + */ + oauthProfile: string; +} + +// "testing" covers the connection-check call against the entered credentials; +// "saving" covers the settings write that follows once the test succeeds. +export type SubmitPhase = "testing" | "saving"; + +export interface OAuthResult { + readonly kind: OAuthKind; + readonly tokens: CodexTokens | XaiTokens; + readonly commit: () => Promise; + /** Settings/catalog name the stored profile projects to. */ + readonly providerName: string; +} + +export interface ProviderPreset { + readonly id: string; + readonly models: readonly string[]; + readonly anthropic: boolean; + readonly opencodeGo: boolean; +} + +export interface SubmitOpts { + // True when the operator chose to save despite a failed connection test — + // some providers speak chat completions but not /models, so validation + // cannot be a hard gate. + readonly skipValidation: boolean; + /** + * Catalog metadata for the picked provider. Absent on the custom path. Lets + * the caller persist the full seeded model list and the protocol flags the + * four form values cannot express. + */ + readonly preset?: ProviderPreset; + /** Present when the operator exchanged OAuth credentials during setup. */ + readonly oauth?: OAuthResult; +} + +export type ProviderSetupSubmit = ( + values: ProviderFormValues, + setPhase: (phase: SubmitPhase) => void, + opts: SubmitOpts, +) => Promise; + +/** A login in flight: where to authorize, when it finished, how to abandon it. */ +export interface OAuthLoginStart { + readonly authorizeUrl: string; + readonly completed: Promise<{ + readonly profile: AuthProfile; + readonly commit: () => Promise; + }>; + readonly cancel: () => void; +} + +export type OAuthLoginStarter = (input: { + readonly kind: OAuthKind; + readonly profile: string; + readonly signal: AbortSignal; +}) => Promise; + +/** Fetches the names of already-authorized profiles for a provider kind. */ +export type OAuthProfileLister = (kind: OAuthKind) => Promise; + +export interface ProviderSetupConfig { + readonly onSubmit: ProviderSetupSubmit; + /** + * One-time telemetry disclosure. Shown here so a brand-new install sees it + * on the same launch the first telemetry event fires, not on a later run. + */ + readonly showTelemetryNotice: boolean; + /** Renderer factory override for headless mounting in tests. */ + readonly createRenderer?: () => Promise; + /** Login driver override so tests need neither a browser nor a port. */ + readonly startLogin?: OAuthLoginStarter; + /** Profile lister override so tests need no auth-store files on disk. */ + readonly listOAuthProfiles?: OAuthProfileLister; + /** Sign-in deadline override, in milliseconds. */ + readonly loginTimeoutMs?: number; + /** Ollama discovery override for deterministic setup tests. */ + readonly discoverOllamaModels?: typeof discoverOllamaModelsRequest; + /** Go catalog prefetch override so setup tests stay off the network. */ + readonly prefetchGoModels?: typeof prefetchGoModelsRequest; + /** + * Skip the provider pick-list and start directly on that provider's first + * form step (account name for multi-instance kinds, or the custom name + * field) — the inline connect path from the model picker's add-provider + * selector already knows which provider it wants. + */ + readonly initialProviderId?: string; + /** + * Catalog keys already present in global settings. Used by the API-key + * multi-instance name step for suggested slugs and collision confirms. + * OAuth still reads live profiles from the auth store. + */ + readonly existingProviderNames?: readonly string[]; +} + +/** + * Mutable state shared by the setup surface and its extracted flows. The + * fields are plain and reassigned in place because the original single-function + * implementation closed over them; the object form is what lets the paint, + * login, and discovery phases live in separate modules without changing the + * update semantics. + */ +export interface SetupState { + readonly config: ProviderSetupConfig; + readonly renderer: CliRenderer; + readonly externalRenderer: boolean; + readonly choices: readonly ProviderChoice[]; + readonly existingProviderNames: readonly string[]; + readonly values: ProviderFormValues; + readonly margin: number; + choice: ProviderChoice | null; + stepIndex: number; + // Set when the operator escapes the model pick-list into free text. + typedModel: boolean; + submitting: boolean; + submitPhase: SubmitPhase; + submitError: string | null; + saveAnywayOffered: boolean; + rampTimer: ReturnType | null; + readonly startLogin: OAuthLoginStarter; + readonly listOAuthProfiles: OAuthProfileLister; + readonly loginTimeoutMs: number; + loginStatus: "idle" | "pending" | "failed" | "done"; + loginURL: string | null; + loginError: string | null; + loginResult: OAuthResult | null; + loginAbort: AbortController | null; + loginHandle: OAuthLoginStart | null; + loginTimer: ReturnType | null; + // Carried back to the provider step so an abandoned sign-in says so there + // rather than dropping the operator on a silent list. + loginCancelled: boolean; + // Bumped on every start and every abandon, so a late resolution from a + // cancelled or superseded attempt can never move the screen. + loginAttempt: number; + // The OAuth "name" step's own state: an inline error from the last + // validation, and a pending re-authorize confirmation for a name that + // collided with an existing profile. `confirmedSlug` is the exact slug the + // confirmation applies to, so an edit to the field (which invalidates it) + // is detected by comparison rather than a separate dirty flag. + oauthProfileError: string | null; + oauthProfileConfirmPending: boolean; + confirmedSlug: string | null; + // Bumped whenever the name step is (re-)entered, so a profile-list fetch + // left over from a step the operator has since navigated away from can + // never write into the wrong step's state. + oauthNameAttempt: number; + readonly discoverOllamaModels: typeof discoverOllamaModelsRequest; + ollamaDiscovery: "idle" | "loading" | OllamaDiscoveryState; + ollamaDiscoveryAttempt: number; + ollamaDiscoveryAbort: AbortController | null; + readonly prefetchGoModels: typeof prefetchGoModelsRequest; + goPrefetchAttempt: number; + listRows: readonly ResidualCatalogEntry[]; + list: OverlayList; + settled: boolean; + resolveDone: (submitted: boolean) => void; +} + +/** Renderable tree plus the paint entry points the flows re-run on state change. */ +export interface Surface { + readonly root: BoxRenderable; + readonly input: InputRenderable; + paint(): void; + paintStatus(): void; +} + +export interface LoginFlow { + /** Start (or restart) the browser sign-in for the current OAuth choice. */ + beginLogin(): void; + /** Drop whatever sign-in attempt is in flight without moving the screen. */ + abandonLogin(): void; + /** Abandon an outstanding sign-in and return to the provider list. */ + cancelLogin(): void; +} + +export interface DiscoveryFlows { + beginOllamaDiscovery(): void; + abandonOllamaDiscovery(): void; + beginGoPrefetch(): void; + abandonGoPrefetch(): void; +} + +/** Multi-instance "name" step (OAuth accounts and API-key instances). */ +export interface AccountNameFlow { + /** Reset per-visit state and prefill a suggested, non-colliding slug. */ + enter(): void; + /** Validate the typed slug; a collision needs one more Enter to confirm. */ + advance(): void; +} + +/** Setup-surface step navigation the extracted flows call back into. */ +export interface SetupFlowHooks { + showStep(): void; + back(): void; + enterModelList(): void; +} + +/** Step predicates the surface paint and the extracted flows share. */ +export interface SetupSelectors { + steps(): readonly SetupStep[]; + currentStep(): SetupStep; + isOllamaModelStep(): boolean; + isListStep(): boolean; + isAccountNameStep(): boolean; + isGoModelListStep(): boolean; +} diff --git a/src/tui/queued-delivery-hop.test.ts b/src/tui/queued-delivery-hop.test.ts index bd59dd67d..ed00d195e 100644 --- a/src/tui/queued-delivery-hop.test.ts +++ b/src/tui/queued-delivery-hop.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from "bun:test"; import { attachSessionBridge, type SessionBridge } from "./runtime-bridge"; import { createLiveSessionPort } from "./live-session-port"; -import { createAppShell } from "./shell"; +import { createAppShell } from "./shell/index"; import { withTestRenderer } from "./harness"; import { createLiveSteerDeliver, routeQueuedDelivery } from "./queued-delivery.js"; import { createSessionOperationQueue } from "./session-operation-queue.js"; diff --git a/src/tui/ramp-paint.test.ts b/src/tui/ramp-paint.test.ts index 50e9abb0a..bf475f04e 100644 --- a/src/tui/ramp-paint.test.ts +++ b/src/tui/ramp-paint.test.ts @@ -11,7 +11,7 @@ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness"; import { RAMP_CYCLE_MS } from "./ramp"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; -import { createAppShell } from "./shell"; +import { createAppShell } from "./shell/index"; import { UI } from "./theme"; const BRAILLE = /[⠀-⣿]/; diff --git a/src/tui/reasoning-fold.test.ts b/src/tui/reasoning-fold.test.ts index 8d57aecb0..b8cd52e6b 100644 --- a/src/tui/reasoning-fold.test.ts +++ b/src/tui/reasoning-fold.test.ts @@ -11,7 +11,8 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge.js"; import { createHarness, type Harness } from "./harness.js"; -import { createAppShell, toggleCollapsedRow } from "./shell.js"; +import { toggleCollapsedRow } from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; import { isThinkingRow, rowGroupGap, type StreamRow } from "./stream.js"; type Bridge = ReturnType; diff --git a/src/tui/render-loop.test.ts b/src/tui/render-loop.test.ts index efe2c2341..7c04c2d3f 100644 --- a/src/tui/render-loop.test.ts +++ b/src/tui/render-loop.test.ts @@ -11,7 +11,8 @@ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness"; import { RAMP_CYCLE_MS, rampPulse } from "./ramp"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; -import { appendStreamRow, createAppShell } from "./shell"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; const SHELL_OPTS = { terminal: { columns: 80, rows: 24 }, diff --git a/src/tui/row-click.test.ts b/src/tui/row-click.test.ts index 50a9e8758..dad01f30a 100644 --- a/src/tui/row-click.test.ts +++ b/src/tui/row-click.test.ts @@ -8,7 +8,8 @@ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness"; -import { appendStreamRow, createAppShell } from "./shell"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; import { ROW_ARROW, type StreamRow } from "./stream"; const CALL: StreamRow = { diff --git a/src/tui/row-retext.test.ts b/src/tui/row-retext.test.ts new file mode 100644 index 000000000..722c78fab --- /dev/null +++ b/src/tui/row-retext.test.ts @@ -0,0 +1,75 @@ +/** + * In-place retext keeps a row's paint node when only its state flips — most + * importantly the gutter voice: a tool row that fails after being painted + * pending must dim its gutter on the same node, not keep the live bronze. + */ +import { describe, expect, test } from "bun:test"; +import { + BoxRenderable, + TextRenderable, + parseColor, + rgbToHex, + type ColorInput, +} from "@opentui/core"; +import { withTestRenderer } from "./harness"; +import { appendStreamRow, replaceStreamRowAt } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import { transcriptRowChildren } from "./shell/transcript"; +import { UI } from "./theme"; +import { pushToolCall, pushToolResult } from "./tool-rows"; +import type { StreamRow } from "./stream"; + +const SHELL_OPTS = { + terminal: { columns: 100, rows: 24 }, + wireKeys: false, + run: "idle", +} as const; + +const gutterOf = (node: unknown): TextRenderable => { + if (!(node instanceof BoxRenderable)) throw new Error("row node is not a wrapper"); + const [gutter] = node.getChildren(); + if (!(gutter instanceof TextRenderable)) throw new Error("first child is not the gutter"); + return gutter; +}; + +/** The node normalizes fg to an RGBA; compare it to the hex token exactly. */ +const fgIs = (gutter: TextRenderable, hex: string): boolean => + rgbToHex(parseColor(gutter.fg as ColorInput)) === hex; + +describe("retext gutter voice", () => { + test("a tool row that fails after being painted pending dims its gutter in place", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, SHELL_OPTS); + try { + const rows: StreamRow[] = []; + pushToolCall(rows, { + name: "fetch", + arguments: JSON.stringify({ url: "https://x.dev" }), + }); + const pending = rows[0]!; + expect(pending.pending).toBe(true); + appendStreamRow(shell, pending); + await h.renderOnce(); + const wrapper = transcriptRowChildren(shell)[0]; + const gutter = gutterOf(wrapper); + const fgToken = `${gutter.fg}`; + expect(fgToken).not.toBe(UI.textDim); + expect(fgIs(gutter, UI.textDim)).toBe(false); + + pushToolResult(rows, { name: "fetch", content: "", isError: true }); + const failed = rows[0]!; + expect(failed.failed).toBe(true); + replaceStreamRowAt(shell, 0, failed); + // Same paint node, same shape: the flip retexted rather than rebuilt. + expect(transcriptRowChildren(shell)[0]).toBe(wrapper); + expect(fgIs(gutter, UI.textDim)).toBe(true); + await h.renderOnce(); + } finally { + shell.dispose(); + } + }, + { width: 100, height: 24 }, + ); + }); +}); diff --git a/src/tui/row-update-perf.test.ts b/src/tui/row-update-perf.test.ts new file mode 100644 index 000000000..59aa87ce4 --- /dev/null +++ b/src/tui/row-update-perf.test.ts @@ -0,0 +1,310 @@ +/** + * Perf gate for CL-6791 P5-J3: non-markdown rows must update in place or at + * frame cadence — N updates to a row within one frame apply at most once, and + * no update destroys and rebuilds the row's paint subtree. + */ +import { describe, expect, test } from "bun:test"; +import { + attachSessionBridge, + createRecordingPort, + type TaskProgressSession, +} from "./runtime-bridge"; +import { createAppShell } from "./shell/index"; +import { transcriptRowChildren, streamRowCount, streamRowAt } from "./shell/transcript"; +import { toolResultRow } from "./mcp-view"; +import { withTestRenderer } from "./harness"; +import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js"; +import type { AppShell } from "./shell/internals.js"; +import type { StreamRow } from "./stream.js"; + +type ChromeModule = typeof import("./shell/chrome.js"); +type TeardownModule = typeof import("./teardown.js"); + +interface WorkCounts { + destroys: number; + builds: number; + replaces: number; +} + +/** + * Count real work, not calls: subtree destroys (teardown), node rebuilds + * (createStreamRowRenderable) and row retexts (replaceStreamRowAt) — the + * seams a destroy-rebuild would have to pass through. + */ +async function withCountedWork(run: (work: WorkCounts) => Promise): Promise { + const work: WorkCounts = { destroys: 0, builds: 0, replaces: 0 }; + return withMockedModuleDuring( + import.meta.resolve("./teardown.js"), + (real) => ({ + ...real, + destroySubtree: (node: unknown) => { + work.destroys++; + real.destroySubtree(node); + }, + }), + () => + withMockedModuleDuring( + import.meta.resolve("./shell/chrome.js"), + (real) => ({ + ...real, + replaceStreamRowAt: (shell: AppShell, index: number, row: StreamRow) => { + work.replaces++; + real.replaceStreamRowAt(shell, index, row); + }, + createStreamRowRenderable: ( + ...args: Parameters + ) => { + work.builds++; + return real.createStreamRowRenderable(...args); + }, + }), + () => run(work), + ), + ); +} + +const SHELL_OPTS = { + terminal: { columns: 100, rows: 24 }, + wireKeys: false, + run: "idle", +} as const; + +function taskSession(over: Partial): TaskProgressSession { + return { + id: "task-1", + status: "running", + currentToolName: "grep", + currentToolPreview: null, + currentToolStartedAt: null, + startedAt: 0, + lastActivityAt: 0, + ...over, + }; +} + +describe("row update perf gates (J3)", () => { + test("N diff-row updates within one frame apply once, with no destroy or rebuild", async () => { + await withCountedWork(async (work) => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, SHELL_OPTS); + const bridge = attachSessionBridge(shell, createRecordingPort()); + try { + bridge.handle({ type: "inference.start", data: {} }); + const arguments_ = JSON.stringify({ + path: "src/a.ts", + oldText: "x", + newText: "y", + }); + for (let i = 0; i < 5; i++) { + bridge.handle({ + type: "inference.tool_call.end", + data: { name: "edit_file", callId: `c${i}`, arguments: arguments_ }, + }); + } + // The first call appends; the four repeats only fold into the + // pending snapshot — nothing has repainted yet. + expect(streamRowCount(shell)).toBe(1); + expect(work.replaces).toBe(0); + const destroysBeforeFrame = work.destroys; + await h.renderOnce(); + expect(work.replaces).toBe(1); + // The coalesced repeat changes the row's shape (a run header, no + // diff body), so the single frame-time application may rebuild — + // but only once, never once per repeat. + expect(work.destroys - destroysBeforeFrame).toBeLessThanOrEqual(1); + const row = streamRowAt(shell, 0); + expect(row?.coalesced).toBe(true); + expect(row?.outstanding).toBe(5); + // An idle frame applies nothing further. + await h.renderOnce(); + expect(work.replaces).toBe(1); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 100, height: 24 }, + ); + }); + }); + + test("tool elapsed ticks: unchanged clock applies nothing, changed clock once per frame", async () => { + await withCountedWork(async (work) => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, SHELL_OPTS); + let nowMs = 1_000; + let tick: (() => void) | undefined; + const bridge = attachSessionBridge(shell, createRecordingPort(), { + now: () => nowMs, + schedule: (fn: () => void) => { + tick = fn; + return () => {}; + }, + }); + try { + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.tool_call.end", + data: { name: "bash", callId: "b1", arguments: { command: "sleep 5" } }, + }); + await h.renderOnce(); + const baseline = work.replaces; + const destroys = work.destroys; + + tick?.(); + await h.renderOnce(); + // The first tick changes the row (no stat yet -> "0:00"). + expect(work.replaces).toBe(baseline + 1); + expect(work.destroys).toBe(destroys); + + // Ticks within the same clock second leave the stat unchanged: + // zero updates across any number of them. + for (let i = 0; i < 4; i++) tick?.(); + await h.renderOnce(); + expect(work.replaces).toBe(baseline + 1); + + nowMs += 5_000; + for (let i = 0; i < 3; i++) tick?.(); + await h.renderOnce(); + expect(work.replaces).toBe(baseline + 2); + expect(work.destroys).toBe(destroys); + expect(streamRowAt(shell, 0)?.stat).toBe("0:05"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 100, height: 24 }, + ); + }); + }); + + test("N sentence-row progress updates within one frame apply once, with no destroy or rebuild", async () => { + await withCountedWork(async (work) => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, SHELL_OPTS); + const nowMs = 42_000; + const bridge = attachSessionBridge(shell, createRecordingPort(), { + now: () => nowMs, + }); + try { + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.tool_call.end", + data: { + name: "spawn_agent", + callId: "task-1", + arguments: { description: "Review permission gate" }, + }, + }); + await h.renderOnce(); + const baseline = work.replaces; + const destroys = work.destroys; + + for (let i = 0; i < 4; i++) { + bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]); + } + await h.renderOnce(); + expect(work.replaces).toBe(baseline + 1); + expect(work.destroys).toBe(destroys); + const row = streamRowAt(shell, 0); + expect(row?.pending).toBe(true); + expect(row?.stat).toContain("grep"); + + // Unchanged progress applies nothing further. + bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]); + await h.renderOnce(); + expect(work.replaces).toBe(baseline + 1); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 100, height: 24 }, + ); + }); + }); + + test("diff rows retext their lines in place when the shape is unchanged", async () => { + await withCountedWork(async (work) => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, SHELL_OPTS); + const { toolCallRow } = await import("./diff.js"); + const { appendStreamRow, replaceStreamRowAt } = await import("./shell/chrome.js"); + try { + const diffRow = (newText: string): StreamRow => ({ + ...toolCallRow({ + name: "edit_file", + arguments: JSON.stringify({ path: "src/a.ts", oldText: "x", newText }), + }), + expanded: true, + }); + appendStreamRow(shell, diffRow("y")); + await h.renderOnce(); + const node = transcriptRowChildren(shell)[0]; + const builds = work.builds; + const destroys = work.destroys; + + for (let i = 0; i < 4; i++) { + replaceStreamRowAt(shell, 0, diffRow(`z${i}`)); + } + expect(work.destroys).toBe(destroys); + expect(work.builds).toBe(builds); + expect(transcriptRowChildren(shell)[0]).toBe(node); + await h.renderOnce(); + expect(h.captureCharFrame()).toContain("z3"); + } finally { + shell.dispose(); + } + }, + { width: 100, height: 24 }, + ); + }); + }); + + test("structured rows retext their table in place instead of rebuilding", async () => { + await withCountedWork(async (work) => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, SHELL_OPTS); + const LIST_1 = JSON.stringify({ + projects: [ + { name: "Alpha", status: "In Progress", priority: "urgent" }, + { name: "Beta", status: { name: "Done" }, priority: "low" }, + ], + }); + try { + const { appendStreamRow, replaceStreamRowAt } = await import("./shell/chrome.js"); + const expandedRow = (content: string): StreamRow => ({ + ...toolResultRow({ name: "mcp__linear__list_projects", content }), + expanded: true, + }); + appendStreamRow(shell, expandedRow(LIST_1)); + await h.renderOnce(); + const node = transcriptRowChildren(shell)[0]; + const builds = work.builds; + const destroys = work.destroys; + + for (let i = 0; i < 5; i++) { + replaceStreamRowAt(shell, 0, expandedRow(LIST_1.replace("Alpha", `Alpha ${i}`))); + } + // Five updates, zero destroy-rebuilds: the same paint node + // carries the new content, and only its cells changed. + expect(work.destroys).toBe(destroys); + expect(work.builds).toBe(builds); + expect(transcriptRowChildren(shell)[0]).toBe(node); + await h.renderOnce(); + expect(h.captureCharFrame()).toContain("Alpha 4"); + } finally { + shell.dispose(); + } + }, + { width: 100, height: 24 }, + ); + }); + }); +}); diff --git a/src/tui/row-update-queue.ts b/src/tui/row-update-queue.ts new file mode 100644 index 000000000..5c0dbb2fe --- /dev/null +++ b/src/tui/row-update-queue.ts @@ -0,0 +1,52 @@ +/** + * Frame-coalesced tool-row updates (CL-6791 J3): the high-frequency row + * repaints — elapsed clocks, agent progress, repeat-call coalescing — + * accumulate here and apply once per renderer frame through the same flush + * seam as the open streaming row (J1), instead of repainting the row per + * event. Immediate seams (a result merging into its call) take the pending + * row back out so they read and write the freshest state. + */ +import { replaceStreamRowAt } from "./shell/chrome.js"; +import { streamRowAt } from "./shell/transcript.js"; +import type { AppShell } from "./shell/internals.js"; +import type { StreamRow } from "./stream.js"; +import type { BridgeBag } from "./runtime-bridge.js"; + +/** Accumulated repaints by absolute row index; the latest snapshot wins. */ +export type PendingRowUpdates = Map; + +export function scheduleRowUpdate(bag: BridgeBag, index: number, row: StreamRow): void { + bag.pendingRowUpdates.set(index, row); +} + +/** The freshest row an immediate seam should read for `index`, if any. */ +export function takePendingRowUpdate(bag: BridgeBag, index: number): StreamRow | undefined { + const row = bag.pendingRowUpdates.get(index); + bag.pendingRowUpdates.delete(index); + return row; +} + +/** Drop updates a rollback truncated out of the log. */ +export function dropPendingRowUpdatesFrom(bag: BridgeBag, boundary: number): void { + for (const index of bag.pendingRowUpdates.keys()) { + if (index >= boundary) bag.pendingRowUpdates.delete(index); + } +} + +/** Apply every accumulated row repaint; called once per renderer frame. */ +export function applyPendingRowUpdates(shell: AppShell, bag: BridgeBag): void { + if (bag.pendingRowUpdates.size === 0) return; + const entries = [...bag.pendingRowUpdates]; + bag.pendingRowUpdates.clear(); + for (const [index, pending] of entries) { + const live = streamRowAt(shell, index); + // Evicted by the retention cap or truncated: nothing left to repaint. + if (live === undefined) continue; + // An expand/collapse toggle between schedule and flush owns the flag. + const row = + live.expanded !== undefined && live.expanded !== pending.expanded + ? { ...pending, expanded: live.expanded } + : pending; + replaceStreamRowAt(shell, index, row); + } +} diff --git a/src/tui/run-snapshot-kind.test.ts b/src/tui/run-snapshot-kind.test.ts index 74645378c..486db80c7 100644 --- a/src/tui/run-snapshot-kind.test.ts +++ b/src/tui/run-snapshot-kind.test.ts @@ -6,7 +6,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { clearActiveRun, getActiveRun, setActiveRun } from "../session/active-run.js"; import { finalizeRunState, loadState, saveState, type RunState } from "../session/state.js"; -import { clearsActiveRun, type SnapshotKind } from "./runner.js"; +import { clearsActiveRun } from "./runner/exit.js"; +import type { SnapshotKind } from "./runner/state.js"; describe("clearsActiveRun", () => { test("only the run-ending write clears the active-run handle", () => { diff --git a/src/tui/runner-exit-code.test.ts b/src/tui/runner-exit-code.test.ts index c78d267af..8d4860f0c 100644 --- a/src/tui/runner-exit-code.test.ts +++ b/src/tui/runner-exit-code.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "bun:test"; import { resolveLocalSettingsPath } from "../config/settings.js"; -import { resolveExitCode } from "./runner.js"; +import { resolveExitCode } from "./runner/exit.js"; describe("resolveExitCode", () => { test("returns 0 when run completes successfully with no errors", () => { diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index 0572da1cf..fd17b851e 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -6,18 +6,14 @@ import type { KeyEvent } from "@opentui/core"; import type { CostSummary } from "../cost/cost-summary.js"; import type { SubAgentSession } from "../subagent/session-store.js"; import { createHarness } from "./harness.js"; -import { - acceptOverlaySelection, - closeInsetOverlay, - moveOverlaySelection, - resolvePaletteCatalog, - runOverlayAction, -} from "./shell.js"; +import { acceptOverlaySelection, closeInsetOverlay } from "./shell/overlay-host.js"; +import { moveOverlaySelection, runOverlayAction } from "./shell/overlay-list.js"; +import { resolvePaletteCatalog } from "./shell/palette.js"; import { mountRunnerHost, observeSessionFromSubAgents, rowFromTranscriptEntry, -} from "./runner-host.js"; +} from "./runner/host.js"; /** The bottom rule holds StyledText; join its chunks for assertions. */ function ruleOf(rule: { content: unknown }): string { diff --git a/src/tui/runner.ts b/src/tui/runner.ts deleted file mode 100644 index a27415854..000000000 --- a/src/tui/runner.ts +++ /dev/null @@ -1,2747 +0,0 @@ -import { join } from "node:path"; -import { homedir } from "node:os"; -import { readFile } from "node:fs/promises"; -import { EventEmitter } from "node:events"; -import { AgentContextLockError, type Agent } from "@intx/agent"; -import { getLogger } from "@intx/log"; -import { loadRecentTurns } from "../session/optimized-context-store.js"; -import { refreshLiveProviderCatalog, resolveMcpServers, type Config } from "../config/index.js"; -import { - globalSettingsPath, - loadLocalSettings, - listFavoriteModels, - listRecentModels, - loadSettings, - localSettingsPath, - resolveLocalSettingsPath, - markTelemetryNoticeShown, - persistSkipPermissionsDefault, - pushRecentModel, - shellTimeoutFromSettings, - toolWatchdogFromSettings, - markLastChangelogVersion, - toggleFavoriteModel, - setDefaultModel, - isExaMCPPreset, - type ModelRef, - type ResolvedProvider, - type Settings, - type LocalSettings, - type PluginConfig, - type MCPServerConfig, - type MCPServerSettingsEntry, -} from "../config/settings.js"; -import { addProviderSelectorChoices, providerChoices } from "./provider-setup.js"; -import { persistConnectedSelection } from "./provider-setup-submit.js"; -import { connectProviderInline } from "./provider-connect.js"; -import { modelOptionId } from "./model-catalog.js"; -import { resolveWaitForApproval, type ToolWatchdogConfig } from "./tool-execution-watchdog.js"; -import { attachApprovalBudget, createGateRequestApproval } from "./request-approval.js"; -import { codexProfileFromProviderName, isCodexProviderName } from "../config/codex-providers.js"; -import { - createGlobalSettingsWriter, - createLocalSettingsWriter, - persistGlobalHTTPMCPServer, - persistLocalMCPServerEnabled, - persistLocalMCPServerRemoved, - persistMCPServerEnabled, - persistMCPServerRemoved, - validateMCPServerName, - type PersistMCPServerListResult, -} from "../mcp/add-server.js"; -import { createExaMCPServerConfig, EXA_MCP_SERVER_NAME } from "../mcp/exa.js"; -import { xaiProfileFromProviderName } from "../config/xai-providers.js"; -import type { PluginDescriptor } from "../plugins/admin.js"; -import { cycleReasoningEffort, resolveSessionEffort } from "../provider/reasoning-effort.js"; -import { prefetchGoModels } from "../provider/opencode-go-models.js"; -import { isOpenCodeGoProvider } from "../../packages/opencode-go/src/index.js"; -import { getValidCodexToken } from "../auth/codex/session.js"; -import { getValidXaiToken } from "../auth/xai/session.js"; -import { - createPluginLoadDiagnostics, - emitPluginWarningLog, - warningsForPluginEntry, -} from "../plugins/diagnostics.js"; -import { registerCommandPlugins, registerWorkflowPlugins } from "../plugins/register.js"; -import { - getCommand, - listCommands, - setHiddenCommands, - type CommandContext, - type CommandResult, -} from "./commands/registry.js"; -import { registerBuiltInCommands } from "./commands/built-in.js"; -import type { PluginModule } from "../plugins/loader.js"; -import { - armFeedbackCapture, - cancelFeedbackCapture, - captureFeedback, - feedbackResultMessage, - getLastTurnTraceId, - isFeedbackCapturePending, - takeFeedbackCapture, -} from "../telemetry/feedback.js"; -import { activateHeldTelemetry, telemetryFirstRunPending } from "../telemetry/first-run.js"; -import { TELEMETRY_NOTICE } from "../telemetry/index.js"; -import { captureSlashCommand } from "../telemetry/product-events.js"; -import { getTelemetry, liveTelemetry } from "../telemetry/singleton.js"; -import { createTelemetryToggleHandler } from "../telemetry/toggle.js"; -import { isPluginEnabledForSurface } from "./plugin-surface.js"; - -import { loadStartupChangelogMarkdown, stampVersionAfterStartup } from "../changelog/index.js"; -import { scheduleUpgradeNotice } from "../upgrade/index.js"; -import pkg from "../../package.json" with { type: "json" }; -import { getActivePricingCache } from "../cost/cost-visibility.js"; -import { formatCost } from "../cost/faremeter.js"; -import { billingIdentityFromSource, createSessionCostAccumulator } from "../cost/session-cost.js"; -import { - buildCostSummary, - maskContextMeterWhenNoTurns, - type CostSummary, -} from "../cost/cost-summary.js"; -import { contextTokensFromUsage } from "../provider/context-window.js"; -import { type ToolAvailability } from "../agent/tool-search.js"; -import { detectLanguageServerAvailable } from "../agent/lsp-availability.js"; -import { type SessionMode } from "../config/session-mode.js"; -import { - createFleetWatch, - createSubAgentSessionStore, - fleetDigest, - FLEET_REPORT_SETTLE_MS, - FLEET_STALL_POLL_MS, - liveFleetCount, - observeFleet, -} from "../subagent/index.js"; -import { getProcessAdmissionQueue } from "../subagent/admission.js"; -import type { ContextStore, InferenceSource, InboundMessage } from "@intx/types/runtime"; -import { OPERATOR_ORIGINATED_FLAG } from "../agent/message-provenance.js"; -import { createSessionOperationQueue } from "./session-operation-queue.js"; -import { - createProviderFailureAttemptTracker, - suppressProviderFailurePresentation, - type ProviderFailureAttempt, -} from "./provider-failure-attempt.js"; -import { setAgentSourceUnlessClosed } from "./agent-source-sync.js"; -import { createChatDirector, hydrateTasksFromTurns } from "../agent/director.js"; -import { onTurnBoundary } from "../agent/reactor-events.js"; -import { loadAgentProfiles } from "../agent/profiles.js"; -import { resolveAgentPluginProfiles } from "../plugins/agent-plugins.js"; -import { createPermissionsAdmin, type ScopedApproval } from "../permission/admin.js"; -import type { GrantScope } from "../permission/types.js"; - -import { - createAgentToolset, - type MCPConnectCallbacks, - type MCPServerState, - type OperatorResult, -} from "../agent/tools.js"; -import { - collectWebPlugins, - resolveWebProviderFromPlugins, - webBrand, -} from "../web/plugin-provider.js"; -import { collectToolPlugins, resolveToolPlugins } from "../plugins/tool-plugins.js"; -import { setActiveWebProviderBrand } from "./tool-formatter.js"; -import { consumeStream } from "../session/stream-consumer.js"; -import { mountRunnerHost } from "./runner-host.js"; -import { mergeMcpSurfaceEntries, isBuiltinRow } from "./mcp-list.js"; -import { nextMcpCatalog } from "./mcp-catalog.js"; -import { - createDeliveryGeneration, - createLeftoverSend, - createLiveSteerDeliver, - routeQueuedDelivery, -} from "./queued-delivery.js"; -import { createRuntimeShutdown } from "./runtime-shutdown.js"; -import { - applyFocus, - attachClipboardImage, - setEffortCycleHandler, - setMentionSuggestionSource, - setPluginNeedsAttention, - setPromptModelLabel, - setPromptRecognitionSource, - setSentMessageHistory, - setShellInputSuspended, - setShellRunState, - setStatusFlash, - surfaceSystemNotice, -} from "./shell.js"; -import { RUNTIME_FLASH_MS } from "./runtime-notices.js"; -import { - captureAuthFailure, - classifyAgentSendFailure, - shouldSettleUiAfterSendFailure, -} from "./session-chrome.js"; -import { ingestOperatorPrompt } from "./prompt-attachments.js"; -import { - CREDENTIAL_FAILURE_USER_MESSAGE, - isResolvedProviderFailureError, - terminalProviderFailureMessage, -} from "../inference-error-message.js"; -import { - normalizeInferenceErrorForTerminal, - type InferenceErrorLike, -} from "../inference-gateway-error.js"; -import { listPathSuggestions } from "./components/at-mention/list.js"; -import { imageAttachmentFromPath, type PendingImageAttachment } from "./image-attachments.js"; -import { appendSentMessage, loadSentMessages } from "../session/sent-messages.js"; -import type { OperatorGateEvent } from "./gate-events.js"; -import { createRunSummary, type RunSummary } from "../session/hooks.js"; -import { - generateSessionId, - initSessionDir, - renameSession, - sessionContextDir, - sessionDir, -} from "../session/index.js"; -import { resolveSessionLabel, truncateSessionLabel } from "../session/session-label.js"; -import { - finalizeRunState, - saveState, - type ConnectedMcpServer, - type RunState, -} from "../session/state.js"; -import { setActiveDisposeHost, clearActiveDisposeHost } from "../session/active-host.js"; -import { openInBrowser } from "../auth/oauth/browser.js"; -import { RESUME_TRANSCRIPT_BLOCK_LIMIT, turnsToContentBlocks } from "./turns-to-blocks.js"; -import { WorkflowController } from "./workflow-controller.js"; -import { - assembleChatAgent, - assembleSessionGate, - assembleSessionLifecycle, - createAdvertisedToolset, - loadSessionLocalSettings, - resolveLiveSessionSources, - type LiveSessionSources, -} from "../session/assemble-runtime.js"; -import { createApprovalResume } from "../session/approval-resume.js"; -import { createReactorAuthorize } from "../permission/reactor-authorize.js"; -import { - buildCompactionContinuationMessage, - createLiveSubAgentSources, - createSessionPruningCompactor, - loadSessionChatPrompt, - skillDirsFromEnabledPlugins, -} from "../session/runtime-assembly.js"; -import { applyLiveModelSwitch } from "../session/live-model-switch.js"; -import { createModelSummarizer, type SummaryContext } from "../session/summarizer.js"; -import { ID_PREFIX, LOG_NAMESPACE_ROOT, SETTINGS_DIR_NAME } from "../branding.js"; -import { deliverAgentMessage } from "./deliver-agent-message.js"; -import { prepareTUISession } from "./session-start.js"; -import { - buildPluginDescriptor, - createPluginsAdmin, - createPluginsAdminState, -} from "./plugins-admin-backend.js"; - -const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); - -export function createTUIEventEmitter(): EventEmitter { - return new EventEmitter(); -} - -export { getTUIRunSummaryStatus } from "../session/run-sink.js"; - -export interface ResolveExitCodeArgs { - runError: string | undefined; - sinkError: string | undefined; - status: RunSummary["status"]; -} - -export function resolveExitCode(args: ResolveExitCodeArgs): number { - const { runError, sinkError, status } = args; - if (runError !== undefined || sinkError !== undefined || status !== "done") { - return 1; - } - return 0; -} - -/** One-line transcript block when resume history fails to load. */ -export function resumeTranscriptLoadErrorBlock(err: unknown): { - type: "error"; - message: string; -} { - const message = err instanceof Error ? err.message : String(err); - return { type: "error", message: `Could not load prior session transcript: ${message}` }; -} - -// The agent package releases its workdir lock at the very end of close(), -// after reactor.abort()/sendQueue.drain() and the shutdown-complete race have -// all run. If any of that throws (most likely right when an operator -// interrupts mid-inference, which is exactly when those paths are under -// stress), the lock is never released — and because the agent is already -// marked closed internally, retrying close() is a silent no-op that can -// never release it either. Every rebuild site that reuses the *same* workdir -// (interrupt, reloadIfIdle) must treat that as fatal for the current rebuild -// instead of calling buildAgent() again: a second createAgent() for the same -// workdir is then guaranteed to throw AgentContextLockError for a lock -// nothing will ever free, which is the "agent already open" crash. Session -// rotation (newSession) is the one rebuild site that does NOT route through -// this helper: it always points buildAgent() at a freshly minted workdir -// before rebuilding, so a leaked lock on the old workdir can never be -// re-acquired there — see the comment at its close() call for why. -export async function closeAgentForRebuild(agent: Agent, context: string): Promise { - try { - await agent.close(); - return true; - } catch (err) { - tuiLogger.debug(`agent.close during ${context} teardown failed: {error}`, { - error: err instanceof Error ? err.message : String(err), - }); - return false; - } -} - -// Every rebuild site funnels its failure (a lock left held by a failed -// close, or any other buildAgent failure) through here so it surfaces as a -// plain-language, caught error rather than an unhandled rejection. -export function agentRebuildFailure(err: unknown): Error { - return err instanceof AgentContextLockError - ? new Error( - "Could not start a new agent: the previous one did not shut down cleanly. Restart Corbits to continue.", - ) - : err instanceof Error - ? err - : new Error(String(err)); -} - -/** - * Why a run.json snapshot is being written. Only "run-end" ends the run - * itself and so clears the active-run handle that the crash handler in - * index.ts reads. - * - * RunState.status cannot stand in for this. A /clear or /new rotation - * persists a terminal "done" for the outgoing session while the process - * keeps running under a fresh session id, so inferring "the run is over" - * from a non-"running" status disarms crash finalization for everything - * after the first rotation -- the session that dies then never gets its - * terminal record and reads as "running" forever. - */ -export type SnapshotKind = "progress" | "session-rotation" | "run-end"; - -export function clearsActiveRun(kind: SnapshotKind): boolean { - return kind === "run-end"; -} - -const GRANT_SCOPE_LABEL: Record = { - session: "This session", - project: "This project", - global: "Global", - "provider-model": "Provider / model", -}; - -/** - * Resolve the base for a local-settings read-modify-write. - * Absent file → empty object; unreadable/invalid → null (caller must skip write). - */ -export async function loadLocalSettingsWriteBase( - path: string, - load: (path: string) => Promise = loadLocalSettings, -): Promise { - try { - return (await load(path)) ?? {}; - } catch { - return null; - } -} - -/** - * Populate the slash-command registry for a session: built-ins first, then - * enabled plugin commands and workflows, then the hidden-command filter. - * - * Exported so the production wiring is testable — built-in registration used to - * ride on an import side effect and silently disappeared when its only importer - * was deleted. - */ -export type SubmissionRoute = - | { kind: "empty" } - | { kind: "command"; name: string; args: string } - | { kind: "prompt"; text: string }; - -/** - * Decide what a submitted composer line is. A leading `/` means a slash command - * — it must never reach the model as a prompt, whether it was typed directly or - * picked from the palette. - */ -export function routeSubmission(raw: string): SubmissionRoute { - const trimmed = raw.trim(); - if (trimmed.length === 0) return { kind: "empty" }; - const body = trimmed.startsWith("/") ? trimmed.slice(1).trim() : trimmed; - if (!trimmed.startsWith("/")) return { kind: "prompt", text: trimmed }; - if (body.length === 0) return { kind: "empty" }; - const sep = body.search(/\s/); - return sep === -1 - ? { kind: "command", name: body, args: "" } - : { kind: "command", name: body.slice(0, sep), args: body.slice(sep + 1).trim() }; -} - -export interface SubmitHandlerDeps { - dispatchCommand: (name: string, args: string) => void; - sendPrompt: (text: string, attachments?: readonly PendingImageAttachment[]) => void; - /** Consent-by-proceeding hook: runs only for real prompts, never commands. */ - onPromptSubmitted?: () => void; - /** - * When true, the next non-command submit is treated as intentional feedback - * text (bare `/feedback` multi-turn mode) instead of a model prompt. - */ - isFeedbackCapturePending?: () => boolean; - /** Consume the pending feedback arm and handle the text; return operator message. */ - onFeedbackText?: (text: string) => string; - /** Drop a pending multi-turn /feedback arm (empty Enter cancel). */ - cancelFeedbackCapture?: () => void; - /** Surface a local system notice (feedback thanks / blocked / cancelled). */ - onSystemNotice?: (text: string) => void; -} - -/** - * Composer submit handler. Slash input is dispatched against the command - * registry instead of being sent to the model. When feedback capture is armed - * (bare `/feedback`), the next non-command line is captured as survey text. - * - * Returns an outcome so the session bridge can keep local-only submits off the - * agent busy path and out of the mid-run queue. - */ -export type SubmitOutcome = "agent" | "local" | "empty"; - -/** - * Classify a composer line without side effects. Local = slash command or - * armed multi-turn feedback text; empty = no-op (or cancel-feedback); agent = - * real model turn. - */ -export function classifySubmission( - text: string, - options: { - hasAttachments?: boolean; - feedbackPending?: boolean; - feedbackCaptureEnabled?: boolean; - } = {}, -): SubmitOutcome { - const route = routeSubmission(text); - const hasAttachments = options.hasAttachments === true; - if (route.kind === "empty" && !hasAttachments) return "empty"; - if (route.kind === "command") return "local"; - if ( - route.kind === "prompt" && - options.feedbackPending === true && - options.feedbackCaptureEnabled === true - ) { - return "local"; - } - return "agent"; -} - -export function createSubmitHandler( - deps: SubmitHandlerDeps, -): (text: string, attachments?: readonly PendingImageAttachment[]) => SubmitOutcome { - return (text, attachments) => { - const route = routeSubmission(text); - const hasAttachments = attachments !== undefined && attachments.length > 0; - const feedbackPending = deps.isFeedbackCapturePending?.() === true; - const feedbackCaptureEnabled = deps.onFeedbackText !== undefined; - const outcome = classifySubmission(text, { - hasAttachments, - feedbackPending, - feedbackCaptureEnabled, - }); - - // Empty Enter while /feedback is armed cancels instead of trapping the - // operator until they type free text or /clear. - if (outcome === "empty") { - if (feedbackPending) { - deps.cancelFeedbackCapture?.(); - deps.onSystemNotice?.("Feedback cancelled."); - } - return "empty"; - } - if (route.kind === "command") { - // Any other slash command drops a bare-/feedback arm so the next - // free-text line is not mis-routed as survey text. - if (feedbackPending && route.name !== "feedback") { - deps.cancelFeedbackCapture?.(); - } - deps.dispatchCommand(route.name, route.args); - return "local"; - } - // Multi-turn /feedback: next Enter is survey text, not a model prompt. - if (outcome === "local" && deps.onFeedbackText !== undefined) { - const notice = deps.onFeedbackText(route.kind === "prompt" ? route.text : text); - deps.onSystemNotice?.(notice); - return "local"; - } - deps.onPromptSubmitted?.(); - deps.sendPrompt(route.kind === "prompt" ? route.text : "", attachments); - return "agent"; - }; -} - -/** Text sent alongside an image when the operator attached one without a prompt. */ -export const IMAGE_ONLY_PROMPT = "Please inspect the attached image."; - -/** - * Build the inbound message for a genuine operator submit — the real - * prompt-submit path in the TUI (sendUserPrompt / the "send" command - * result), with or without attachments. Carries OPERATOR_ORIGINATED_FLAG so - * director.ts's loop-protection backstop can tell this apart from - * system-originated sends (compaction continuations, retries, nudges). - */ -export function userInboundMessage( - text: string, - attachments: readonly PendingImageAttachment[], -): InboundMessage { - return { - ref: { uid: 1, mailbox: "INBOX" }, - headers: { - from: "user@local", - to: ["agent@local"], - date: new Date().toISOString(), - messageId: `<${crypto.randomUUID()}@local>`, - interchangeType: "conversation.message", - }, - flags: [OPERATOR_ORIGINATED_FLAG], - signatureStatus: "missing", - content: text.length > 0 ? text : IMAGE_ONLY_PROMPT, - attachments: attachments.map((a) => ({ - name: a.name, - contentType: a.contentType, - data: a.data, - })), - }; -} - -/** First-run telemetry disclosure to show before consent-by-proceeding applies. */ -export function telemetryStartupNotice( - globalSettings: Settings | null | undefined, - env: NodeJS.ProcessEnv = process.env, -): string | undefined { - return telemetryFirstRunPending(globalSettings, env) ? TELEMETRY_NOTICE : undefined; -} - -export function setUpCommandRegistry( - settings: Settings | undefined, - plugins: PluginModule[], - getPluginConfig: () => Record = () => settings?.plugins ?? {}, -): void { - registerBuiltInCommands(); - registerWorkflowPlugins(plugins, getPluginConfig()); - registerCommandPlugins(plugins, getPluginConfig); - setHiddenCommands(settings?.hiddenCommands ?? []); -} - -export function surfaceTerminalProviderFailure( - shell: Parameters[0], - providerId: string, - error: InferenceErrorLike, - displayLabel?: string, -): void { - surfaceSystemNotice(shell, terminalProviderFailureMessage(providerId, error, displayLabel)); -} - -export interface InferenceAttemptIdentity { - providerId: string; - displayLabel?: string; -} - -export function tuiSendFailureMessage( - error: unknown, - failureKind: "auth" | "error", - providerFailureObserved: boolean, - attempt: InferenceAttemptIdentity, - providerError?: InferenceErrorLike, -): string { - if (failureKind === "auth") { - return CREDENTIAL_FAILURE_USER_MESSAGE; - } - if (!providerFailureObserved && !isResolvedProviderFailureError(error)) { - return error instanceof Error ? error.message : String(error); - } - const providerId = - providerError?.providerId ?? - (isResolvedProviderFailureError(error) ? error.providerId : attempt.providerId); - const displayLabel = providerId === attempt.providerId ? attempt.displayLabel : undefined; - if (providerError === undefined && isResolvedProviderFailureError(error)) return error.message; - const diagnostic = providerError ?? { - category: "fatal", - message: error instanceof Error ? error.message : String(error), - }; - return terminalProviderFailureMessage(providerId, diagnostic, displayLabel); -} - -export async function runTUI(initialConfig: Config): Promise { - const start = await prepareTUISession(initialConfig, liveTelemetry); - if (start === null) return 0; - let config = start.config; - const { inferenceDeps, trust: sessionTrust, pluginLoadDiag } = start; - - const { pluginModules } = sessionTrust; - // /plugins UI backend state: discovered modules plus live, persisted config - // (enabled flag, credentials, web override, extra paths). Trust grants swap - // metadata-only stubs for full loads without restarting the process. - const pluginState = createPluginsAdminState({ - cwd: config.cwd, - settings: config.settings, - modules: pluginModules, - pathTrust: sessionTrust.pathTrust, - projectTrust: sessionTrust.projectTrust, - }); - emitPluginWarningLog(pluginLoadDiag); - // Fire-and-forget startup diagnostics (this + tool-plugin / profile resolution - // below) have no result channel back to an operator action. Log-only is fine - // for the structured logger; the standing `plugin !` mark and `/plugins` - // surface carry the same warnings to the operator instead of a startup - // system notice. - const standingPluginWarnings: string[] = [...pluginLoadDiag.warnings]; - // Host mounts later; attention is painted once the shell exists. - let paintPluginAttention: ((needs: boolean) => void) | null = null; - const notePluginWarnings = (warnings: readonly string[]): void => { - if (warnings.length === 0) return; - standingPluginWarnings.push(...warnings); - paintPluginAttention?.(standingPluginWarnings.length > 0); - }; - // Saved through onboarding's "save anyway" bypass without a passing - // connection test — warn now instead of a bare adapter error on first send. - const startupPluginNotices: string[] = []; - if (config.verified === false) { - startupPluginNotices.push( - `We couldn't confirm your "${config.providerName}" key works. If your first message fails with an auth error, double-check the key.`, - ); - } - // Mutable list so trusting a project/path plugin can replace a metadata-only stub - // with a fully loaded module without restarting the process. - const executablePlugins = () => pluginState.modules.filter((m) => m.metadataOnly !== true); - setUpCommandRegistry(config.settings, executablePlugins(), () => pluginState.pluginConfig); - let sessionId = start.sessionId; - const resumeSkipInitialTask = start.resumeSkipInitialTask; - let startedAt = start.startedAt; - let runTaskTitle = start.runTaskTitle; - // Resolved once at the resume boundary so turnsUsed/mcpServers reads - // downstream never repeat their own omission-handling default. - const resumeSeed = start.resumeSeed; - - let workdir = start.workdir; - const activeRunHandle = start.activeRunHandle; - const crashGuard = start.crashGuard; - crashGuard.bindLiveSession(() => ({ - cwd: config.cwd, - sessionId, - startedAt, - runTaskTitle, - providerName: config.providerName, - model: config.model, - })); - - try { - const emitter = createTUIEventEmitter(); - const globalSettingsWriter = createGlobalSettingsWriter(config.globalSettingsPath); - const localSettingsWriter = createLocalSettingsWriter(localSettingsPath(config.cwd)); - const initialHookEnabled: Record = Object.fromEntries( - Object.entries(config.settings?.hooks ?? {}).map(([id, v]) => [id, v.enabled]), - ); - const { hookManager, runSink, cycleRecorder } = await assembleSessionLifecycle({ - cwd: config.cwd, - emitter, - getTelemetry, - getSessionId: () => sessionId, - getSource: () => liveSource, - initialTurnCount: resumeSeed.turnsUsed, - // persistRunSnapshot is defined below but not invoked until the stream - // starts consuming events, well after this closure captures it. - onTurnBoundarySnapshot: () => { - void persistRunSnapshot("running"); - }, - hookEnabled: initialHookEnabled, - onHookEvent: (event) => emitter.emit("hook", event), - resolveContextDir: () => workdir, - }); - // Cheap static check, not a real parser: a shell hook always receives the - // lifecycle name as $1, so it can react to either; a TypeScript hook's - // exports tell us which of postTurn/postRun it actually implements. - const hookRunsOn = new Map(); - for (const status of hookManager.getStatuses()) { - if (status.type === "shell") { - hookRunsOn.set(status.id, "runs postTurn and postRun (receives the lifecycle name as $1)"); - continue; - } - try { - const source = await readFile(status.path, "utf8"); - const hasPostTurn = /export\s+(async\s+)?function\s+postTurn\b/.test(source); - const hasPostRun = /export\s+(async\s+)?function\s+postRun\b/.test(source); - hookRunsOn.set( - status.id, - hasPostTurn && hasPostRun - ? "runs postTurn and postRun" - : hasPostTurn - ? "runs postTurn" - : hasPostRun - ? "runs postRun" - : "no postTurn/postRun export found — see file", - ); - } catch { - hookRunsOn.set(status.id, "could not read hook file — see file"); - } - } - let liveHookConfig: Record = { - ...(config.settings?.hooks ?? {}), - }; - const persistHookSettings = async (): Promise => { - const result = await globalSettingsWriter.mutate((base) => ({ - ...base, - hooks: liveHookConfig, - })); - if (result === "skipped") { - tuiLogger.warn("Skipping hook settings write: unreadable global settings at {path}", { - path: config.globalSettingsPath, - }); - } - }; - const setHookEnabled = async (id: string, enabled: boolean): Promise => { - hookManager.setEnabled(id, enabled); - liveHookConfig = { ...liveHookConfig, [id]: { enabled } }; - await persistHookSettings(); - }; - let runError: string | undefined; - - const recordRunError = (err: unknown): void => { - runError = err instanceof Error ? err.message : String(err); - }; - - // A send rejected because the operator interrupted is not a failure to - // report, and it must not settle a UI the interrupt path already settled. - let sendAborted = false; - const isCodexAuthError = (err: unknown): boolean => - err instanceof Error && err.name === "CodexAuthError"; - const isXaiAuthError = (err: unknown): boolean => - err instanceof Error && err.name === "XaiAuthError"; - - const approvalPersistNotice: { notify?: (text: string) => void } = {}; - - // Shared by the permission gate and every operator-gate emission site: an - // unattended auto-continue run must not park on any gate forever, whichever - // kind it is. No caller arms this today — the goal subsystem was the only - // source of an auto-deny/auto-cancel deadline and has been removed. The - // timeout plumbing (gate-events.ts / request-approval.ts, and every - // OperatorGateEvent/PermissionGateEvent emission site below) stays for a - // future generalized auto-continue mechanism to re-arm by giving this a - // real body again. - const approvalTimeout = (): { timeoutMs: number; timeoutMessage: string } | undefined => - undefined; - - const { gate: permissionGate } = await assembleSessionGate({ - cwd: config.cwd, - sessionId, - providerName: config.providerName, - model: config.model, - telemetry: liveTelemetry, - requestApproval: createGateRequestApproval({ - emitGate: (event) => emitter.emit("permission.gate", event), - approvalTimeout, - }), - getActiveProviderModel: () => `${config.providerName}:${config.model}`, - onPersistNotice: (text) => approvalPersistNotice.notify?.(text), - interactive: true, - skipPermissions: config.dangerouslySkipPermissions, - auto: config.auto, - // Main session: gating rides the reactor's approval-suspend seam. - reactorGated: true, - onGrant: (approval, covers) => emitter.emit("permission.grant", { approval, covers }), - }); - const approvalResume = createApprovalResume({ - getAgent: () => currentAgent, - gate: permissionGate, - }); - - const permissionsAdmin = createPermissionsAdmin(permissionGate, config.cwd); - - // Track the active subagent provider so a live /agent switch (provider, model, - // or reasoning effort) reaches subagents spawned afterward. Derives from the - // live `config` binding on every spawn, so every switch path that reassigns - // `config` (model picker, /agent, post-connect refresh) is picked up without - // a separate cache to keep in sync. - const liveSubAgent = createLiveSubAgentSources(() => config); - - // Dedicated child-session records for enter-session inspection. Child events - // land here only — never in the parent chat transcript. - const subAgentSessions = createSubAgentSessionStore({ - admission: getProcessAdmissionQueue(), - }); - - pluginState.webCandidates = collectWebPlugins(executablePlugins()); - // Tool plugins are wired in only when enabled AND consented. - pluginState.toolCandidates = collectToolPlugins(executablePlugins()); - // Web and tool plugin resolution are independent, so resolve them concurrently. - const toolPluginDiag = createPluginLoadDiagnostics(); - const [activeWeb, extraToolPlugins] = await Promise.all([ - resolveWebProviderFromPlugins({ - candidates: pluginState.webCandidates, - pluginConfig: config.settings?.plugins ?? {}, - webOverride: config.settings?.web, - }), - resolveToolPlugins({ - candidates: pluginState.toolCandidates, - pluginConfig: config.settings?.plugins ?? {}, - diagnostics: toolPluginDiag, - }), - ]); - if (activeWeb !== undefined) setActiveWebProviderBrand(webBrand(activeWeb.name)); - emitPluginWarningLog(toolPluginDiag); - standingPluginWarnings.push(...toolPluginDiag.warnings); - - // Descriptors mirror the mutable module list so plugins added by path - // mid-session appear without a restart. - pluginState.descriptors = pluginState.modules - .map((m) => buildPluginDescriptor(m)) - .filter((d): d is PluginDescriptor => d !== undefined); - // Attach agent profiles to their descriptors so the /plugins UI can show - // which sub-agents a plugin contributes. - for (const mod of pluginState.modules) { - if (mod.manifest?.kind !== "agent" || mod.agentPlugin === undefined) continue; - const desc = pluginState.descriptors.find((d) => d.id === mod.manifest!.id); - if (desc === undefined) continue; - const agents = Array.isArray(mod.agentPlugin.agents) ? mod.agentPlugin.agents : []; - desc.agentProfiles = agents - .filter( - (a): a is Record => typeof a === "object" && a !== null && "id" in a, - ) - .map((a) => ({ - id: String(a["id"]), - ...(typeof a["description"] === "string" ? { description: a["description"] } : {}), - })); - } - const pluginsAdmin = createPluginsAdmin({ - state: pluginState, - globalSettingsPath: config.globalSettingsPath, - globalSettingsWriter, - noteWarnings: notePluginWarnings, - }); - const profilesDir = join(config.cwd, ".agents", "agents"); - const profileDiag = createPluginLoadDiagnostics(); - const pluginAgentProfiles = await resolveAgentPluginProfiles( - executablePlugins(), - config.settings?.plugins ?? {}, - { diagnostics: profileDiag }, - ); - emitPluginWarningLog(profileDiag); - standingPluginWarnings.push(...profileDiag.warnings); - const initialProfiles = await loadAgentProfiles(profilesDir, pluginAgentProfiles); - const liveAgentProfiles = initialProfiles; - - // Skill directories from enabled plugins, in addition to project-local - // `.agents`/`.claude`/`.codex/skills` that discoverSkills/resolveSkillBody check. - const skillDirs = skillDirsFromEnabledPlugins(executablePlugins(), pluginState.pluginConfig); - - const shellTimeout = shellTimeoutFromSettings(config.settings); - // Mutable so Settings → waitForApproval takes effect on the next tool call - // without rebuilding the toolset. - const liveToolWatchdog: ToolWatchdogConfig = { - ...(toolWatchdogFromSettings(config.settings) ?? {}), - }; - // CL-5814: orchestrator is the only product path — no first-run mode picker. - const liveSessionMode: SessionMode = "orchestrator"; - // Local settings still supply shell env; sessionMode is ignored if present. - const localSettingsForEnv = await loadSessionLocalSettings({ - cwd: config.cwd, - globalSettingsPath: config.globalSettingsPath, - }); - const toolAvailability: ToolAvailability = { - languageServerAvailable: detectLanguageServerAvailable(config.cwd), - }; - // The workflow controller is built below, after the toolset; the holder lets - // submit_output's handler complete the live workflow without a - // construction-order cycle. - const workflowControllerHolder: { instance?: WorkflowController } = {}; - - // Assigned before any tool runs; getter wires session blob reads into posix tools. - let currentAgent!: Agent; - // Set alongside currentAgent in buildAgent; getter wires the session's own - // blob store into the truncation spill path (see result-truncation-plugin.ts). - let currentStorage: ContextStore | null = null; - - const toolset = await createAgentToolset({ - cwd: config.cwd, - permissionGate, - skillDirs, - telemetry: liveTelemetry, - isCodex: isCodexProviderName(config.providerName), - ...(shellTimeout !== undefined ? { shellTimeout } : {}), - ...(localSettingsForEnv?.env !== undefined ? { shellEnv: localSettingsForEnv.env } : {}), - toolWatchdog: liveToolWatchdog, - getBlobReader: () => currentAgent.blobReader, - getBlobWriter: () => currentStorage?.writeBlob, - getContextDir: () => workdir, - - isWorkflowActive: () => workflowControllerHolder.instance?.isActive() === true, - completeWorkflowStep: (stepId) => - workflowControllerHolder.instance?.complete(stepId) ?? "not-current", - ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), - onOperatorGate: (question, options) => - new Promise((resolve) => { - const { finish, signal } = attachApprovalBudget(resolve, { - tool: "ask_operator", - kind: "operator", - }); - const timeout = approvalTimeout(); - const event: OperatorGateEvent = { - question, - options, - resolve: finish, - ...(timeout !== undefined ? timeout : {}), - ...(signal !== undefined ? { signal } : {}), - }; - emitter.emit("operator.gate", event); - }), - sessionMode: liveSessionMode, - toolAvailability, - ...(config.mcpServers !== undefined ? { mcpServers: config.mcpServers } : {}), - mcpServersSource: config.mcpServersSource ?? "none", - projectTrust: pluginState.projectTrust, - requestMcpTrust: async (server) => { - // TOFU via operator gate: Trust this local MCP server? - const result = await new Promise((resolve) => { - const { finish, signal } = attachApprovalBudget(resolve, { - tool: `mcp:${server.name}`, - kind: "operator", - }); - const timeout = approvalTimeout(); - const event: OperatorGateEvent = { - question: - `Trust local MCP server "${server.name}" for this project?` + - (server.command !== undefined - ? `\nCommand: ${server.command}${(server.args ?? []).length > 0 ? ` ${(server.args ?? []).join(" ")}` : ""}` - : server.url !== undefined - ? `\nURL: ${server.url}` - : ""), - options: ["Trust and connect", "Deny"], - resolve: finish, - ...(timeout !== undefined ? timeout : {}), - ...(signal !== undefined ? { signal } : {}), - }; - emitter.emit("operator.gate", event); - }); - return result.kind === "option" && result.index === 0; - }, - subAgent: { - provider: liveSubAgent.provider, - sessions: subAgentSessions, - getWorkdirBase: () => sessionDir(config.cwd, sessionId), - // Progress only — not the full event stream. Forwarding every sub-agent - // inference.delta into the parent transcript interleaves worker text with - // the parent turn; progress keeps the status bar alive and the Agents - // strip current without that pollution. - onProgress: (info) => { - emitter.emit("subagent.progress", info); - }, - settings: liveSubAgent.settings, - catalog: liveSubAgent.catalog, - profiles: () => liveAgentProfiles, - }, - }); - - const { systemPrompt } = await loadSessionChatPrompt({ - cwd: config.cwd, - skillDirs, - ...(config.systemPromptExtensions !== undefined - ? { systemPromptExtensions: config.systemPromptExtensions } - : {}), - sessionMode: liveSessionMode, - toolAvailability, - skills: toolset.skills, - }); - - const directorHolder: { instance?: ReturnType } = {}; - const hostHolder: { instance?: Awaited> } = {}; - - // Owns the workflow lifecycle: slash-command starts, capability overrides, - // resume, and publishing status to the App via the emitter. - const workflowController = new WorkflowController({ - cwd: config.cwd, - emitter, - getSessionId: () => sessionId, - getToolDefinitions: () => toolset.dynamicRunner.currentDefinitions(), - getDirector: () => directorHolder.instance, - }); - workflowControllerHolder.instance = workflowController; - - // Dynamic tool discovery: only the fixed built-in prefix plus activated - // tools reach the wire, so the provider cache prefix holds steady; MCP - // tools must be promoted here before the model can invoke them. - const { activated: activatedToolNames, computeAdvertised } = createAdvertisedToolset({ - sessionMode: liveSessionMode, - toolAvailability, - getProvider: () => config, - }); - - const initialCodexProfile = codexProfileFromProviderName(config.providerName); - const initialXaiProfile = xaiProfileFromProviderName(config.providerName); - - // Reload, interrupt, compaction continuation, and proxy deliver share one queue - // so a rebuild never races an in-flight deliver. - const sessionOps = createSessionOperationQueue(); - const deliveryGeneration = createDeliveryGeneration(); - const enqueueAgentDeliver = (deliverToLiveAgent: () => void): void => { - const stillCurrent = deliveryGeneration.capture(); - void sessionOps.enqueue(async () => { - if (!stillCurrent()) return; - // The shell already popped the queue item and painted it as delivered - // by the time this runs, so a failed rebuild must be surfaced here — - // otherwise the message silently never reaches the agent. - await deliverAgentMessage({ - getFatalBuildError: () => fatalBuildError, - deliverToLiveAgent, - onDeliverFailure: systemNotice, - }); - }); - }; - - // The agent freezes its tool-dispatch map at construction, so MCP servers that - // connect after startup are not callable until the agent is rebuilt. buildAgent - // re-runs tool resolution against the (now-populated) dynamic runner and resumes - // conversation from the same git-backed store, so a reload is transparent. - const buildSessionSources = (): LiveSessionSources => - resolveLiveSessionSources(config, sessionId); - - const initialBundle = buildSessionSources(); - let liveSources = initialBundle.sources; - let liveDefaultSource = initialBundle.defaultSource; - // The source the next inference will use, tracked live so the compaction - // summarizer always summarizes with the current model (model switches and - // Codex token refreshes update it below). - let liveSource: InferenceSource = initialBundle.selected; - - // Compaction summarizer: produces a structured, workflow-aware handoff via a - // one-shot call on the live model, falling back to the deterministic summary - // on any failure. Workflow state is read at compaction time so a pass - // mid-/build or mid-/plan still names the active step. - const compactionSummarize = createModelSummarizer({ - getSource: () => liveSource, - deps: inferenceDeps, - }); - const summaryContext = (): SummaryContext | undefined => { - const status = workflowController.status(); - if (!status.active) return undefined; - return { - workflow: { - ...(status.name !== undefined ? { name: status.name } : {}), - stepLabel: status.label, - stepIndex: status.stepIndex, - total: status.total, - }, - }; - }; - - // Mutable reference so the compaction summarize callback reads the live mode - // without requiring an agent rebuild on every settings change. - let liveCompactionMode = config.settings?.compactionMode ?? "llm"; - - const chatAgent = assembleChatAgent({ - toolsId: `${ID_PREFIX}/tui-tools`, - agentId: `${ID_PREFIX}/tui-agent`, - systemPrompt, - getDynamicRunner: () => toolset.dynamicRunner, - computeAdvertised, - activateTools: (names) => activatedToolNames.activate(names), - inactivityTimeoutMs: config.inactivityTimeoutMs ?? 750_000, - totalTimeoutMs: config.totalTimeoutMs, - onTasksChange: (tasks) => emitter.emit("tasks", tasks), - requestContinuation: () => { - enqueueAgentDeliver(() => currentAgent.deliver(buildCompactionContinuationMessage())); - }, - getProvider: () => config, - // Live id so mid-session `/model` updates xAI bare-429 remapping - // without rebuilding the agent (aligned with transcript stamp). - getProviderId: () => config.providerName, - directorHolder, - onToolsPromoted: () => { - pendingReload = true; - reloadIfIdle(); - }, - getWorkdir: () => workdir, - authorize: createReactorAuthorize(permissionGate), - inferenceDeps, - getSources: () => (liveSources.length > 0 ? liveSources : [liveSource]), - getDefaultSource: () => (liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id), - getCompactor: () => - createSessionPruningCompactor({ - compactionMode: liveCompactionMode, - summarize: compactionSummarize, - summaryContext, - telemetry: liveTelemetry, - // Main-session folds only — exec runner and subagents stay silent. - onFolded: (info) => emitter.emit("compaction", info), - }), - onBuilt: (agent, storage) => { - currentAgent = agent; - currentStorage = storage; - }, - }); - const buildAgent = chatAgent.buildAgent; - - const sessionCost = createSessionCostAccumulator({ - pricingCache: getActivePricingCache, - }); - - // MCP servers connected so far, keyed by name so a reconnect after a failure - // replaces rather than duplicates the entry. - let connectedMcpServers: ConnectedMcpServer[] = resumeSeed.mcpServers; - // Every configured server's latest state, for the /mcp surface. Unlike - // `connectedMcpServers` (persisted run metadata) this keeps the ones that - // failed or are still waiting on authorization. - const mcpStates = new Map(); - let configuredMcpEntries: MCPServerSettingsEntry[] = [...config.mcpServerEntries]; - const mcpConnectController = new AbortController(); - - const writeRunSnapshot = async ( - status: RunState["status"], - extra?: Pick, - kind: SnapshotKind = "progress", - ): Promise => { - const task = runTaskTitle.trim().length > 0 ? runTaskTitle.trim() : "(conversation)"; - const model = `${liveSource.id}:${liveSource.model}`; - // Kept in step with every persisted snapshot so the crash handler's copy - // (activeRunHandle, read by index.ts) never lags what's actually on disk. - activeRunHandle.task = task; - activeRunHandle.startedAt = startedAt; - activeRunHandle.model = model; - const state: RunState = { - status, - turnsUsed: runSink.getTurnCount(), - task, - startedAt, - model, - mcpServers: connectedMcpServers, - ...extra, - }; - if (clearsActiveRun(kind)) { - await finalizeRunState(config.cwd, sessionId, state); - } else { - await saveState(config.cwd, sessionId, state); - } - }; - - // Progress snapshots are fired unsequenced (model switch, MCP connect, turn - // completion), so a straggler could otherwise land after the terminal write - // and resurrect status "running" — atomicWrite is last-rename-wins. Once the - // run is finalized, drop them; the run-ending path writes through - // writeRunSnapshot directly. - // - // Never a "run-end" write: everything routed here happens while the process - // is still alive and must stay crash-coverable, including the rotation - // "done" that closes out a session on /clear or /new. - const persistRunSnapshot = async ( - status: RunState["status"], - extra?: Pick, - kind: Exclude = "progress", - ): Promise => { - if (crashGuard.isFinalized()) return; - await writeRunSnapshot(status, extra, kind); - }; - - // Cycles persist to the context store only on inference.done; the assembled - // recorder keeps the in-flight cycle's text so an errored or interrupted - // turn leaves its partial output in partial.jsonl instead of vanishing. - const providerFailureAttempts = createProviderFailureAttemptTracker(); - crashGuard.setPartialFlush(() => cycleRecorder.dispose("crashed").then(() => undefined)); - const streamSink = (event: Parameters[0]): void => { - let eventForSink = event; - if (event.type === "message.received") { - providerFailureAttempts.advanceToNextMessage(); - } else if (event.type === "inference.start" || event.type === "inference.done") { - providerFailureAttempts.reset(); - } else if (event.type === "inference.error") { - const error = event.data.error; - const executingAttempt = providerFailureAttempts.current(); - const providerId = - "providerId" in error && typeof error.providerId === "string" - ? error.providerId - : (executingAttempt?.providerId ?? config.providerName); - providerFailureAttempts.observe(normalizeInferenceErrorForTerminal(error, providerId)); - } else if (event.type === "connector.reply") { - const reply = providerFailureAttempts.consumeConnectorReply(); - if (reply?.suppressPresentation === true) { - eventForSink = suppressProviderFailurePresentation(event); - } - } else if (event.type === "message.run.ended") { - providerFailureAttempts.consumeTerminal(); - } - runSink.sink(eventForSink); - cycleRecorder.handleEvent(event); - if (onTurnBoundary(event)) { - sessionCost.addTurn(event.data.usage, billingIdentityFromSource(event.data.source)); - } - }; - - // Tool count before any MCP server connects; a reload is only worthwhile if - // connecting actually added tools. - const baseToolCount = toolset.dynamicRunner.currentDefinitions().length; - - currentAgent = await buildAgent(); - await persistRunSnapshot("running"); - void resolveSessionLabel(config.cwd, sessionId, runTaskTitle).then((label) => { - emitter.emit("session.title", label); - }); - let streamPromise = consumeStream(currentAgent.stream(), streamSink); - - // Serial operation queue. Rotation (reload, interrupt, newSession), compaction - // continuation, and proxy deliver enqueue async tasks; they run one at a time. - // `send` awaits the tail before dispatching so it never races a concurrent rebuild. - let inFlight = 0; - let pendingReload = false; - // When buildAgent() throws after the old agent has been closed, this flag is - // set and subsequent `send` calls throw immediately rather than dispatching to - // a closed agent. - let fatalBuildError: Error | null = null; - - const enqueueOp = sessionOps.enqueue; - - const reloadIfIdle = (): void => { - if (!pendingReload || inFlight > 0) return; - pendingReload = false; - void enqueueOp(async () => { - try { - const old = currentAgent; - const closedCleanly = await closeAgentForRebuild(old, "reload"); - await streamPromise.catch((err: unknown) => { - tuiLogger.debug("stream drain during reload teardown failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - if (!closedCleanly) { - throw new AgentContextLockError(workdir); - } - currentAgent = await buildAgent(); - streamPromise = consumeStream(currentAgent.stream(), streamSink); - // The rebuild made a fresh director; re-attach the active workflow. - workflowController.reattach(); - } catch (err) { - recordRunError(err); - fatalBuildError = agentRebuildFailure(err); - } - }); - }; - - // tool_search (and contextual triggers, e.g. the lsp hint) promote tools into - // the advertised set. Advertising takes effect on the next infer; a reload is - // scheduled so a newly connected MCP tool also becomes dispatchable after a - // rebuild (built-in tools are already dispatchable, so promoting them alone - // needs no reload, but the reload is a cheap no-op in that case). - const promoteTools = (names: string[]): void => { - if (!activatedToolNames.activate(names)) return; - directorHolder.instance?.updateToolDefinitions( - computeAdvertised(toolset.dynamicRunner.currentDefinitions()), - ); - pendingReload = true; - reloadIfIdle(); - }; - toolset.setToolPromoter(promoteTools); - - // The active Codex source, tracked whenever a "codex/" source is - // selected so its access token can be refreshed before each send. Seeded from - // config when the session starts on a Codex profile (buildAgent sets that - // source directly, not through the proxy's setSource). - let activeCodexSource: { profile: string; source: InferenceSource } | undefined = - initialCodexProfile !== undefined - ? { profile: initialCodexProfile, source: liveSource } - : undefined; - let activeXaiSource: { profile: string; source: InferenceSource } | undefined = - initialXaiProfile !== undefined - ? { profile: initialXaiProfile, source: liveSource } - : undefined; - - // Refresh the active Codex access token (if any) and push it onto the live - // agent before a send. getValidCodexToken returns the stored token when still - // valid and refreshes transparently otherwise, so this satisfies "check - // before each inference call" without crashing the loop: a failure surfaces - // as a CodexAuthError naming the profile and rejects the send. - // - // The source is pushed on every send, not only when the token changed: an - // agent rebuild (tool promotion, interrupt, /clear) reseeds the source from - // the original login-time token, so unconditionally re-pushing the live token - // is what keeps the rebuilt agent from sending a stale credential. - const refreshCodexBeforeSend = async (): Promise => { - const active = activeCodexSource; - if (active === undefined) return; - const { access } = await getValidCodexToken(active.profile); - const source: InferenceSource = - access === active.source.apiKey ? active.source : { ...active.source, apiKey: access }; - activeCodexSource = { profile: active.profile, source }; - liveSource = source; - setAgentSourceUnlessClosed(currentAgent, source); - }; - - const refreshXaiBeforeSend = async (): Promise => { - const active = activeXaiSource; - if (active === undefined) return; - const { access } = await getValidXaiToken(active.profile); - const source: InferenceSource = - access === active.source.apiKey ? active.source : { ...active.source, apiKey: access }; - activeXaiSource = { profile: active.profile, source }; - liveSource = source; - setAgentSourceUnlessClosed(currentAgent, source); - }; - - // Stable handle handed to the App so the underlying agent can be swapped out - // from under it without a remount; method calls always target the live agent. - // Host mounts later; stampProvider.fn is wired once the bridge exists. - const stampProvider: { fn: ((id: string | undefined) => void) | undefined } = { - fn: undefined, - }; - const agentProxy: Agent = { - send: async (content, opts) => { - await sessionOps.awaitTail(); - if (fatalBuildError !== null) throw fatalBuildError; - const trimmed = typeof content === "string" ? content.trim() : ""; - if (trimmed.length > 0 && runTaskTitle.trim().length === 0) { - runTaskTitle = trimmed.length > 240 ? `${trimmed.slice(0, 237)}...` : trimmed; - emitter.emit("session.title", truncateSessionLabel(runTaskTitle)); - void persistRunSnapshot("running"); - } - inFlight++; - try { - await refreshCodexBeforeSend(); - await refreshXaiBeforeSend(); - return await currentAgent.send(content, opts); - } finally { - inFlight--; - reloadIfIdle(); - } - }, - stream: () => currentAgent.stream(), - deliver: (message) => { - enqueueAgentDeliver(() => currentAgent.deliver(message)); - }, - close: () => currentAgent.close(), - setSource: (source) => { - const codexProfile = codexProfileFromProviderName(source.id); - const xaiProfile = xaiProfileFromProviderName(source.id); - activeCodexSource = - codexProfile !== undefined ? { profile: codexProfile, source } : undefined; - activeXaiSource = xaiProfile !== undefined ? { profile: xaiProfile, source } : undefined; - liveSource = source; - liveSources = [source]; - liveDefaultSource = source.id; - setAgentSourceUnlessClosed(currentAgent, source); - stampProvider.fn?.(source.id); - void persistRunSnapshot("running"); - }, - setSources: (sources, defaultSource) => { - currentAgent.setSources(sources, defaultSource); - liveSources = sources; - liveDefaultSource = defaultSource; - const head = sources.find((s) => s.id === defaultSource) ?? sources[0]; - if (head !== undefined) { - const codexProfile = codexProfileFromProviderName(head.id); - const xaiProfile = xaiProfileFromProviderName(head.id); - activeCodexSource = - codexProfile !== undefined ? { profile: codexProfile, source: head } : undefined; - activeXaiSource = - xaiProfile !== undefined ? { profile: xaiProfile, source: head } : undefined; - liveSource = head; - stampProvider.fn?.(head.id); - } - void persistRunSnapshot("running"); - }, - history: () => currentAgent.history(), - checkpoints: (limit) => currentAgent.checkpoints(limit), - readAt: (hash) => currentAgent.readAt(hash), - get blobReader() { - return currentAgent.blobReader; - }, - }; - - // Hard stop only (Ctrl+C / doInterrupt). Soft steer (Enter mid-run enqueue) - // and follow-up (queued drain / deliver) must never call this — those paths - // leave in-flight workers running. Closing the agent is the only thing that - // aborts the reactor mid-inference (the send signal only rejects the send - // promise); that close cascades: operationController.abort → wait_agents parent - // signal → child abort. Do not add cancelAll here — fleet cancelAll is - // reserved for /clear (newSession) and shutdown. - // Close it, drain the old stream, and rebuild a fresh agent so the next send - // works. - const interrupt = (): void => { - sendAborted = true; - void enqueueOp(async () => { - try { - // close() tears down stream consumers before the aborted cycle's - // inference.error is delivered, so the recorder never sees a terminal - // event for the dead cycle — dispose closes it against stray deltas - // and salvages the buffer before that teardown, so it is never lost - // or misattributed to the rebuilt agent's next cycle. - await cycleRecorder.dispose("interrupted"); - const closedCleanly = await closeAgentForRebuild(currentAgent, "interrupt"); - await streamPromise.catch((err: unknown) => { - tuiLogger.debug("stream drain during interrupt teardown failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - if (!closedCleanly) { - throw new AgentContextLockError(workdir); - } - currentAgent = await buildAgent(); - cycleRecorder.reset(); - streamPromise = consumeStream(currentAgent.stream(), streamSink); - workflowController.reattach(); - fatalBuildError = null; - } catch (err) { - recordRunError(err); - fatalBuildError = agentRebuildFailure(err); - } - }); - }; - - // /clear and /new start a fresh conversation: mint a new session id and its - // own state directory, repoint the working tree at it, and rebuild the agent - // so it resumes from an empty git-backed store. The prior session stays on - // disk under its own id, resumable later. - // - // Sub-agent lifecycle on rotation: App cancels live workers (cancelAll + - // abort handles → child agent.close) before clearing the session store so - // /clear does not leave orphaned child reactors burning tokens. - const newSession = (): void => { - deliveryGeneration.bump(); - cancelFeedbackCapture(); - // Wipe the painted transcript immediately. The product host listens for - // session.clear; the Ink App used to clear its own stream unconditionally - // and that path never moved to OpenTUI. - emitter.emit("session.clear"); - // Cancel live workers before rotation so /clear does not leave orphaned - // child reactors burning tokens under the old session id. - subAgentSessions.cancelAll("Session cleared"); - // Backend rotation is always enqueued regardless of contention; the queue - // serialises it behind any in-progress op. Sub-agents nest under the new - // session automatically because getWorkdirBase reads the live sessionId. - void enqueueOp(async () => { - try { - // Tear the old agent down and dispose the recorder before workdir is - // repointed: the pump can deliver stray deltas until the stream - // settles, and a dead cycle's partial must land in the session that - // produced it, not the fresh one. - await cycleRecorder.dispose("rotation"); - // Deliberately not routed through closeAgentForRebuild/ - // agentRebuildFailure (unlike interrupt and reloadIfIdle, CL-5753): - // rotation mints a fresh sessionId/workdir below before calling - // buildAgent(), so even a close() that leaks the old workdir's lock - // (see closeAgentForRebuild's doc comment) can never cause a second - // acquisition on that same workdir — buildAgent() always targets - // the new, unlocked directory. The old lock still leaks for the - // rest of the process, but nothing ever tries to re-acquire it, so - // there is no crash to guard against here. - await currentAgent.close().catch((err: unknown) => { - tuiLogger.debug("agent.close during session-rotation teardown failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - await streamPromise.catch((err: unknown) => { - tuiLogger.debug("stream drain during session-rotation teardown failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - await persistRunSnapshot("done", { finishedAt: Date.now() }, "session-rotation"); - sessionId = generateSessionId(); - // Repointed, not cleared: the process lives on, so the crash handler - // must keep finding this handle and close out the *new* session. - activeRunHandle.sessionId = sessionId; - startedAt = Date.now(); - runTaskTitle = config.task; - emitter.emit( - "session.title", - runTaskTitle.trim().length > 0 - ? truncateSessionLabel(runTaskTitle) - : "Untitled session", - ); - workdir = sessionContextDir(config.cwd, sessionId); - await initSessionDir(config.cwd, sessionId); - const rotatedBundle = buildSessionSources(); - liveSources = rotatedBundle.sources; - liveDefaultSource = rotatedBundle.defaultSource; - liveSource = rotatedBundle.selected; - permissionGate.reset(); - runSink.reset(); - sessionCost.reset(); - currentAgent = await buildAgent(); - cycleRecorder.reset(); - streamPromise = consumeStream(currentAgent.stream(), streamSink); - await persistRunSnapshot("running"); - // A fresh session drops any active workflow. - workflowController.reset(); - fatalBuildError = null; - // Sink and director are empty now — repaint so the meter stays hidden - // rather than showing the pre-clear occupancy until the next turn. - hostHolder.instance?.refreshCostContext(); - } catch (err) { - recordRunError(err); - fatalBuildError = err instanceof Error ? err : new Error(String(err)); - } - }); - }; - - // The `onboarded` flag is global user state: read and written against the TRUE - // global settings file, never config.globalSettingsPath (which is the --config - // file when one was given). This keeps first-run detection consistent and stops - // a --config launch from stamping project-config contents into the global file. - const trueGlobalSettingsPath = globalSettingsPath(); - const globalSettingsForOnboarding = await loadSettings(trueGlobalSettingsPath); - - // Consent by proceeding (see telemetry/first-run.ts): on a first run the - // singleton is a held no-op and the passive banner below is the - // disclosure. The first interactively submitted prompt activates telemetry - // and fires the held cli_start; a user who never acts keeps the hold for - // this whole launch, and the render stamp means events start normally on - // the next one. Keyed off the same TRUE global settings file as - // `onboarded` above. - const onChangeTelemetryEnabled = createTelemetryToggleHandler( - trueGlobalSettingsPath, - undefined, - globalSettingsWriter.enqueue, - ); - const telemetryFirstRun = telemetryFirstRunPending(globalSettingsForOnboarding); - const telemetryNotice = telemetryStartupNotice(globalSettingsForOnboarding); - // Tracks the user's intent (persisted opt-in, updated live by the settings - // toggle) rather than the held instance's state, so the settings tab shows - // On during the hold and an opt-out before the first action suppresses - // activation entirely. - let liveTelemetryIntent = telemetryFirstRun || getTelemetry().enabled; - // Off by default: the prompt border's running cost is a distraction most - // sessions do not want. /cost stays available regardless. - let liveShowPromptCost = config.settings?.showPromptCost ?? false; - if (telemetryFirstRun) { - void globalSettingsWriter - .enqueue(() => markTelemetryNoticeShown(trueGlobalSettingsPath)) - .catch(() => { - // Best-effort: worst case the notice shows again next launch. - }); - } - - // Post-upgrade release notes watermark policy (CL-5475): - // - first_install: stamp quietly so later launches do not dump history. - // - upgrade: stamp only when notes were actually shown. The former Ink - // whats-new banner is gone on the OpenTUI path, so notesShown is false - // until a surface is restored — never silently consume upgrade notes. - // - resume / current: leave the watermark alone. - const changelogDecision = loadStartupChangelogMarkdown({ - lastChangelogVersion: globalSettingsForOnboarding?.lastChangelogVersion, - packageVersion: typeof pkg.version === "string" ? pkg.version : "0.0.0", - }); - const notesShown = false; - const stampVersion = stampVersionAfterStartup(changelogDecision, notesShown); - if (stampVersion !== null) { - void globalSettingsWriter - .enqueue(() => markLastChangelogVersion(trueGlobalSettingsPath, stampVersion)) - .catch(() => { - // Best-effort watermark. - }); - } - - // Every settings RMW in this runner shares this tail, including writes to - // the true global path during a --config session. - const enqueueGlobalPersist = globalSettingsWriter.enqueue; - - // Absent file → fresh base; unreadable/invalid → skip the write rather than - // clobber a corrupt settings file with a minimal shell. - const persistGlobalSettings = async ( - what: string, - apply: (base: Settings) => Settings, - ): Promise => { - const result = await globalSettingsWriter.mutate(apply); - if (result === "ok") return true; - tuiLogger.warn("Skipping {what} write: unreadable global settings at {path}", { - what, - path: config.globalSettingsPath, - }); - return false; - }; - - const commandContext: CommandContext = { - signalClear: newSession, - getSkipPermissions: () => permissionGate.getSkipPermissions(), - setSkipPermissions: (value: boolean) => { - permissionGate.setSkipPermissions(value); - config.dangerouslySkipPermissions = value; - void enqueueGlobalPersist(async () => { - try { - const result = await persistSkipPermissionsDefault(config.globalSettingsPath, value); - if (result === "skipped") { - systemNotice("Yolo flipped for this session, but the default did not stick."); - } - } catch { - systemNotice("Yolo flipped for this session, but the default did not stick."); - } - }); - }, - getCostSummary: (): CostSummary => { - const usage = runSink.getTokenUsage(); - const lastTurnUsage = runSink.getLastTurnUsage(); - const pricingCache = getActivePricingCache(); - const billed = sessionCost.snapshot(); - const totalCost = billed.meteredCost; - // A provider that omits or zeroes usage would otherwise pin the meter at - // 0% forever; fall back to the director's local estimate (turns plus - // system-prompt/tool-schema overhead). The governor already decided - // whether it's estimating when it computed this turn's arming — trust - // that decision rather than re-deriving it from a second usage read. - const contextEstimate = directorHolder.instance?.getContextEstimate(); - const isEstimate = contextEstimate !== undefined && contextEstimate.isEstimate; - const summary = buildCostSummary({ - modelId: config.model, - baseURL: config.baseURL, - providerName: config.providerName, - pricingCache, - totalCost, - formattedCost: formatCost(totalCost), - inputTokens: usage.input, - outputTokens: usage.output, - cacheReadTokens: usage.cacheRead, - contextTokens: isEstimate - ? contextEstimate.tokens - : contextTokensFromUsage(lastTurnUsage), - contextIsEstimate: isEstimate, - sessionBillingMix: billed.mix, - sessionHiddenReason: billed.hiddenReason, - }); - return maskContextMeterWhenNoTurns(summary, runSink.getTurnCount()); - }, - startWorkflow: (name) => workflowController.start(name), - getFleetStatus: () => fleetDigest(subAgentSessions.list(), Date.now()), - renameSession: (name) => { - const trimmed = name.trim(); - if (trimmed.length === 0) return "Session name cannot be empty"; - runTaskTitle = trimmed; - emitter.emit("session.title", truncateSessionLabel(runTaskTitle)); - void renameSession(config.cwd, sessionId, trimmed) - .then(() => persistRunSnapshot("running")) - .catch((err: unknown) => { - tuiLogger.warn("rename session failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - return undefined; - }, - submitFeedback: (text) => { - // Inline /feedback must drop a prior bare-/feedback arm so the - // next normal prompt is not stolen as survey text. - cancelFeedbackCapture(); - const status = captureFeedback(getTelemetry(), text, { - turnTraceId: getLastTurnTraceId(), - }); - return feedbackResultMessage(status); - }, - beginFeedbackCapture: () => { - armFeedbackCapture(); - }, - }; - - // Routed through the shell's notice path rather than straight into the - // transcript: anything the runner says before the first turn arrives while - // the landing hero still owns the screen, and a transcript row there wipes - // the whole composition. Once a session row has ended the landing this is an - // ordinary system row, so there is no second behaviour to reason about. - const systemNotice = (text: string): void => { - surfaceSystemNotice(host.shell, text); - }; - approvalPersistNotice.notify = systemNotice; - - /** Settle the shell after a rejected send so the run does not look live. */ - const handleSendFailure = ( - err: unknown, - attempt: InferenceAttemptIdentity, - providerFailure: ProviderFailureAttempt, - ): void => { - const failure = classifyAgentSendFailure(err, sendAborted, isCodexAuthError, isXaiAuthError); - captureAuthFailure(getTelemetry(), failure); - if (!shouldSettleUiAfterSendFailure(failure.kind)) return; - if (failure.kind === "abort") return; - recordRunError(err); - if (!providerFailure.presented) { - systemNotice( - tuiSendFailureMessage( - err, - failure.kind, - providerFailure.observed, - attempt, - providerFailure.error, - ), - ); - providerFailureAttempts.markPresented(providerFailure); - } - setShellRunState(host.shell, "idle"); - }; - - const currentAttemptIdentity = (): InferenceAttemptIdentity => { - const displayLabel = config.settings?.providers[config.providerName]?.name; - return { - providerId: config.providerName, - ...(displayLabel !== undefined ? { displayLabel } : {}), - }; - }; - - const sendWithAttemptIdentity = async (message: InboundMessage): Promise => { - const attempt = currentAttemptIdentity(); - const providerFailure = providerFailureAttempts.begin(attempt); - try { - const result = await agentProxy.send(message); - // An ask-tier call parked on the reactor's approval gate settles the - // send early; resolve the operator surface here and deliver the - // decision on the correlationId signal channel so the parked run - // resumes. - await approvalResume.handle(result); - } catch (error) { - handleSendFailure(error, attempt, providerFailure); - } finally { - providerFailureAttempts.sendSettled(providerFailure); - } - }; - - // The permissions surface addresses grants by their position in the last - // listing, so revoke resolves against the same snapshot the operator saw. - let listedGrants: readonly ScopedApproval[] = []; - - const localSettingsFile = resolveLocalSettingsPath(config.cwd, config.globalSettingsPath); - - const applyCommandResult = (result: CommandResult): void => { - switch (result.type) { - case "message": - systemNotice(result.text); - return; - case "send": - // A command the operator typed and submitted at the prompt — same - // provenance as a plain-text send, just composed by the command - // handler instead of typed verbatim. - void sendWithAttemptIdentity(userInboundMessage(result.text, [])); - return; - case "workflow": - systemNotice(workflowController.start(result.name)); - return; - case "noop": - return; - case "overlay": - if (!host.openSurface(result.overlay)) { - const named = result.overlay === "add-provider" ? "connect" : result.overlay; - systemNotice(`No surface for /${named}.`); - } - return; - case "modal": - // /model is the only modal reachable from a command; provider login is - // reached from the picker itself. - if (result.modal === "agent" && host.openSurface("models")) return; - systemNotice(`${result.modal} is not available in this renderer yet`); - return; - case "view": - systemNotice(`${result.view} is not available in this renderer yet`); - return; - case "paste-image": - void attachClipboardImage(host.shell); - return; - } - }; - - /** - * Full user-prompt send path: inline image paths become attachments, - * @mentions are expanded, and the message is recorded for Up/Down recall. - */ - const sendUserPrompt = async ( - text: string, - pending: readonly PendingImageAttachment[], - ): Promise => { - sendAborted = false; - if (text.trim().length > 0) { - void appendSentMessage(config.cwd, sessionId, text).catch((err: unknown) => { - tuiLogger.debug("sent-message append failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - } - const ingested = await ingestOperatorPrompt( - text, - config.cwd, - imageAttachmentFromPath, - pending, - ); - await sendWithAttemptIdentity(userInboundMessage(ingested.text, ingested.attachments)); - }; - - const dispatchCommand = (name: string, args: string): void => { - const command = getCommand(name); - if (command === undefined) { - systemNotice(`Unknown command: ${name}`); - return; - } - // Plugins register into the same command registry as the built-ins, so an - // unrecognised name is plugin-authored and is bucketed rather than sent. - // Shared emitter so TUI and any headless path report the same event. - captureSlashCommand(getTelemetry(), command.name); - applyCommandResult(command.handler(args, commandContext)); - }; - - // Mount OpenTUI before the initial task is sent so gate and stream listeners - // are registered first. Ctrl+C stays with the shell (interrupt the run); - // OpenTUI owns the alternate screen and mouse reporting itself. - // Alt+A add-provider selector rows: every first-class provider kind, including - // Custom (full manual form). No already-connected filtering — OAuth and - // multi-instance accounts are per-name, so dropping a kind once it has one - // account would hide the path to a second. Read fresh on each open against - // the live catalog. - const computeAddProviderChoices = () => - addProviderSelectorChoices(providerChoices(), config.providers); - - const send = createSubmitHandler({ - dispatchCommand: (name, args) => dispatchCommand(name, args), - sendPrompt: (text, attachments) => { - void sendUserPrompt(text, attachments ?? []).catch((error: unknown) => { - handleSendFailure(error, currentAttemptIdentity(), { - observed: false, - presented: false, - error: undefined, - }); - }); - }, - onPromptSubmitted: () => { - if (telemetryFirstRun && liveTelemetryIntent) { - void activateHeldTelemetry(trueGlobalSettingsPath, () => liveTelemetryIntent); - } - }, - isFeedbackCapturePending, - cancelFeedbackCapture, - onFeedbackText: (text) => { - takeFeedbackCapture(); - const status = captureFeedback(getTelemetry(), text, { - turnTraceId: getLastTurnTraceId(), - }); - return feedbackResultMessage(status); - }, - onSystemNotice: systemNotice, - }); - const mcpConnectCallbacks: MCPConnectCallbacks = { - interactiveAuth: true, - onStatus: (status) => { - mcpStates.set(status.name, status); - emitter.emit("mcp.status", status); - if (status.state === "connected") { - connectedMcpServers = [ - ...connectedMcpServers.filter((server) => server.name !== status.name), - { name: status.name, toolCount: status.tools.length }, - ]; - void persistRunSnapshot("running"); - } - }, - // MCP tools register for dispatch but stay blind until tool_search promotes them. - onToolsChanged: (definitions) => - directorHolder.instance?.updateToolDefinitions(computeAdvertised(definitions)), - }; - - const connectLateMCPServer = (server: MCPServerConfig): void => { - void toolset - .connectMCPServer(server, mcpConnectCallbacks, mcpConnectController.signal) - .catch((err: unknown) => { - if (err instanceof Error && err.name === "AbortError") return; - tuiLogger.error("Late MCP connect failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - }; - - const persistedMCPServer = (name: string): MCPServerConfig | undefined => { - const fromConnect = (config.mcpServers ?? []).find((server) => server.name === name); - if (fromConnect !== undefined) return fromConnect; - const entry = configuredMcpEntries.find( - (server) => server.name === name && !isExaMCPPreset(server), - ); - if (entry === undefined) return undefined; - const { enabled: _enabled, ...connect } = entry; - return connect; - }; - - const applyMcpCatalog = (result: Extract): void => { - const next = nextMcpCatalog({ - source: config.mcpServersSource ?? "none", - result, - globalServers: config.settings?.mcpServers, - }); - configuredMcpEntries = next.overlayEntries; - config = { - ...config, - ...(next.settings !== undefined ? { settings: next.settings } : {}), - mcpServerEntries: next.overlayEntries, - mcpServers: next.mcpServers, - mcpServersSource: next.mcpServersSource, - }; - toolset.setMcpServersSource(next.mcpServersSource); - }; - - const applyAddedMcpCatalog = (entries: MCPServerSettingsEntry[], settings: Settings): void => { - configuredMcpEntries = entries; - const source = config.mcpServersSource ?? "none"; - const nextSource = source === "none" ? "global" : source; - config = { - ...config, - settings, - mcpServerEntries: entries, - mcpServers: resolveMcpServers(entries, undefined), - mcpServersSource: nextSource, - }; - toolset.setMcpServersSource(nextSource); - }; - - const mcpTransportForEnable = (name: string): MCPServerConfig | undefined => { - const entry = configuredMcpEntries.find((server) => server.name === name); - if (entry !== undefined) { - if (isExaMCPPreset(entry)) return createExaMCPServerConfig(); - if (entry.enabled === false) return undefined; - const { enabled: _enabled, ...connect } = entry; - return connect; - } - if (name === EXA_MCP_SERVER_NAME) return createExaMCPServerConfig(); - return undefined; - }; - - const dropConnectedMcpServer = (name: string): void => { - connectedMcpServers = connectedMcpServers.filter((server) => server.name !== name); - void persistRunSnapshot("running"); - }; - - const host = await mountRunnerHost({ - // An unnamed session shows nothing rather than a placeholder. - title: runTaskTitle, - cwd: process.cwd(), - eventEmitter: emitter, - send, - classifySubmit: (text, attachments) => - classifySubmission(text, { - hasAttachments: attachments !== undefined && attachments.length > 0, - feedbackPending: isFeedbackCapturePending(), - feedbackCaptureEnabled: true, - }), - interrupt, - deliver: routeQueuedDelivery({ - send: createLeftoverSend({ - enqueue: sessionOps.enqueue, - ingest: (text, pending) => - ingestOperatorPrompt(text, config.cwd, imageAttachmentFromPath, pending), - send: (text, pending) => { - sendAborted = false; - void sendWithAttemptIdentity(userInboundMessage(text, pending)); - }, - recordSent: (text) => { - if (text.trim().length === 0) return; - void appendSentMessage(config.cwd, sessionId, text).catch((err: unknown) => { - tuiLogger.debug("sent-message append failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - }, - captureGeneration: deliveryGeneration.capture, - onFailure: (error) => - handleSendFailure(error, currentAttemptIdentity(), { - observed: false, - presented: false, - error: undefined, - }), - }), - parentCycleLive: () => host.bridge.parentCycleLive, - deliverSteer: createLiveSteerDeliver({ - enqueue: sessionOps.enqueue, - ingest: (text, pending) => - ingestOperatorPrompt(text, config.cwd, imageAttachmentFromPath, pending), - deliver: (text, pending) => { - agentProxy.deliver(userInboundMessage(text, pending)); - }, - captureGeneration: deliveryGeneration.capture, - onFailure: (error) => - handleSendFailure(error, currentAttemptIdentity(), { - observed: false, - presented: false, - error: undefined, - }), - }), - }), - // Consent by proceeding requires the disclosure to be on screen before the - // first prompt activates the held telemetry instance: the landing shows it, - // and the shell re-files it into the transcript when the landing clears. - ...(telemetryNotice !== undefined ? { telemetryNotice } : {}), - providers: config.providers, - recentModels: listRecentModels(config.settings ?? { providers: {} }), - favoriteModels: listFavoriteModels(config.settings ?? { providers: {} }), - addProviderChoices: computeAddProviderChoices, - onConnectProvider: (providerName) => { - void (async () => { - let result: Awaited>; - // The setup surface shares the live session's renderer — a second - // CliRenderer cannot exist on the same stdin. Shell input stays - // suspended for the surface's lifetime so its keystrokes (including - // Ctrl+C to cancel the sign-in) never also reach the shell. - setShellInputSuspended(host.shell, true); - try { - result = await connectProviderInline({ - providerId: providerName, - settingsPath: trueGlobalSettingsPath, - localSettingsPath: localSettingsFile, - existing: config.settings ?? null, - persistSettings: async (apply) => { - const next = await globalSettingsWriter.updateAt(trueGlobalSettingsPath, apply); - if (next === null) throw new Error("global settings are unreadable"); - config = { ...config, settings: next }; - return next; - }, - createRenderer: () => Promise.resolve(host.renderer), - }); - } catch (err) { - systemNotice( - `Connecting ${providerName} failed: ${err instanceof Error ? err.message : String(err)}`, - ); - return; - } finally { - setShellInputSuspended(host.shell, false); - // The setup surface focused its own input; hand focus back to - // whatever shell zone owned it before the surface mounted. - applyFocus(host.shell); - } - if (!result.connected) return; - - const onDisk = await loadSettings(trueGlobalSettingsPath); - const resolvedForCatalog: ResolvedProvider = { - apiKey: config.apiKey, - baseURL: config.baseURL, - model: config.model, - providerName: config.providerName, - ...(config.keyless !== undefined ? { keyless: config.keyless } : {}), - }; - const providers = await refreshLiveProviderCatalog(onDisk, resolvedForCatalog); - config = { ...config, providers, ...(onDisk !== null ? { settings: onDisk } : {}) }; - host.refreshModels( - listRecentModels(config.settings ?? { providers: {} }), - listFavoriteModels(config.settings ?? { providers: {} }), - providers, - ); - // Reopen positioned at the account just connected — the picker's - // default open (top of list) would otherwise leave the operator to - // hunt for the row they just authorized. - const connectedName = result.providerName ?? providerName; - host.openModels?.( - result.model !== undefined ? modelOptionId(connectedName, result.model) : undefined, - ); - systemNotice(`Connected ${connectedName}. Open /model to pick a model.`); - if (isOpenCodeGoProvider({ name: providerName })) { - void prefetchGoModels() - .then(async () => { - if (hostHolder.instance === undefined) return; - const nextDisk = await loadSettings(trueGlobalSettingsPath); - const nextProviders = await refreshLiveProviderCatalog( - nextDisk, - resolvedForCatalog, - ); - config = { - ...config, - providers: nextProviders, - ...(nextDisk !== null ? { settings: nextDisk } : {}), - }; - hostHolder.instance.refreshModels( - listRecentModels(config.settings ?? { providers: {} }), - listFavoriteModels(config.settings ?? { providers: {} }), - nextProviders, - ); - }) - .catch((err: unknown) => { - tuiLogger.debug("go model prefetch failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - } - })().catch((err: unknown) => { - tuiLogger.debug("provider connect failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - }, - modelLabel: () => { - const effort = resolveSessionEffort( - config.model, - config.reasoningEffort, - isCodexProviderName(config.providerName), - ); - return { - profile: config.providerName, - model: config.model, - ...(effort !== undefined ? { effort } : {}), - }; - }, - activeModel: () => ({ provider: config.providerName, model: config.model }), - readCostSummary: () => commandContext.getCostSummary?.(), - showPromptCost: () => liveShowPromptCost, - onModelSelect: (id) => { - const sep = id.indexOf(":"); - if (sep <= 0) return; - const provider = id.slice(0, sep); - const model = id.slice(sep + 1); - applyLiveModelSwitch( - { providerName: provider, model }, - { - applyIdentity: (next) => { - config = { ...config, providerName: next.providerName, model: next.model }; - }, - setPermissionIdentity: (providerName, modelName) => { - permissionGate.setProviderIdentity(providerName, modelName); - }, - rebuildInference: (next) => { - host.bridge.setInferenceProviderId( - next.providerName, - config.settings?.providers[next.providerName]?.name, - ); - const bundle = buildSessionSources(); - agentProxy.setSources(bundle.sources, bundle.defaultSource); - }, - refreshAdvertisedSchemas: () => { - directorHolder.instance?.updateToolDefinitions( - computeAdvertised(toolset.dynamicRunner.currentDefinitions()), - ); - }, - }, - ); - - const ref: ModelRef = { provider, model }; - void (async () => { - let next: Settings | undefined; - const result = await globalSettingsWriter.mutateAt(trueGlobalSettingsPath, (onDisk) => { - next = pushRecentModel(onDisk, ref); - return next; - }); - if (result === "skipped" || next === undefined) { - throw new Error("global settings are unreadable"); - } - config = { ...config, settings: next }; - host.refreshModels(listRecentModels(next), listFavoriteModels(next)); - })().catch((err: unknown) => { - tuiLogger.debug("model selection persist failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - }, - onFavoriteToggle: (id) => { - const sep = id.indexOf(":"); - if (sep <= 0) return; - const ref: ModelRef = { provider: id.slice(0, sep), model: id.slice(sep + 1) }; - void (async () => { - let next: Settings | undefined; - const result = await globalSettingsWriter.mutateAt(trueGlobalSettingsPath, (onDisk) => { - next = toggleFavoriteModel(onDisk, ref); - return next; - }); - if (result === "skipped" || next === undefined) { - throw new Error("global settings are unreadable"); - } - config = { ...config, settings: next }; - host.refreshModels(listRecentModels(next), listFavoriteModels(next)); - })().catch((err: unknown) => { - tuiLogger.debug("favorite toggle persist failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - }, - onSetDefault: (id) => { - const sep = id.indexOf(":"); - if (sep <= 0) return; - const ref: ModelRef = { provider: id.slice(0, sep), model: id.slice(sep + 1) }; - void (async () => { - let next: Settings | undefined; - const result = await globalSettingsWriter.mutateAt(trueGlobalSettingsPath, (onDisk) => { - next = setDefaultModel( - onDisk, - ref, - config.providers.find((provider) => provider.name === ref.provider), - ); - return next; - }); - if (result === "skipped" || next === undefined) { - throw new Error("global settings are unreadable"); - } - await persistConnectedSelection(localSettingsFile, ref.provider, ref.model); - config = { ...config, settings: next }; - systemNotice(`Default set to ${ref.model} (${ref.provider})`); - })().catch((err: unknown) => { - tuiLogger.debug("set default persist failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - }, - commands: () => listCommands().map((c) => ({ name: c.name, description: c.description })), - onCommand: (name) => { - const route = routeSubmission(name); - if (route.kind === "empty") return; - if (route.kind === "command") { - dispatchCommand(route.name, route.args); - return; - } - const [commandName = "", ...rest] = route.text.split(/\s+/); - dispatchCommand(commandName, rest.join(" ")); - }, - chrome: () => ({ - tasks: directorHolder.instance?.getTasks() ?? null, - agents: subAgentSessions.listForStrip().map((s) => ({ - agentId: s.agentId, - id: s.id, - description: s.description, - status: s.status, - lifecycleStatus: s.lifecycleStatus, - currentToolName: s.currentToolName, - currentToolPreview: s.currentToolPreview, - currentToolStartedAt: s.currentToolStartedAt, - startedAt: s.startedAt, - lastActivityAt: s.lastActivityAt, - ...(s.finishedAt !== undefined ? { finishedAt: s.finishedAt } : {}), - ...(s.runInFlight !== undefined ? { runInFlight: s.runInFlight } : {}), - })), - }), - subscribeChrome: (notify) => { - const unsubscribeAgents = subAgentSessions.subscribe(notify); - emitter.on("tasks", notify); - return () => { - unsubscribeAgents(); - emitter.off("tasks", notify); - }; - }, - subAgentSessions: () => subAgentSessions.list(), - surfaces: { - permissions: { - list: async () => { - listedGrants = await permissionsAdmin.list(); - return listedGrants.map((entry, index) => ({ - id: String(index), - scopeLabel: GRANT_SCOPE_LABEL[entry.scope], - tool: entry.tool, - pattern: entry.pattern, - ...(entry.providerModel !== undefined ? { providerModel: entry.providerModel } : {}), - })); - }, - revoke: async (id) => { - const entry = listedGrants[Number(id)]; - if (entry !== undefined) await permissionsAdmin.revoke(entry); - }, - }, - plugins: { - cwd: config.cwd, - home: homedir(), - list: () => { - const cfg = pluginsAdmin.getConfig(); - return pluginsAdmin.list().map((p) => { - const mod = pluginState.modules.find((m) => m.manifest?.id === p.id); - const attributed = warningsForPluginEntry(standingPluginWarnings, { - id: p.id, - ...(p.agentProfiles !== undefined ? { agentProfiles: p.agentProfiles } : {}), - }); - return { - id: p.id, - name: p.name, - origin: p.origin, - enabled: isPluginEnabledForSurface(mod, cfg), - credentials: p.credentials, - credentialValues: cfg[p.id]?.credentials ?? {}, - ...(p.kind !== undefined ? { kind: p.kind } : {}), - ...(p.description !== undefined ? { description: p.description } : {}), - ...(p.needsTrust === true ? { needsTrust: true } : {}), - ...(p.canRevokeTrust === true ? { canRevokeTrust: true } : {}), - ...(p.agentProfiles !== undefined ? { agentProfiles: p.agentProfiles } : {}), - ...(p.pluginPath !== undefined - ? { pluginPath: p.pluginPath, originPath: p.pluginPath } - : mod?.pluginPath !== undefined - ? { pluginPath: mod.pluginPath, originPath: mod.pluginPath } - : {}), - ...(p.source !== undefined - ? { source: p.source } - : mod?.source !== undefined - ? { source: mod.source } - : {}), - ...(attributed.length > 0 ? { warnings: attributed } : {}), - }; - }); - }, - setEnabled: async (id, enabled) => { - const existing = pluginsAdmin.getConfig()[id] ?? {}; - return (await pluginsAdmin.saveConfig(id, { ...existing, enabled })) ?? undefined; - }, - saveCredentials: async (id, credentials) => { - const existing = pluginsAdmin.getConfig()[id] ?? {}; - await pluginsAdmin.saveConfig(id, { ...existing, credentials }); - }, - verify: (id, credentials) => pluginsAdmin.verify(id, credentials), - addPath: (path) => pluginsAdmin.addPath(path), - remove: (id) => pluginsAdmin.remove(id), - webProviders: () => pluginState.webCandidates.map((c) => ({ id: c.id, name: c.name })), - currentWebProvider: () => pluginsAdmin.getWebOverride(), - setWebProvider: (id) => pluginsAdmin.setWebOverride(id), - loadWarnings: () => standingPluginWarnings, - }, - mcp: { - list: () => - mergeMcpSurfaceEntries(configuredMcpEntries, mcpStates, config.mcpServers ?? []), - openAuthURL: (url) => openInBrowser(url), - subscribe: (listener) => { - emitter.on("mcp.status", listener); - return () => emitter.off("mcp.status", listener); - }, - get mcpServersSource() { - return config.mcpServersSource ?? "none"; - }, - addServer: async (name, url) => { - const result = await persistGlobalHTTPMCPServer( - globalSettingsWriter, - name, - url, - config.mcpServersSource ?? "none", - toolset.hasMCPServer, - ); - if (!result.ok) { - const message = - result.reason === "local-shadow" - ? `Cannot add a global MCP server while ${SETTINGS_DIR_NAME}/settings.json ` + - "defines mcpServers; remove that local list and restart first." - : result.reason === "duplicate" || result.reason === "active" - ? `An MCP server named "${name.trim()}" already exists or is connecting.` - : result.reason === "skipped" - ? "Could not read global settings, so no MCP server was added." - : result.reason === "invalid-name" - ? (validateMCPServerName(name.trim()) ?? "Enter a valid server name first.") - : "Enter an absolute HTTP(S) URL first."; - return { ok: false, message }; - } - applyAddedMcpCatalog(result.settings.mcpServers ?? [], result.settings); - connectLateMCPServer(result.server); - return { ok: true, message: `Added ${result.server.name}; connecting now.` }; - }, - retryServer: async (name) => { - const server = persistedMCPServer(name); - if (server === undefined) { - return { - ok: false, - message: `No persisted MCP server named "${name}" to retry.`, - }; - } - connectLateMCPServer(server); - return { ok: true, message: `Retrying ${server.name}; connecting now.` }; - }, - setEnabled: async (name, enabled) => { - const source = config.mcpServersSource ?? "none"; - const result = - source === "local" - ? await persistLocalMCPServerEnabled(localSettingsWriter, name, enabled) - : await persistMCPServerEnabled(globalSettingsWriter, name, enabled); - if (!result.ok) { - const verb = enabled ? "enable" : "disable"; - const message = - result.reason === "skipped" - ? `Could not read settings, so the MCP server was not ${verb}d.` - : `No MCP server named "${name}" to ${verb}.`; - return { ok: false, message }; - } - applyMcpCatalog(result); - if (!enabled) { - await toolset.disconnectMCPServer(name, mcpConnectCallbacks); - dropConnectedMcpServer(name); - return { ok: true, message: `Disabled ${name}.` }; - } - const server = mcpTransportForEnable(name); - if (server !== undefined) connectLateMCPServer(server); - return { ok: true, message: `Enabled ${name}; connecting now.` }; - }, - removeServer: async (name) => { - if ( - isBuiltinRow( - name, - configuredMcpEntries.find((entry) => entry.name === name), - config.mcpServers ?? [], - ) - ) { - return { ok: false, message: "Built-in Exa cannot be removed." }; - } - const source = config.mcpServersSource ?? "none"; - const result = - source === "local" - ? await persistLocalMCPServerRemoved(localSettingsWriter, name) - : await persistMCPServerRemoved(globalSettingsWriter, name); - if (!result.ok) { - const message = - result.reason === "builtin-exa" - ? "Built-in Exa cannot be removed." - : result.reason === "skipped" - ? "Could not read settings, so the MCP server was not removed." - : `No MCP server named "${name}" to remove.`; - return { ok: false, message }; - } - applyMcpCatalog(result); - await toolset.disconnectMCPServer(name, mcpConnectCallbacks); - mcpStates.delete(name); - dropConnectedMcpServer(name); - for (const server of config.mcpServers ?? []) { - connectLateMCPServer(server); - } - return { ok: true, message: `Removed ${name}.` }; - }, - }, - hooks: { - list: () => - hookManager.getStatuses().map((status) => ({ - id: status.id, - name: status.name, - type: status.type, - path: status.path, - enabled: status.enabled, - runsOn: hookRunsOn.get(status.id) ?? "see file", - })), - setEnabled: (id, enabled) => setHookEnabled(id, enabled), - }, - settings: { - read: () => ({ - compactionMode: liveCompactionMode, - waitForApproval: resolveWaitForApproval(liveToolWatchdog), - telemetryEnabled: liveTelemetryIntent, - showPromptCost: liveShowPromptCost, - }), - setCompactionMode: (mode) => { - liveCompactionMode = mode; - void persistGlobalSettings("compaction mode", (base) => ({ - ...base, - compactionMode: mode, - })); - }, - setWaitForApproval: (value) => { - liveToolWatchdog.waitForApproval = value; - void persistGlobalSettings("wait-for-approval", (base) => ({ - ...base, - tools: { ...base.tools, waitForApproval: value }, - })); - }, - setTelemetryEnabled: (enabled) => { - // Only flip the live intent when the toggle is accepted. Env kill - // switches refuse re-enable; leaving the UI on while capture stays - // off is a silent lie. - if (!onChangeTelemetryEnabled(enabled)) { - systemNotice("Telemetry stays off — disabled by DO_NOT_TRACK or CORBITS_TELEMETRY."); - return; - } - liveTelemetryIntent = enabled; - }, - setShowPromptCost: (value) => { - liveShowPromptCost = value; - host.refreshCostContext(); - void persistGlobalSettings("show prompt cost", (base) => ({ - ...base, - showPromptCost: value, - })); - }, - hooksSummary: () => { - const statuses = hookManager.getStatuses(); - return { - discovered: statuses.length, - off: statuses.filter((s) => !s.enabled).length, - }; - }, - openHooks: () => dispatchCommand("hooks", ""), - }, - }, - }); - hostHolder.instance = host; - - if (config.providers.some((p) => isOpenCodeGoProvider(p))) { - void prefetchGoModels() - .then(async () => { - if (hostHolder.instance === undefined) return; - const onDisk = await loadSettings(trueGlobalSettingsPath); - const resolvedForCatalog: ResolvedProvider = { - apiKey: config.apiKey, - baseURL: config.baseURL, - model: config.model, - providerName: config.providerName, - ...(config.keyless !== undefined ? { keyless: config.keyless } : {}), - }; - const providers = await refreshLiveProviderCatalog(onDisk, resolvedForCatalog); - config = { ...config, providers, ...(onDisk !== null ? { settings: onDisk } : {}) }; - hostHolder.instance.refreshModels( - listRecentModels(config.settings ?? { providers: {} }), - listFavoriteModels(config.settings ?? { providers: {} }), - providers, - ); - }) - .catch((err: unknown) => { - tuiLogger.debug("go model prefetch failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - } - - const shutdownRuntime = createRuntimeShutdown({ - disposeHost: host.dispose, - cancelWorkers: () => { - subAgentSessions.cancelAll("Session closed"); - }, - closeAgent: () => currentAgent.close(), - }); - crashGuard.setDisposeHost(() => { - void shutdownRuntime(); - }); - setActiveDisposeHost(() => crashGuard.invokeDisposeHost()); - - // Harness inference.error events omit providerId; stamp the live catalog id - // onto the stream map so transcript copy can identify known-xAI short 429s. - stampProvider.fn = (id) => - host.bridge.setInferenceProviderId( - id, - id === undefined ? undefined : config.settings?.providers[id]?.name, - ); - stampProvider.fn(config.providerName); - - setMentionSuggestionSource(host.shell, (prefix) => listPathSuggestions(prefix, config.cwd)); - - // The fleet reports itself. Store changes drive it, so a lane finishing or - // failing is on screen the moment it happens rather than at the next turn - // boundary. The settle timer coalesces a parallel burst into one observation; - // the stall poll re-runs so a lane that goes quiet with no further store - // event is still announced once. `observeFleet` decides what is worth saying. - let fleetWatch = createFleetWatch(); - const reportFleet = (): void => { - const observation = observeFleet(fleetWatch, subAgentSessions.list(), Date.now()); - fleetWatch = observation.watch; - for (const update of observation.updates) surfaceSystemNotice(host.shell, update); - }; - let fleetSettle: ReturnType | null = null; - // Live-lane count feeds the bridge's idle-with-fleet hold (CL-7057): the - // run stays busy after the parent turn settles until the last lane - // terminalizes. Store notifications fire per child event, not per status - // flip, so emit only when the count itself moves. - let lastLiveFleet = 0; - const unsubscribeFleetReport = subAgentSessions.subscribe(() => { - const liveFleet = liveFleetCount(subAgentSessions.list()); - if (liveFleet !== lastLiveFleet) { - lastLiveFleet = liveFleet; - emitter.emit("event", { type: "fleet", running: liveFleet }); - } - if (fleetSettle !== null) return; - fleetSettle = setTimeout(() => { - fleetSettle = null; - reportFleet(); - }, FLEET_REPORT_SETTLE_MS); - if (typeof fleetSettle.unref === "function") fleetSettle.unref(); - }); - const fleetStallPoll = setInterval(reportFleet, FLEET_STALL_POLL_MS); - if (typeof fleetStallPoll.unref === "function") fleetStallPoll.unref(); - - // Registered slash-command names only — bare skill/agent words stay unstyled. - setPromptRecognitionSource(host.shell, () => ({ - commandNames: listCommands().map((command) => command.name), - })); - - // Shift+Tab: cycle reasoning effort for the live model and rebuild sources so - // the next inference turn picks up the new providerOptions.reasoning_effort. - setEffortCycleHandler(host.shell, () => { - const next = cycleReasoningEffort( - config.model, - config.reasoningEffort, - isCodexProviderName(config.providerName), - ); - if (next === undefined) { - setStatusFlash(host.shell, "this model has no reasoning effort levels", { - ttlMs: RUNTIME_FLASH_MS, - }); - return; - } - config = { ...config, reasoningEffort: next }; - const bundle = buildSessionSources(); - agentProxy.setSources(bundle.sources, bundle.defaultSource); - setPromptModelLabel(host.shell, { - profile: config.providerName, - model: config.model, - effort: next, - }); - setStatusFlash(host.shell, `reasoning effort: ${next}`, { - ttlMs: RUNTIME_FLASH_MS, - }); - }); - - // Recall spans the whole session, including what was sent before a resume. - void loadSentMessages(config.cwd, sessionId) - .then((sent) => setSentMessageHistory(host.shell, sent)) - .catch(() => undefined); - - if (!resumeSkipInitialTask && config.task.trim().length > 0) { - // The operator's initial task, typed as a CLI argument before launch — - // same provenance as a prompt submit. - void sendWithAttemptIdentity(userInboundMessage(config.task.trim(), [])); - } - - // Hydrate a resumed session's transcript after first paint. Reading history and - // mapping it to content blocks is pure I/O with no bearing on the shell, so the - // App renders empty immediately and fills in the past turns once they are ready. - // Only the tail needed to fill RESUME_TRANSCRIPT_BLOCK_LIMIT blocks is read from - // disk — a long session's full history is not needed just to paint a transcript - // that itself caps how much it displays. - void loadRecentTurns(workdir, RESUME_TRANSCRIPT_BLOCK_LIMIT) - .then((turns) => { - const blocks = turnsToContentBlocks(turns, { maxBlocks: RESUME_TRANSCRIPT_BLOCK_LIMIT }); - const tasks = hydrateTasksFromTurns(turns); - // Restored tasks go to the panel only. They are live state, not something - // that happened in the conversation, so putting them in scrollback as well - // renders the same list twice on one screen. - if (tasks.length > 0) directorHolder.instance?.restoreTasks(tasks); - if (blocks.length > 0) emitter.emit("history.hydrate", blocks); - }) - .catch((err: unknown) => { - // Resume still works without painted history, but a silent empty - // transcript looks like a brand-new session. Log and surface a one-line - // error block so the operator knows history failed to load. - const block = resumeTranscriptLoadErrorBlock(err); - tuiLogger.warn("Failed to load resume transcript from {workdir}: {error}", { - workdir, - error: err instanceof Error ? err.message : String(err), - }); - emitter.emit("history.hydrate", [block]); - }); - - // Connect MCP servers after the TUI is up so the UI is usable immediately and - // any OAuth authorization is surfaced as a copyable link rather than a browser - // pop. Each connected server's tools land on the live runner and are - // dispatchable the same turn (createAgentWithLiveToolDispatch). They stay - // unadvertised until tool_search promotes them. When every server has - // settled, reload-if-idle so construction-time maps match, then resume any - // persisted workflow. Aborted on exit so an unfinished auth wait does not - // keep the process alive. - void toolset - .connectMCP(mcpConnectCallbacks, mcpConnectController.signal) - .then(async () => { - if (toolset.dynamicRunner.currentDefinitions().length > baseToolCount) { - pendingReload = true; - reloadIfIdle(); - } - // Now that the capability map reflects connected MCP servers, restore any - // persisted workflow. New workflows are manual-only slash commands. - await workflowController.resume(); - }) - .catch((err: unknown) => { - // Fire-and-forget: an aborted connect on exit is expected and ignored; - // any other failure is logged rather than raised as an unhandled rejection. - if (err instanceof Error && err.name === "AbortError") return; - getLogger([LOG_NAMESPACE_ROOT, "tui", "mcp"]).error("MCP connect failed: {error}", { - error: err instanceof Error ? err.message : String(err), - }); - }); - - // Surface fire-and-forget startup notices now that there is a shell (queued - // above, before `host` existed). Plugin load warnings are NOT notices — they - // drive `plugin !` and `/plugins` instead. - for (const notice of startupPluginNotices) surfaceSystemNotice(host.shell, notice); - paintPluginAttention = (needs) => setPluginNeedsAttention(host.shell, needs); - paintPluginAttention(standingPluginWarnings.length > 0); - - // The persisted /yolo default is otherwise silent: nothing on screen would - // otherwise tell the operator that permission prompts are off for a repo - // they never ran --dangerously-skip-permissions or /yolo in. - if (config.skipPermissionsFromSettings) { - surfaceSystemNotice( - host.shell, - "Permission prompts are disabled by your saved default (/yolo off to re-enable).", - ); - } - - // Soft upgrade check: never blocks startup; offline / rate-limit is a quiet skip. - // surfaceSystemNotice keeps the landing hero up and flushes into the transcript - // once a session row ends the landing (same path as MCP startup chatter). - scheduleUpgradeNotice({ - notify: (text) => surfaceSystemNotice(host.shell, text), - options: { - currentVersion: typeof pkg.version === "string" ? pkg.version : "0.0.0", - }, - }); - - await host.waitUntilExit(); - // Stop inference and every worker before persistence, hooks, or telemetry can - // delay process exit. Closing the terminal is a process-lifetime boundary. - await shutdownRuntime(); - clearInterval(fleetStallPoll); - if (fleetSettle !== null) clearTimeout(fleetSettle); - unsubscribeFleetReport(); - // Quitting mid-stream is an abnormal end for the in-flight cycle: nothing - // downstream delivers its terminal event once the app is gone. - await cycleRecorder.dispose("exit"); - mcpConnectController.abort(); - - const finishedAt = Date.now(); - const turnCollector = runSink.getTurnCollector(); - const sinkError = runSink.getRunError(); - const summaryStatus = runSink.getStatus(); - // RunSummary's status ("done" | "failed" | "cancelled") maps directly onto - // RunState's terminal statuses — no fallback to "running" here, otherwise a - // finished run (finishedAt set) can be left reading as still in progress. - const persistedStatus: RunState["status"] = summaryStatus; - crashGuard.markFinalized(); - // The run itself is over here, so this write clears the active-run handle - // (via finalizeRunState in state.ts) in the same call, rather than pairing - // the on-disk write with a separate in-memory statement at this call site. - // The dispose host has no on-disk counterpart to piggyback on, so it still - // needs its own clear here, mirroring finalizeOnCrash — otherwise a signal - // arriving after this normal exit would find a handle pointing at a - // torn-down closure. - clearActiveDisposeHost(); - await writeRunSnapshot( - persistedStatus, - { - finishedAt, - ...(sinkError !== undefined ? { error: sinkError } : {}), - }, - "run-end", - ); - const runSummary = createRunSummary({ - task: runTaskTitle.length > 0 ? runTaskTitle : config.task, - status: summaryStatus, - startedAt, - finishedAt, - turnsUsed: runSink.getTurnCount(), - tokenUsage: runSink.getTokenUsage(), - turns: turnCollector?.getTurns() ?? [], - toolCallCount: runSink.getToolCallCount(), - ...(sinkError !== undefined ? { error: sinkError } : {}), - }); - await hookManager.dispatchPostRun(runSummary); - // exit_reason mirrors status at present — "cancelled" covers both an - // operator interrupt and Ctrl+C, since the emit site here cannot tell them - // apart (runSink only distinguishes done/failed/cancelled). - const exitReason = - runSummary.status === "done" - ? "done" - : runSummary.status === "failed" - ? "error" - : "cancelled"; - getTelemetry().capture("session_end", { - status: runSummary.status, - turn_count: runSummary.turnsUsed, - duration_ms: runSummary.durationMs, - session_mode: liveSessionMode, - exit_reason: exitReason, - }); - // Bound against process.exit dropping the session_end capture for short - // sessions; flush itself is deadline-capped so exit stays snappy. - // PerfTrace OTEL export runs once at process exit in main (flushPerfToOtel). - await getTelemetry().flush(); - - await sessionOps.awaitTail(); - try { - await streamPromise; - } catch { - // ignore - } - await toolset.dispose(); - - return resolveExitCode({ - runError, - sinkError, - status: runSink.getStatus(), - }); - } catch (err) { - // Terminal first: state persistence below can await disk I/O, and every - // millisecond before this runs is a millisecond the operator is staring at - // a frozen alternate screen. Kept outside finalizeOnCrash because that - // short-circuits once the clean path has marked the run finalized, and a - // throw after that point still has to give the terminal back. - try { - crashGuard.invokeDisposeHost(); - } catch (disposeErr: unknown) { - tuiLogger.warn("crash finalize: host dispose failed: {error}", { - error: disposeErr instanceof Error ? disposeErr.message : String(disposeErr), - }); - } - await crashGuard.finalizeOnCrash(err); - throw err; - } -} diff --git a/src/tui/runner/commands.ts b/src/tui/runner/commands.ts new file mode 100644 index 000000000..78b4bd7a1 --- /dev/null +++ b/src/tui/runner/commands.ts @@ -0,0 +1,223 @@ +/** + * Command layer for the TUI runner: slash-command registry population, the + * command context the handlers run against, and result surfacing. + */ + +import { getLogger } from "@intx/log"; +import type { CommandContext, CommandResult } from "../commands/registry.js"; +import { getCommand, setHiddenCommands } from "../commands/registry.js"; +import { registerBuiltInCommands } from "../commands/built-in.js"; +import { registerCommandPlugins, registerWorkflowPlugins } from "../../plugins/register.js"; +import type { PluginModule } from "../../plugins/loader.js"; +import type { PluginConfig } from "../../config/settings.js"; +import { persistSkipPermissionsDefault, type Settings } from "../../config/settings.js"; +import { getTelemetry } from "../../telemetry/singleton.js"; +import { captureSlashCommand } from "../../telemetry/product-events.js"; +import { + armFeedbackCapture, + cancelFeedbackCapture, + captureFeedback, + feedbackResultMessage, + getLastTurnTraceId, +} from "../../telemetry/feedback.js"; +import { getActivePricingCache } from "../../cost/cost-visibility.js"; +import { formatCost } from "../../cost/faremeter.js"; +import { + buildCostSummary, + maskContextMeterWhenNoTurns, + type CostSummary, +} from "../../cost/cost-summary.js"; +import { contextTokensFromUsage } from "../../provider/context-window.js"; +import { fleetDigest } from "../../subagent/index.js"; +import { renameSession } from "../../session/index.js"; +import { truncateSessionLabel } from "../../session/session-label.js"; +import { surfaceSystemNotice, attachClipboardImage } from "../shell/prompt.js"; +import type { InferenceErrorLike } from "../../inference-gateway-error.js"; +import { terminalProviderFailureMessage } from "../../inference-error-message.js"; +import type { InferenceAttemptIdentity } from "./state.js"; +import { hostOf, type RunnerServices, type RunnerState } from "./state.js"; +import { userInboundMessage } from "./submit.js"; +import { LOG_NAMESPACE_ROOT } from "../../branding.js"; + +const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + +export function surfaceTerminalProviderFailure( + shell: Parameters[0], + providerId: string, + error: InferenceErrorLike, + displayLabel?: string, +): void { + surfaceSystemNotice(shell, terminalProviderFailureMessage(providerId, error, displayLabel)); +} + +/** + * Populate the slash-command registry for a session: built-ins first, then + * enabled plugin commands and workflows, then the hidden-command filter. + * + * Exported so the production wiring is testable — built-in registration used to + * ride on an import side effect and silently disappeared when its only importer + * was deleted. + */ +export function setUpCommandRegistry( + settings: Settings | undefined, + plugins: PluginModule[], + getPluginConfig: () => Record = () => settings?.plugins ?? {}, +): void { + registerBuiltInCommands(); + registerWorkflowPlugins(plugins, getPluginConfig()); + registerCommandPlugins(plugins, getPluginConfig); + setHiddenCommands(settings?.hiddenCommands ?? []); +} + +export interface CommandLayer { + currentAttemptIdentity: () => InferenceAttemptIdentity; + commandContext: CommandContext; +} + +/** + * Wire the dispatch path: the command context handlers run against, result + * surfacing, and the attempt identity the submit path reports failures + * against. + */ +export function createCommandLayer(state: RunnerState, services: RunnerServices): CommandLayer { + const currentAttemptIdentity = (): InferenceAttemptIdentity => { + const displayLabel = state.config.settings?.providers[state.config.providerName]?.name; + return { + providerId: state.config.providerName, + ...(displayLabel !== undefined ? { displayLabel } : {}), + }; + }; + state.currentAttemptIdentity = currentAttemptIdentity; + + const commandContext: CommandContext = { + signalClear: () => state.newSession?.(), + getSkipPermissions: () => services.permissionGate.getSkipPermissions(), + setSkipPermissions: (value: boolean) => { + services.permissionGate.setSkipPermissions(value); + state.config.dangerouslySkipPermissions = value; + void services.globalSettingsWriter.enqueue(async () => { + try { + const result = await persistSkipPermissionsDefault( + state.config.globalSettingsPath, + value, + ); + if (result === "skipped") { + state.systemNotice?.("Yolo flipped for this session, but the default did not stick."); + } + } catch { + state.systemNotice?.("Yolo flipped for this session, but the default did not stick."); + } + }); + }, + getCostSummary: (): CostSummary => { + const usage = services.runSink.getTokenUsage(); + const lastTurnUsage = services.runSink.getLastTurnUsage(); + const pricingCache = getActivePricingCache(); + const billed = services.sessionCost.snapshot(); + const totalCost = billed.meteredCost; + // A provider that omits or zeroes usage would otherwise pin the meter at + // 0% forever; fall back to the director's local estimate (turns plus + // system-prompt/tool-schema overhead). The governor already decided + // whether it's estimating when it computed this turn's arming — trust + // that decision rather than re-deriving it from a second usage read. + const contextEstimate = services.directorHolder.instance?.getContextEstimate(); + const isEstimate = contextEstimate !== undefined && contextEstimate.isEstimate; + const summary = buildCostSummary({ + modelId: state.config.model, + baseURL: state.config.baseURL, + providerName: state.config.providerName, + pricingCache, + totalCost, + formattedCost: formatCost(totalCost), + inputTokens: usage.input, + outputTokens: usage.output, + cacheReadTokens: usage.cacheRead, + contextTokens: isEstimate ? contextEstimate.tokens : contextTokensFromUsage(lastTurnUsage), + contextIsEstimate: isEstimate, + sessionBillingMix: billed.mix, + sessionHiddenReason: billed.hiddenReason, + }); + return maskContextMeterWhenNoTurns(summary, services.runSink.getTurnCount()); + }, + startWorkflow: (name) => services.workflowController.start(name), + getFleetStatus: () => fleetDigest(services.subAgentSessions.list(), Date.now()), + renameSession: (name) => { + const trimmed = name.trim(); + if (trimmed.length === 0) return "Session name cannot be empty"; + state.runTaskTitle = trimmed; + services.emitter.emit("session.title", truncateSessionLabel(state.runTaskTitle)); + void renameSession(state.config.cwd, state.sessionId, trimmed) + .then(() => state.persistRunSnapshot?.("running")) + .catch((err: unknown) => { + tuiLogger.warn("rename session failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + return undefined; + }, + submitFeedback: (text) => { + // Inline /feedback must drop a prior bare-/feedback arm so the + // next normal prompt is not stolen as survey text. + cancelFeedbackCapture(); + const status = captureFeedback(getTelemetry(), text, { + turnTraceId: getLastTurnTraceId(), + }); + return feedbackResultMessage(status); + }, + beginFeedbackCapture: () => { + armFeedbackCapture(); + }, + }; + + const applyCommandResult = (result: CommandResult): void => { + switch (result.type) { + case "message": + state.systemNotice?.(result.text); + return; + case "send": + // A command the operator typed and submitted at the prompt — same + // provenance as a plain-text send, just composed by the command + // handler instead of typed verbatim. + void state.sendWithAttemptIdentity?.(userInboundMessage(result.text, [])); + return; + case "workflow": + state.systemNotice?.(services.workflowController.start(result.name)); + return; + case "noop": + return; + case "overlay": + if (!hostOf(state).openSurface(result.overlay)) { + const named = result.overlay === "add-provider" ? "connect" : result.overlay; + state.systemNotice?.(`No surface for /${named}.`); + } + return; + case "modal": + // /model is the only modal reachable from a command; provider login is + // reached from the picker itself. + if (result.modal === "agent" && hostOf(state).openSurface("models")) return; + state.systemNotice?.(`${result.modal} is not available in this renderer yet`); + return; + case "view": + state.systemNotice?.(`${result.view} is not available in this renderer yet`); + return; + case "paste-image": + void attachClipboardImage(hostOf(state).shell); + return; + } + }; + + const dispatchCommand = (name: string, args: string): void => { + const command = getCommand(name); + if (command === undefined) { + state.systemNotice?.(`Unknown command: ${name}`); + return; + } + // Plugins register into the same command registry as the built-ins, so an + // unrecognised name is plugin-authored and is bucketed rather than sent. + // Shared emitter so TUI and any headless path report the same event. + captureSlashCommand(getTelemetry(), command.name); + applyCommandResult(command.handler(args, commandContext)); + }; + state.dispatchCommand = dispatchCommand; + return { currentAttemptIdentity, commandContext }; +} diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts new file mode 100644 index 000000000..36f76427d --- /dev/null +++ b/src/tui/runner/exit.ts @@ -0,0 +1,599 @@ +/** + * Exit and rebuild paths for the TUI runner: the exit-code contract, the + * close/rebuild failure helpers, the run.json snapshot writers, the agent + * lifecycle (reload-if-idle, interrupt, session rotation, the stable agent + * proxy), the stream sink, and the quit-time finalization tail. + */ + +import { AgentContextLockError, type Agent } from "@intx/agent"; +import { getLogger } from "@intx/log"; +import type { InferenceSource } from "@intx/types/runtime"; +import { consumeStream } from "../../session/stream-consumer.js"; +import { getTelemetry } from "../../telemetry/singleton.js"; +import { onTurnBoundary } from "../../agent/reactor-events.js"; +import { setAgentSourceUnlessClosed } from "../agent-source-sync.js"; +import { billingIdentityFromSource } from "../../cost/session-cost.js"; +import { createRunSummary, type RunSummary } from "../../session/hooks.js"; +import { finalizeRunState, saveState, type RunState } from "../../session/state.js"; +import { generateSessionId, initSessionDir, sessionContextDir } from "../../session/index.js"; +import { resolveSessionLabel, truncateSessionLabel } from "../../session/session-label.js"; +import { clearActiveDisposeHost } from "../../session/active-host.js"; +import { getValidCodexToken } from "../../auth/codex/session.js"; +import { getValidXaiToken } from "../../auth/xai/session.js"; +import { suppressProviderFailurePresentation } from "../provider/failure-attempt.js"; +import { normalizeInferenceErrorForTerminal } from "../../inference-gateway-error.js"; +import { codexProfileFromProviderName } from "../../config/codex-providers.js"; +import { xaiProfileFromProviderName } from "../../config/xai-providers.js"; +import { LOG_NAMESPACE_ROOT } from "../../branding.js"; +import { cancelFeedbackCapture } from "../../telemetry/feedback.js"; +import { + hostOf, + liveAgent, + recordRunError, + type RunnerServices, + type RunnerState, + type SnapshotExtra, + type SnapshotKind, + type SnapshotStatus, +} from "./state.js"; + +const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + +export interface ResolveExitCodeArgs { + runError: string | undefined; + sinkError: string | undefined; + status: RunSummary["status"]; +} + +export function resolveExitCode(args: ResolveExitCodeArgs): number { + const { runError, sinkError, status } = args; + if (runError !== undefined || sinkError !== undefined || status !== "done") { + return 1; + } + return 0; +} + +/** One-line transcript block when resume history fails to load. */ +export function resumeTranscriptLoadErrorBlock(err: unknown): { + type: "error"; + message: string; +} { + const message = err instanceof Error ? err.message : String(err); + return { type: "error", message: `Could not load prior session transcript: ${message}` }; +} + +// The agent package releases its workdir lock at the very end of close(), +// after reactor.abort()/sendQueue.drain() and the shutdown-complete race have +// all run. If any of that throws (most likely right when an operator +// interrupts mid-inference, which is exactly when those paths are under +// stress), the lock is never released — and because the agent is already +// marked closed internally, retrying close() is a silent no-op that can +// never release it either. Every rebuild site that reuses the *same* workdir +// (interrupt, reloadIfIdle) must treat that as fatal for the current rebuild +// instead of calling buildAgent() again: a second createAgent() for the same +// workdir is then guaranteed to throw AgentContextLockError for a lock +// nothing will ever free, which is the "agent already open" crash. Session +// rotation (newSession) is the one rebuild site that does NOT route through +// this helper: it always points buildAgent() at a freshly minted workdir +// before rebuilding, so a leaked lock on the old workdir can never be +// re-acquired there — see the comment at its close() call for why. +export async function closeAgentForRebuild(agent: Agent, context: string): Promise { + try { + await agent.close(); + return true; + } catch (err) { + tuiLogger.debug(`agent.close during ${context} teardown failed: {error}`, { + error: err instanceof Error ? err.message : String(err), + }); + return false; + } +} + +// Every rebuild site funnels its failure (a lock left held by a failed +// close, or any other buildAgent failure) through here so it surfaces as a +// plain-language, caught error rather than an unhandled rejection. +export function agentRebuildFailure(err: unknown): Error { + return err instanceof AgentContextLockError + ? new Error( + "Could not start a new agent: the previous one did not shut down cleanly. Restart Corbits to continue.", + ) + : err instanceof Error + ? err + : new Error(String(err)); +} + +export function clearsActiveRun(kind: SnapshotKind): boolean { + return kind === "run-end"; +} + +/** + * The run.json snapshot writers. Pure over (state, services), so the + * lifecycle and the finalize tail can each build their own instance. + */ +function createRunPersistence(state: RunnerState, services: RunnerServices) { + const writeRunSnapshot = async ( + status: SnapshotStatus, + extra?: SnapshotExtra, + kind: SnapshotKind = "progress", + ): Promise => { + const task = + state.runTaskTitle.trim().length > 0 ? state.runTaskTitle.trim() : "(conversation)"; + const model = `${state.liveSource.id}:${state.liveSource.model}`; + // Kept in step with every persisted snapshot so the crash handler's copy + // (activeRunHandle, read by index.ts) never lags what's actually on disk. + services.activeRunHandle.task = task; + services.activeRunHandle.startedAt = state.startedAt; + services.activeRunHandle.model = model; + const persisted: RunState = { + status, + turnsUsed: services.runSink.getTurnCount(), + task, + startedAt: state.startedAt, + model, + mcpServers: state.connectedMcpServers, + ...extra, + }; + if (clearsActiveRun(kind)) { + await finalizeRunState(state.config.cwd, state.sessionId, persisted); + } else { + await saveState(state.config.cwd, state.sessionId, persisted); + } + }; + + // Progress snapshots are fired unsequenced (model switch, MCP connect, turn + // completion), so a straggler could otherwise land after the terminal write + // and resurrect status "running" — atomicWrite is last-rename-wins. Once the + // run is finalized, drop them; the run-ending path writes through + // writeRunSnapshot directly. + // + // Never a "run-end" write: everything routed here happens while the process + // is still alive and must stay crash-coverable, including the rotation + // "done" that closes out a session on /clear or /new. + const persistRunSnapshot = async ( + status: SnapshotStatus, + extra?: SnapshotExtra, + kind: Exclude = "progress", + ): Promise => { + if (services.crashGuard.isFinalized()) return; + await writeRunSnapshot(status, extra, kind); + }; + + return { writeRunSnapshot, persistRunSnapshot }; +} + +/** + * Build the mutable run lifecycle over the assembled session: snapshot + * persistence, the stream sink, the initial agent build, and the + * rebuild/rotation paths. Wires interrupt/newSession/agentProxy onto the + * state slots the command, submit, and host layers read. + */ +export async function createRunLifecycle( + state: RunnerState, + services: RunnerServices, +): Promise<{ interrupt: () => void; newSession: () => void; agentProxy: Agent }> { + const { persistRunSnapshot } = createRunPersistence(state, services); + state.persistRunSnapshot = persistRunSnapshot; + + // Cycles persist to the context store only on inference.done; the assembled + // recorder keeps the in-flight cycle's text so an errored or interrupted + // turn leaves its partial output in partial.jsonl instead of vanishing. + const providerFailureAttempts = services.providerFailureAttempts; + services.crashGuard.setPartialFlush(() => + services.cycleRecorder.dispose("crashed").then(() => undefined), + ); + const streamSink = (event: Parameters[0]): void => { + let eventForSink = event; + if (event.type === "message.received") { + providerFailureAttempts.advanceToNextMessage(); + } else if (event.type === "inference.start" || event.type === "inference.done") { + providerFailureAttempts.reset(); + } else if (event.type === "inference.error") { + const error = event.data.error; + const executingAttempt = providerFailureAttempts.current(); + const providerId = + "providerId" in error && typeof error.providerId === "string" + ? error.providerId + : (executingAttempt?.providerId ?? state.config.providerName); + providerFailureAttempts.observe(normalizeInferenceErrorForTerminal(error, providerId)); + } else if (event.type === "connector.reply") { + const reply = providerFailureAttempts.consumeConnectorReply(); + if (reply?.suppressPresentation === true) { + eventForSink = suppressProviderFailurePresentation(event); + } + } else if (event.type === "message.run.ended") { + providerFailureAttempts.consumeTerminal(); + } + services.runSink.sink(eventForSink); + services.cycleRecorder.handleEvent(event); + if (onTurnBoundary(event)) { + services.sessionCost.addTurn(event.data.usage, billingIdentityFromSource(event.data.source)); + } + }; + + state.currentAgent = await services.buildAgent(); + await persistRunSnapshot("running"); + void resolveSessionLabel(state.config.cwd, state.sessionId, state.runTaskTitle).then((label) => { + services.emitter.emit("session.title", label); + }); + state.streamPromise = consumeStream(liveAgent(state).stream(), streamSink); + + // Serial operation queue. Rotation (reload, interrupt, newSession), compaction + // continuation, and proxy deliver enqueue async tasks; they run one at a time. + // `send` awaits the tail before dispatching so it never races a concurrent rebuild. + const enqueueOp = services.sessionOps.enqueue; + + const reloadIfIdle = (): void => { + if (!state.pendingReload || state.inFlight > 0) return; + state.pendingReload = false; + void enqueueOp(async () => { + try { + const old = liveAgent(state); + const closedCleanly = await closeAgentForRebuild(old, "reload"); + await state.streamPromise?.catch((err: unknown) => { + tuiLogger.debug("stream drain during reload teardown failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + if (!closedCleanly) { + throw new AgentContextLockError(state.workdir); + } + state.currentAgent = await services.buildAgent(); + state.streamPromise = consumeStream(liveAgent(state).stream(), streamSink); + // The rebuild made a fresh director; re-attach the active workflow. + services.workflowController.reattach(); + } catch (err) { + recordRunError(state, err); + state.fatalBuildError = agentRebuildFailure(err); + } + }); + }; + state.reloadIfIdle = reloadIfIdle; + + // tool_search (and contextual triggers, e.g. the lsp hint) promote tools into + // the advertised set. Advertising takes effect on the next infer; a reload is + // scheduled so a newly connected MCP tool also becomes dispatchable after a + // rebuild (built-in tools are already dispatchable, so promoting them alone + // needs no reload, but the reload is a cheap no-op in that case). + const promoteTools = (names: string[]): void => { + if (!services.activatedToolNames.activate(names)) return; + services.directorHolder.instance?.updateToolDefinitions( + services.computeAdvertised(services.toolset.dynamicRunner.currentDefinitions()), + ); + state.pendingReload = true; + reloadIfIdle(); + }; + services.toolset.setToolPromoter(promoteTools); + + // The active Codex source, tracked whenever a "codex/" source is + // selected so its access token can be refreshed before each send. Seeded from + // config when the session starts on a Codex profile (buildAgent sets that + // source directly, not through the proxy's setSource). + state.activeCodexSource = + state.initialCodexProfile !== undefined + ? { profile: state.initialCodexProfile, source: state.liveSource } + : undefined; + state.activeXaiSource = + state.initialXaiProfile !== undefined + ? { profile: state.initialXaiProfile, source: state.liveSource } + : undefined; + + // Refresh the active Codex access token (if any) and push it onto the live + // agent before a send. getValidCodexToken returns the stored token when still + // valid and refreshes transparently otherwise, so this satisfies "check + // before each inference call" without crashing the loop: a failure surfaces + // as a CodexAuthError naming the profile and rejects the send. + // + // The source is pushed on every send, not only when the token changed: an + // agent rebuild (tool promotion, interrupt, /clear) reseeds the source from + // the original login-time token, so unconditionally re-pushing the live token + // is what keeps the rebuilt agent from sending a stale credential. + const refreshCodexBeforeSend = async (): Promise => { + const active = state.activeCodexSource; + if (active === undefined) return; + const { access } = await getValidCodexToken(active.profile); + const source: InferenceSource = + access === active.source.apiKey ? active.source : { ...active.source, apiKey: access }; + state.activeCodexSource = { profile: active.profile, source }; + state.liveSource = source; + setAgentSourceUnlessClosed(liveAgent(state), source); + }; + + const refreshXaiBeforeSend = async (): Promise => { + const active = state.activeXaiSource; + if (active === undefined) return; + const { access } = await getValidXaiToken(active.profile); + const source: InferenceSource = + access === active.source.apiKey ? active.source : { ...active.source, apiKey: access }; + state.activeXaiSource = { profile: active.profile, source }; + state.liveSource = source; + setAgentSourceUnlessClosed(liveAgent(state), source); + }; + + // Stable handle handed to the App so the underlying agent can be swapped out + // from under it without a remount; method calls always target the live agent. + // Host mounts later; stampProvider.fn is wired once the bridge exists. + const agentProxy: Agent = { + send: async (content, opts) => { + await services.sessionOps.awaitTail(); + if (state.fatalBuildError !== null) throw state.fatalBuildError; + const trimmed = typeof content === "string" ? content.trim() : ""; + if (trimmed.length > 0 && state.runTaskTitle.trim().length === 0) { + state.runTaskTitle = trimmed.length > 240 ? `${trimmed.slice(0, 237)}...` : trimmed; + services.emitter.emit("session.title", truncateSessionLabel(state.runTaskTitle)); + void persistRunSnapshot("running"); + } + state.inFlight++; + try { + await refreshCodexBeforeSend(); + await refreshXaiBeforeSend(); + return await liveAgent(state).send(content, opts); + } finally { + state.inFlight--; + reloadIfIdle(); + } + }, + stream: () => liveAgent(state).stream(), + deliver: (message) => { + state.enqueueAgentDeliver?.(() => liveAgent(state).deliver(message)); + }, + close: () => liveAgent(state).close(), + setSource: (source) => { + const codexProfile = codexProfileFromProviderName(source.id); + const xaiProfile = xaiProfileFromProviderName(source.id); + state.activeCodexSource = + codexProfile !== undefined ? { profile: codexProfile, source } : undefined; + state.activeXaiSource = + xaiProfile !== undefined ? { profile: xaiProfile, source } : undefined; + state.liveSource = source; + state.liveSources = [source]; + state.liveDefaultSource = source.id; + setAgentSourceUnlessClosed(liveAgent(state), source); + state.stampProvider.fn?.(source.id); + void persistRunSnapshot("running"); + }, + setSources: (sources, defaultSource) => { + liveAgent(state).setSources(sources, defaultSource); + state.liveSources = sources; + state.liveDefaultSource = defaultSource; + const head = sources.find((s) => s.id === defaultSource) ?? sources[0]; + if (head !== undefined) { + const codexProfile = codexProfileFromProviderName(head.id); + const xaiProfile = xaiProfileFromProviderName(head.id); + state.activeCodexSource = + codexProfile !== undefined ? { profile: codexProfile, source: head } : undefined; + state.activeXaiSource = + xaiProfile !== undefined ? { profile: xaiProfile, source: head } : undefined; + state.liveSource = head; + state.stampProvider.fn?.(head.id); + } + void persistRunSnapshot("running"); + }, + history: () => liveAgent(state).history(), + checkpoints: (limit) => liveAgent(state).checkpoints(limit), + readAt: (hash) => liveAgent(state).readAt(hash), + get blobReader() { + return liveAgent(state).blobReader; + }, + }; + state.agentProxy = agentProxy; + + // Hard stop only (Ctrl+C / doInterrupt). Soft steer (Enter mid-run enqueue) + // and follow-up (queued drain / deliver) must never call this — those paths + // leave in-flight workers running. Closing the agent is the only thing that + // aborts the reactor mid-inference (the send signal only rejects the send + // promise); that close cascades: operationController.abort → wait_agents parent + // signal → child abort. Do not add cancelAll here — fleet cancelAll is + // reserved for /clear (newSession) and shutdown. + // Close it, drain the old stream, and rebuild a fresh agent so the next send + // works. + const interrupt = (): void => { + state.sendAborted = true; + void enqueueOp(async () => { + try { + // close() tears down stream consumers before the aborted cycle's + // inference.error is delivered, so the recorder never sees a terminal + // event for the dead cycle — dispose closes it against stray deltas + // and salvages the buffer before that teardown, so it is never lost + // or misattributed to the rebuilt agent's next cycle. + await services.cycleRecorder.dispose("interrupted"); + const closedCleanly = await closeAgentForRebuild(liveAgent(state), "interrupt"); + await state.streamPromise?.catch((err: unknown) => { + tuiLogger.debug("stream drain during interrupt teardown failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + if (!closedCleanly) { + throw new AgentContextLockError(state.workdir); + } + state.currentAgent = await services.buildAgent(); + services.cycleRecorder.reset(); + state.streamPromise = consumeStream(liveAgent(state).stream(), streamSink); + services.workflowController.reattach(); + state.fatalBuildError = null; + } catch (err) { + recordRunError(state, err); + state.fatalBuildError = agentRebuildFailure(err); + } + }); + }; + state.interrupt = interrupt; + + // /clear and /new start a fresh conversation: mint a new session id and its + // own state directory, repoint the working tree at it, and rebuild the agent + // so it resumes from an empty git-backed store. The prior session stays on + // disk under its own id, resumable later. + // + // Sub-agent lifecycle on rotation: App cancels live workers (cancelAll + + // abort handles → child agent.close) before clearing the session store so + // /clear does not leave orphaned child reactors burning tokens. + const newSession = (): void => { + services.deliveryGeneration.bump(); + cancelFeedbackCapture(); + // Wipe the painted transcript immediately. The product host listens for + // session.clear; the Ink App used to clear its own stream unconditionally + // and that path never moved to OpenTUI. + services.emitter.emit("session.clear"); + // Cancel live workers before rotation so /clear does not leave orphaned + // child reactors burning tokens under the old session id. + services.subAgentSessions.cancelAll("Session cleared"); + // Backend rotation is always enqueued regardless of contention; the queue + // serialises it behind any in-progress op. Sub-agents nest under the new + // session automatically because getWorkdirBase reads the live sessionId. + void enqueueOp(async () => { + try { + // Tear the old agent down and dispose the recorder before workdir is + // repointed: the pump can deliver stray deltas until the stream + // settles, and a dead cycle's partial must land in the session that + // produced it, not the fresh one. + await services.cycleRecorder.dispose("rotation"); + // Deliberately not routed through closeAgentForRebuild/ + // agentRebuildFailure (unlike interrupt and reloadIfIdle, CL-5753): + // rotation mints a fresh sessionId/workdir below before calling + // buildAgent(), so even a close() that leaks the old workdir's lock + // (see closeAgentForRebuild's doc comment) can never cause a second + // acquisition on that same workdir — buildAgent() always targets + // the new, unlocked directory. The old lock still leaks for the + // rest of the process, but nothing ever tries to re-acquire it, so + // there is no crash to guard against here. + await liveAgent(state) + .close() + .catch((err: unknown) => { + tuiLogger.debug("agent.close during session-rotation teardown failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + await state.streamPromise?.catch((err: unknown) => { + tuiLogger.debug("stream drain during session-rotation teardown failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + await persistRunSnapshot("done", { finishedAt: Date.now() }, "session-rotation"); + state.sessionId = generateSessionId(); + // Repointed, not cleared: the process lives on, so the crash handler + // must keep finding this handle and close out the *new* session. + services.activeRunHandle.sessionId = state.sessionId; + state.startedAt = Date.now(); + state.runTaskTitle = state.config.task; + services.emitter.emit( + "session.title", + state.runTaskTitle.trim().length > 0 + ? truncateSessionLabel(state.runTaskTitle) + : "Untitled session", + ); + state.workdir = sessionContextDir(state.config.cwd, state.sessionId); + await initSessionDir(state.config.cwd, state.sessionId); + const rotatedBundle = services.buildSessionSources(); + state.liveSources = rotatedBundle.sources; + state.liveDefaultSource = rotatedBundle.defaultSource; + state.liveSource = rotatedBundle.selected; + services.permissionGate.reset(); + services.runSink.reset(); + services.sessionCost.reset(); + state.currentAgent = await services.buildAgent(); + services.cycleRecorder.reset(); + state.streamPromise = consumeStream(liveAgent(state).stream(), streamSink); + await persistRunSnapshot("running"); + // A fresh session drops any active workflow. + services.workflowController.reset(); + state.fatalBuildError = null; + // Sink and director are empty now — repaint so the meter stays hidden + // rather than showing the pre-clear occupancy until the next turn. + services.hostHolder.instance?.refreshCostContext(); + } catch (err) { + recordRunError(state, err); + state.fatalBuildError = err instanceof Error ? err : new Error(String(err)); + } + }); + }; + state.newSession = newSession; + return { interrupt, newSession, agentProxy }; +} + +/** + * The quit path: everything from waitUntilExit through the terminal run.json + * write, post-run hooks, telemetry flush, and teardown awaits, returning the + * process exit code. + */ +export async function finalizeTUIRun( + state: RunnerState, + services: RunnerServices, +): Promise { + await hostOf(state).waitUntilExit(); + // Stop inference and every worker before persistence, hooks, or telemetry can + // delay process exit. Closing the terminal is a process-lifetime boundary. + await state.shutdownRuntime?.(); + state.stopFleetReporting?.(); + // Quitting mid-stream is an abnormal end for the in-flight cycle: nothing + // downstream delivers its terminal event once the app is gone. + await services.cycleRecorder.dispose("exit"); + services.mcpConnectController.abort(); + + const finishedAt = Date.now(); + const turnCollector = services.runSink.getTurnCollector(); + const sinkError = services.runSink.getRunError(); + const summaryStatus = services.runSink.getStatus(); + // RunSummary's status ("done" | "failed" | "cancelled") maps directly onto + // RunState's terminal statuses — no fallback to "running" here, otherwise a + // finished run (finishedAt set) can be left reading as still in progress. + const persistedStatus: RunState["status"] = summaryStatus; + services.crashGuard.markFinalized(); + // The run itself is over here, so this write clears the active-run handle + // (via finalizeRunState in state.ts) in the same call, rather than pairing + // the on-disk write with a separate in-memory statement at this call site. + // The dispose host has no on-disk counterpart to piggyback on, so it still + // needs its own clear here, mirroring finalizeOnCrash — otherwise a signal + // arriving after this normal exit would find a handle pointing at a + // torn-down closure. + clearActiveDisposeHost(); + const { writeRunSnapshot } = createRunPersistence(state, services); + await writeRunSnapshot( + persistedStatus, + { + finishedAt, + ...(sinkError !== undefined ? { error: sinkError } : {}), + }, + "run-end", + ); + const runSummary = createRunSummary({ + task: state.runTaskTitle.length > 0 ? state.runTaskTitle : state.config.task, + status: summaryStatus, + startedAt: state.startedAt, + finishedAt, + turnsUsed: services.runSink.getTurnCount(), + tokenUsage: services.runSink.getTokenUsage(), + turns: turnCollector?.getTurns() ?? [], + toolCallCount: services.runSink.getToolCallCount(), + ...(sinkError !== undefined ? { error: sinkError } : {}), + }); + await services.hookManager.dispatchPostRun(runSummary); + // exit_reason mirrors status at present — "cancelled" covers both an + // operator interrupt and Ctrl+C, since the emit site here cannot tell them + // apart (runSink only distinguishes done/failed/cancelled). + const exitReason = + runSummary.status === "done" ? "done" : runSummary.status === "failed" ? "error" : "cancelled"; + getTelemetry().capture("session_end", { + status: runSummary.status, + turn_count: runSummary.turnsUsed, + duration_ms: runSummary.durationMs, + session_mode: services.liveSessionMode, + exit_reason: exitReason, + }); + // Bound against process.exit dropping the session_end capture for short + // sessions; flush itself is deadline-capped so exit stays snappy. + // PerfTrace OTEL export runs once at process exit in main (flushPerfToOtel). + await getTelemetry().flush(); + + await services.sessionOps.awaitTail(); + try { + await state.streamPromise; + } catch { + // ignore + } + await services.toolset.dispose(); + + return resolveExitCode({ + runError: state.runError, + sinkError, + status: services.runSink.getStatus(), + }); +} diff --git a/src/tui/runner-host.ts b/src/tui/runner/host.ts similarity index 93% rename from src/tui/runner-host.ts rename to src/tui/runner/host.ts index 1993fe0a6..24f618289 100644 --- a/src/tui/runner-host.ts +++ b/src/tui/runner/host.ts @@ -10,14 +10,14 @@ import type { EventEmitter } from "node:events"; import type { CliRenderer } from "@opentui/core"; -import type { SubAgentSession, SubAgentTranscriptEntry } from "../subagent/session-store.js"; -import { commandItemsFromRegistry, type RegistryCommandSource } from "./command-catalog.js"; +import type { SubAgentSession, SubAgentTranscriptEntry } from "../../subagent/session-store.js"; +import { commandItemsFromRegistry, type RegistryCommandSource } from "../command-catalog.js"; import { openCommandSurface, type CommandSurfaceDeps, type CommandSurfaceKind, -} from "./command-surfaces.js"; -import { chromeFromSession, type ChromeSessionInput } from "./chrome-state.js"; +} from "../command-surfaces.js"; +import { chromeFromSession, type ChromeSessionInput } from "../chrome-state.js"; import { buildModelsFirstCatalog, describeModelCatalogOption, @@ -25,32 +25,34 @@ import { type ModelCatalogOption, type ModelCatalogProvidersInput, type ModelCatalogRef, -} from "./model-catalog.js"; -import type { ItemDescription } from "./shell.js"; -import { - mountProductHost, - type ProductHost, - type ProductHostAddProviderChoice, -} from "./product-host.js"; -import { onTurnBoundary } from "../agent/reactor-events.js"; +} from "../model-catalog.js"; import { + type ItemDescription, clearShellExitHandler, + setShellExitHandler, +} from "../shell/internals.js"; +import { setPromptCostContext, setPromptModelLabel, setPromptWorkspace, - setShellExitHandler, surfaceSystemNotice, -} from "./shell.js"; -import type { CostSummary } from "../cost/cost-summary.js"; -import { watchGitBranch, type FetchBranch } from "./workspace-watch.js"; -import type { PromptActionBarModelLabelInput } from "./components/prompt-action-bar-label.js"; -import type { ObserveSession } from "./residuals.js"; -import type { PendingImageAttachment } from "./image-attachments.js"; -import { toolCallRow } from "./diff.js"; -import { toolResultRow } from "./mcp-view.js"; -import { pushToolCall, pushToolResult } from "./tool-rows.js"; -import type { StreamRow } from "./stream.js"; -import type { QueueKind } from "./session-queue.js"; +} from "../shell/prompt.js"; +import { + mountProductHost, + type ProductHost, + type ProductHostAddProviderChoice, +} from "../product-host.js"; +import { onTurnBoundary } from "../../agent/reactor-events.js"; +import type { CostSummary } from "../../cost/cost-summary.js"; +import { watchGitBranch, type FetchBranch } from "../workspace-watch.js"; +import type { PromptActionBarModelLabelInput } from "../components/prompt-action-bar-label.js"; +import type { ObserveSession } from "../residuals.js"; +import type { PendingImageAttachment } from "../image-attachments.js"; +import { toolCallRow } from "../diff.js"; +import { toolResultRow } from "../mcp-view.js"; +import { pushToolCall, pushToolResult } from "../tool-rows.js"; +import type { StreamRow } from "../stream.js"; +import type { QueueKind } from "../session-queue.js"; export interface RunnerHostDeps { readonly title: string; diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts new file mode 100644 index 000000000..3c9e9f443 --- /dev/null +++ b/src/tui/runner/index.ts @@ -0,0 +1,211 @@ +/** + * TUI runner orchestration (CL-6791 phase 4): runTUI assembles the state bag + * and services, wires the split modules in the original runTUI order + * (session assembly → run lifecycle → settings → commands → submit → mcp → + * host mount → post-startup wiring → exit), and owns the crash-guard + * try/catch. Behavior lives in the sibling modules; this file owns ordering. + */ + +import { EventEmitter } from "node:events"; +import type { Config } from "../../config/index.js"; +import { listFavoriteModels, listRecentModels } from "../../config/settings.js"; +import { isCodexProviderName } from "../../config/codex-providers.js"; +import { resolveSessionEffort } from "../../provider/reasoning-effort.js"; +import { liveTelemetry } from "../../telemetry/singleton.js"; +import { isFeedbackCapturePending } from "../../telemetry/feedback.js"; +import { emitPluginWarningLog } from "../../plugins/diagnostics.js"; +import { createPluginsAdminState } from "../plugins-admin-backend.js"; +import { prepareTUISession } from "../session-start.js"; +import { addProviderSelectorChoices, providerChoices } from "../provider/choices.js"; +import { listCommands } from "../commands/registry.js"; +import { mountRunnerHost } from "./host.js"; +import { assembleTUISession } from "./session.js"; +import { createRunLifecycle, finalizeTUIRun } from "./exit.js"; +import { wireSettings } from "./settings.js"; +import { setUpCommandRegistry, createCommandLayer } from "./commands.js"; +import { + classifySubmission, + createDeliverRouting, + createSubmitPath, + routeSubmission, +} from "./submit.js"; +import { wireMcp } from "./mcp.js"; +import { wirePostStartup } from "./wiring.js"; +import { createRunnerState } from "./state.js"; +import { getLogger } from "@intx/log"; +import { LOG_NAMESPACE_ROOT } from "../../branding.js"; + +export function createTUIEventEmitter(): EventEmitter { + return new EventEmitter(); +} + +export { getTUIRunSummaryStatus } from "../../session/run-sink.js"; + +export async function runTUI(initialConfig: Config): Promise { + const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + const start = await prepareTUISession(initialConfig, liveTelemetry); + if (start === null) return 0; + const state = createRunnerState(start); + + const { pluginModules } = start.trust; + // /plugins UI backend state: discovered modules plus live, persisted config + // (enabled flag, credentials, web override, extra paths). Trust grants swap + // metadata-only stubs for full loads without restarting the process. + const pluginState = createPluginsAdminState({ + cwd: state.config.cwd, + settings: state.config.settings, + modules: pluginModules, + pathTrust: start.trust.pathTrust, + projectTrust: start.trust.projectTrust, + }); + emitPluginWarningLog(start.pluginLoadDiag); + // Fire-and-forget startup diagnostics (this + tool-plugin / profile + // resolution in the session assembly) have no result channel back to an + // operator action. Log-only is fine for the structured logger; the standing + // `plugin !` mark and `/plugins` surface carry the same warnings to the + // operator instead of a startup system notice. + const executablePlugins = () => pluginState.modules.filter((m) => m.metadataOnly !== true); + setUpCommandRegistry(state.config.settings, executablePlugins(), () => pluginState.pluginConfig); + start.crashGuard.bindLiveSession(() => ({ + cwd: state.config.cwd, + sessionId: state.sessionId, + startedAt: state.startedAt, + runTaskTitle: state.runTaskTitle, + providerName: state.config.providerName, + model: state.config.model, + })); + + try { + const services = await assembleTUISession(state, start, pluginState); + const lifecycle = await createRunLifecycle(state, services); + const settings = await wireSettings(state, services); + const commands = createCommandLayer(state, services); + const live = { + attemptIdentity: commands.currentAttemptIdentity, + agentProxy: lifecycle.agentProxy, + }; + const submit = createSubmitPath(state, services, live); + const mcp = wireMcp(state, services); + + // Mount OpenTUI before the initial task is sent so gate and stream listeners + // are registered first. Ctrl+C stays with the shell (interrupt the run); + // OpenTUI owns the alternate screen and mouse reporting itself. + // Alt+A add-provider selector rows: every first-class provider kind, + // including Custom (full manual form). No already-connected filtering — + // OAuth and multi-instance accounts are per-name, so dropping a kind once + // it has one account would hide the path to a second. Read fresh on each + // open against the live catalog. + const computeAddProviderChoices = () => + addProviderSelectorChoices(providerChoices(), state.config.providers); + + const host = await mountRunnerHost({ + // An unnamed session shows nothing rather than a placeholder. + title: state.runTaskTitle, + cwd: process.cwd(), + eventEmitter: services.emitter, + send: submit.send, + classifySubmit: (text, attachments) => + classifySubmission(text, { + hasAttachments: attachments !== undefined && attachments.length > 0, + feedbackPending: isFeedbackCapturePending(), + feedbackCaptureEnabled: true, + }), + interrupt: lifecycle.interrupt, + deliver: createDeliverRouting(state, services, live), + // Consent by proceeding requires the disclosure to be on screen before the + // first prompt activates the held telemetry instance: the landing shows it, + // and the shell re-files it into the transcript when the landing clears. + ...(settings.telemetryNotice !== undefined + ? { telemetryNotice: settings.telemetryNotice } + : {}), + providers: state.config.providers, + recentModels: listRecentModels(state.config.settings ?? { providers: {} }), + favoriteModels: listFavoriteModels(state.config.settings ?? { providers: {} }), + addProviderChoices: computeAddProviderChoices, + onConnectProvider: settings.onConnectProvider, + modelLabel: () => { + const effort = resolveSessionEffort( + state.config.model, + state.config.reasoningEffort, + isCodexProviderName(state.config.providerName), + ); + return { + profile: state.config.providerName, + model: state.config.model, + ...(effort !== undefined ? { effort } : {}), + }; + }, + activeModel: () => ({ provider: state.config.providerName, model: state.config.model }), + readCostSummary: () => commands.commandContext.getCostSummary?.(), + showPromptCost: () => state.liveShowPromptCost, + onModelSelect: settings.onModelSelect, + onFavoriteToggle: settings.onFavoriteToggle, + onSetDefault: settings.onSetDefault, + commands: () => listCommands().map((c) => ({ name: c.name, description: c.description })), + onCommand: (name) => { + const route = routeSubmission(name); + if (route.kind === "empty") return; + if (route.kind === "command") { + state.dispatchCommand?.(route.name, route.args); + return; + } + const [commandName = "", ...rest] = route.text.split(/\s+/); + state.dispatchCommand?.(commandName, rest.join(" ")); + }, + chrome: () => ({ + tasks: services.directorHolder.instance?.getTasks() ?? null, + agents: services.subAgentSessions.listForStrip().map((s) => ({ + agentId: s.agentId, + id: s.id, + description: s.description, + status: s.status, + lifecycleStatus: s.lifecycleStatus, + currentToolName: s.currentToolName, + currentToolPreview: s.currentToolPreview, + currentToolStartedAt: s.currentToolStartedAt, + startedAt: s.startedAt, + lastActivityAt: s.lastActivityAt, + ...(s.finishedAt !== undefined ? { finishedAt: s.finishedAt } : {}), + ...(s.runInFlight !== undefined ? { runInFlight: s.runInFlight } : {}), + })), + }), + subscribeChrome: (notify) => { + const unsubscribeAgents = services.subAgentSessions.subscribe(notify); + services.emitter.on("tasks", notify); + return () => { + unsubscribeAgents(); + services.emitter.off("tasks", notify); + }; + }, + subAgentSessions: () => services.subAgentSessions.list(), + surfaces: { + permissions: settings.surfaces.permissions, + plugins: settings.surfaces.plugins, + mcp: mcp.surface, + hooks: settings.surfaces.hooks, + settings: settings.surfaces.settings, + }, + }); + state.host = host; + services.hostHolder.instance = host; + + wirePostStartup(state, services, mcp.mcpConnectCallbacks); + + return await finalizeTUIRun(state, services); + } catch (err) { + // Terminal first: state persistence below can await disk I/O, and every + // millisecond before this runs is a millisecond the operator is staring at + // a frozen alternate screen. Kept outside finalizeOnCrash because that + // short-circuits once the clean path has marked the run finalized, and a + // throw after that point still has to give the terminal back. + try { + start.crashGuard.invokeDisposeHost(); + } catch (disposeErr: unknown) { + tuiLogger.warn("crash finalize: host dispose failed: {error}", { + error: disposeErr instanceof Error ? disposeErr.message : String(disposeErr), + }); + } + await start.crashGuard.finalizeOnCrash(err); + throw err; + } +} diff --git a/src/tui/runner/mcp.ts b/src/tui/runner/mcp.ts new file mode 100644 index 000000000..8a4eadaf5 --- /dev/null +++ b/src/tui/runner/mcp.ts @@ -0,0 +1,270 @@ +/** + * MCP surface for the TUI runner: late-connect callbacks, the persisted + * server catalog updates, and the /mcp host surface (list, add, retry, + * enable/disable, remove). + */ + +import { getLogger } from "@intx/log"; +import { resolveMcpServers } from "../../config/index.js"; +import { + isExaMCPPreset, + type MCPServerConfig, + type MCPServerSettingsEntry, + type Settings, +} from "../../config/settings.js"; +import { + persistGlobalHTTPMCPServer, + persistLocalMCPServerEnabled, + persistLocalMCPServerRemoved, + persistMCPServerEnabled, + persistMCPServerRemoved, + validateMCPServerName, + type PersistMCPServerListResult, +} from "../../mcp/add-server.js"; +import { createExaMCPServerConfig, EXA_MCP_SERVER_NAME } from "../../mcp/exa.js"; +import { openInBrowser } from "../../auth/oauth/browser.js"; +import { mergeMcpSurfaceEntries, isBuiltinRow } from "../mcp-list.js"; +import { nextMcpCatalog } from "../mcp-catalog.js"; +import type { MCPConnectCallbacks } from "../../agent/tools.js"; +import { type RunnerServices, type RunnerState } from "./state.js"; +import { SETTINGS_DIR_NAME, LOG_NAMESPACE_ROOT } from "../../branding.js"; + +const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + +export interface McpWiring { + connectLateMCPServer: (server: MCPServerConfig) => void; + mcpConnectCallbacks: MCPConnectCallbacks; + surface: ReturnType; +} + +export function wireMcp(state: RunnerState, services: RunnerServices): McpWiring { + const mcpConnectCallbacks: MCPConnectCallbacks = { + interactiveAuth: true, + onStatus: (status) => { + services.mcpStates.set(status.name, status); + services.emitter.emit("mcp.status", status); + if (status.state === "connected") { + state.connectedMcpServers = [ + ...state.connectedMcpServers.filter((server) => server.name !== status.name), + { name: status.name, toolCount: status.tools.length }, + ]; + void state.persistRunSnapshot?.("running"); + } + }, + // MCP tools register for dispatch but stay blind until tool_search promotes them. + onToolsChanged: (definitions) => + services.directorHolder.instance?.updateToolDefinitions( + services.computeAdvertised(definitions), + ), + }; + + const connectLateMCPServer = (server: MCPServerConfig): void => { + void services.toolset + .connectMCPServer(server, mcpConnectCallbacks, services.mcpConnectController.signal) + .catch((err: unknown) => { + if (err instanceof Error && err.name === "AbortError") return; + tuiLogger.error("Late MCP connect failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }; + state.connectLateMCPServer = connectLateMCPServer; + + const persistedMCPServer = (name: string): MCPServerConfig | undefined => { + const fromConnect = (state.config.mcpServers ?? []).find((server) => server.name === name); + if (fromConnect !== undefined) return fromConnect; + const entry = state.configuredMcpEntries.find( + (server) => server.name === name && !isExaMCPPreset(server), + ); + if (entry === undefined) return undefined; + const { enabled: _enabled, ...connect } = entry; + return connect; + }; + + const applyMcpCatalog = (result: Extract): void => { + const next = nextMcpCatalog({ + source: state.config.mcpServersSource ?? "none", + result, + globalServers: state.config.settings?.mcpServers, + }); + state.configuredMcpEntries = next.overlayEntries; + state.config = { + ...state.config, + ...(next.settings !== undefined ? { settings: next.settings } : {}), + mcpServerEntries: next.overlayEntries, + mcpServers: next.mcpServers, + mcpServersSource: next.mcpServersSource, + }; + services.toolset.setMcpServersSource(next.mcpServersSource); + }; + + const applyAddedMcpCatalog = (entries: MCPServerSettingsEntry[], settings: Settings): void => { + state.configuredMcpEntries = entries; + const source = state.config.mcpServersSource ?? "none"; + const nextSource = source === "none" ? "global" : source; + state.config = { + ...state.config, + settings, + mcpServerEntries: entries, + mcpServers: resolveMcpServers(entries, undefined), + mcpServersSource: nextSource, + }; + services.toolset.setMcpServersSource(nextSource); + }; + + const mcpTransportForEnable = (name: string): MCPServerConfig | undefined => { + const entry = state.configuredMcpEntries.find((server) => server.name === name); + if (entry !== undefined) { + if (isExaMCPPreset(entry)) return createExaMCPServerConfig(); + if (entry.enabled === false) return undefined; + const { enabled: _enabled, ...connect } = entry; + return connect; + } + if (name === EXA_MCP_SERVER_NAME) return createExaMCPServerConfig(); + return undefined; + }; + + const dropConnectedMcpServer = (name: string): void => { + state.connectedMcpServers = state.connectedMcpServers.filter((server) => server.name !== name); + void state.persistRunSnapshot?.("running"); + }; + + return { + connectLateMCPServer, + mcpConnectCallbacks, + surface: createMcpSurface( + state, + services, + mcpConnectCallbacks, + persistedMCPServer, + applyMcpCatalog, + applyAddedMcpCatalog, + mcpTransportForEnable, + dropConnectedMcpServer, + connectLateMCPServer, + ), + }; +} + +function createMcpSurface( + state: RunnerState, + services: RunnerServices, + mcpConnectCallbacks: MCPConnectCallbacks, + persistedMCPServer: (name: string) => MCPServerConfig | undefined, + applyMcpCatalog: (result: Extract) => void, + applyAddedMcpCatalog: (entries: MCPServerSettingsEntry[], settings: Settings) => void, + mcpTransportForEnable: (name: string) => MCPServerConfig | undefined, + dropConnectedMcpServer: (name: string) => void, + connectLateMCPServer: (server: MCPServerConfig) => void, +) { + return { + list: () => + mergeMcpSurfaceEntries( + state.configuredMcpEntries, + services.mcpStates, + state.config.mcpServers ?? [], + ), + openAuthURL: (url: string) => openInBrowser(url), + subscribe: (listener: () => void) => { + services.emitter.on("mcp.status", listener); + return () => services.emitter.off("mcp.status", listener); + }, + get mcpServersSource() { + return state.config.mcpServersSource ?? "none"; + }, + addServer: async (name: string, url: string) => { + const result = await persistGlobalHTTPMCPServer( + services.globalSettingsWriter, + name, + url, + state.config.mcpServersSource ?? "none", + services.toolset.hasMCPServer, + ); + if (!result.ok) { + const message = + result.reason === "local-shadow" + ? `Cannot add a global MCP server while ${SETTINGS_DIR_NAME}/settings.json ` + + "defines mcpServers; remove that local list and restart first." + : result.reason === "duplicate" || result.reason === "active" + ? `An MCP server named "${name.trim()}" already exists or is connecting.` + : result.reason === "skipped" + ? "Could not read global settings, so no MCP server was added." + : result.reason === "invalid-name" + ? (validateMCPServerName(name.trim()) ?? "Enter a valid server name first.") + : "Enter an absolute HTTP(S) URL first."; + return { ok: false, message }; + } + applyAddedMcpCatalog(result.settings.mcpServers ?? [], result.settings); + connectLateMCPServer(result.server); + return { ok: true, message: `Added ${result.server.name}; connecting now.` }; + }, + retryServer: async (name: string) => { + const server = persistedMCPServer(name); + if (server === undefined) { + return { + ok: false, + message: `No persisted MCP server named "${name}" to retry.`, + }; + } + connectLateMCPServer(server); + return { ok: true, message: `Retrying ${server.name}; connecting now.` }; + }, + setEnabled: async (name: string, enabled: boolean) => { + const source = state.config.mcpServersSource ?? "none"; + const result = + source === "local" + ? await persistLocalMCPServerEnabled(services.localSettingsWriter, name, enabled) + : await persistMCPServerEnabled(services.globalSettingsWriter, name, enabled); + if (!result.ok) { + const verb = enabled ? "enable" : "disable"; + const message = + result.reason === "skipped" + ? `Could not read settings, so the MCP server was not ${verb}d.` + : `No MCP server named "${name}" to ${verb}.`; + return { ok: false, message }; + } + applyMcpCatalog(result); + if (!enabled) { + await services.toolset.disconnectMCPServer(name, mcpConnectCallbacks); + dropConnectedMcpServer(name); + return { ok: true, message: `Disabled ${name}.` }; + } + const server = mcpTransportForEnable(name); + if (server !== undefined) connectLateMCPServer(server); + return { ok: true, message: `Enabled ${name}; connecting now.` }; + }, + removeServer: async (name: string) => { + if ( + isBuiltinRow( + name, + state.configuredMcpEntries.find((entry) => entry.name === name), + state.config.mcpServers ?? [], + ) + ) { + return { ok: false, message: "Built-in Exa cannot be removed." }; + } + const source = state.config.mcpServersSource ?? "none"; + const result = + source === "local" + ? await persistLocalMCPServerRemoved(services.localSettingsWriter, name) + : await persistMCPServerRemoved(services.globalSettingsWriter, name); + if (!result.ok) { + const message = + result.reason === "builtin-exa" + ? "Built-in Exa cannot be removed." + : result.reason === "skipped" + ? "Could not read settings, so the MCP server was not removed." + : `No MCP server named "${name}" to remove.`; + return { ok: false, message }; + } + applyMcpCatalog(result); + await services.toolset.disconnectMCPServer(name, mcpConnectCallbacks); + services.mcpStates.delete(name); + dropConnectedMcpServer(name); + for (const server of state.config.mcpServers ?? []) { + connectLateMCPServer(server); + } + return { ok: true, message: `Removed ${name}.` }; + }, + }; +} diff --git a/src/tui/runner/send-failure-message.ts b/src/tui/runner/send-failure-message.ts new file mode 100644 index 000000000..56688d2af --- /dev/null +++ b/src/tui/runner/send-failure-message.ts @@ -0,0 +1,37 @@ +/** + * The operator-facing message for a rejected send (the inference-failure + * contract the submit path reports through). Lives in its own leaf so the + * submit and commands siblings never import each other. + */ +import type { InferenceErrorLike } from "../../inference-gateway-error.js"; +import { + CREDENTIAL_FAILURE_USER_MESSAGE, + isResolvedProviderFailureError, + terminalProviderFailureMessage, +} from "../../inference-error-message.js"; +import type { InferenceAttemptIdentity } from "./state.js"; + +export function tuiSendFailureMessage( + error: unknown, + failureKind: "auth" | "error", + providerFailureObserved: boolean, + attempt: InferenceAttemptIdentity, + providerError?: InferenceErrorLike, +): string { + if (failureKind === "auth") { + return CREDENTIAL_FAILURE_USER_MESSAGE; + } + if (!providerFailureObserved && !isResolvedProviderFailureError(error)) { + return error instanceof Error ? error.message : String(error); + } + const providerId = + providerError?.providerId ?? + (isResolvedProviderFailureError(error) ? error.providerId : attempt.providerId); + const displayLabel = providerId === attempt.providerId ? attempt.displayLabel : undefined; + if (providerError === undefined && isResolvedProviderFailureError(error)) return error.message; + const diagnostic = providerError ?? { + category: "fatal", + message: error instanceof Error ? error.message : String(error), + }; + return terminalProviderFailureMessage(providerId, diagnostic, displayLabel); +} diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts new file mode 100644 index 000000000..04e2471aa --- /dev/null +++ b/src/tui/runner/session.ts @@ -0,0 +1,511 @@ +/** + * Session assembly for the TUI runner: everything the old runTUI closure + * built once inside its try block before the first agent build — the session + * lifecycle (hooks sink, run sink, cycle recorder), the permission gate, + * plugin/tool resolution, the agent toolset, the workflow controller, and + * the chat agent factory. Returns the const `RunnerServices` bag index.ts + * threads through the other runner modules; mutable bindings live on + * RunnerState. + */ + +import { join } from "node:path"; +import { EventEmitter } from "node:events"; +import { + localSettingsPath, + shellTimeoutFromSettings, + toolWatchdogFromSettings, +} from "../../config/settings.js"; +import { isCodexProviderName } from "../../config/codex-providers.js"; +import { createGlobalSettingsWriter, createLocalSettingsWriter } from "../../mcp/add-server.js"; +import { getProcessAdmissionQueue } from "../../subagent/admission.js"; +import { createSubAgentSessionStore } from "../../subagent/index.js"; +import { + buildPluginDescriptor, + createPluginsAdmin, + type PluginsAdminState, +} from "../plugins-admin-backend.js"; +import type { PluginDescriptor } from "../../plugins/admin.js"; +import { createPluginLoadDiagnostics, emitPluginWarningLog } from "../../plugins/diagnostics.js"; +import { + collectWebPlugins, + resolveWebProviderFromPlugins, + webBrand, +} from "../../web/plugin-provider.js"; +import { collectToolPlugins, resolveToolPlugins } from "../../plugins/tool-plugins.js"; +import { resolveAgentPluginProfiles } from "../../plugins/agent-plugins.js"; +import { loadAgentProfiles } from "../../agent/profiles.js"; +import { createPermissionsAdmin } from "../../permission/admin.js"; +import { setActiveWebProviderBrand } from "../tool-formatter.js"; +import { + assembleChatAgent, + assembleSessionGate, + assembleSessionLifecycle, + createAdvertisedToolset, + loadSessionLocalSettings, + resolveLiveSessionSources, + type LiveSessionSources, +} from "../../session/assemble-runtime.js"; +import { createApprovalResume } from "../../session/approval-resume.js"; +import { createReactorAuthorize } from "../../permission/reactor-authorize.js"; +import { + buildCompactionContinuationMessage, + createLiveSubAgentSources, + createSessionPruningCompactor, + loadSessionChatPrompt, + skillDirsFromEnabledPlugins, +} from "../../session/runtime-assembly.js"; +import { createModelSummarizer, type SummaryContext } from "../../session/summarizer.js"; +import { createSessionCostAccumulator } from "../../cost/session-cost.js"; +import { createSessionOperationQueue } from "../session-operation-queue.js"; +import { createDeliveryGeneration } from "../queued-delivery.js"; +import { createAgentToolset, type MCPServerState, type OperatorResult } from "../../agent/tools.js"; +import type { ToolAvailability } from "../../agent/tool-search.js"; +import { detectLanguageServerAvailable } from "../../agent/lsp-availability.js"; +import type { SessionMode } from "../../config/session-mode.js"; +import { WorkflowController } from "../workflow-controller.js"; +import type { ToolWatchdogConfig } from "../tool-execution-watchdog.js"; +import { deliverAgentMessage } from "../deliver-agent-message.js"; +import { createProviderFailureAttemptTracker } from "../provider/failure-attempt.js"; +import { getTelemetry, liveTelemetry } from "../../telemetry/singleton.js"; +import { createChatDirector } from "../../agent/director.js"; +import { attachApprovalBudget } from "../request-approval.js"; +import { createGateRequestApproval } from "../request-approval.js"; +import { getActivePricingCache } from "../../cost/cost-visibility.js"; +import type { OperatorGateEvent } from "../gate-events.js"; +import { sessionDir } from "../../session/index.js"; +import { ID_PREFIX } from "../../branding.js"; +import { + liveAgent, + type RunnerHost, + type RunnerServices, + type RunnerState, + type TUIStart, +} from "./state.js"; + +export async function assembleTUISession( + state: RunnerState, + start: TUIStart, + pluginState: PluginsAdminState, +): Promise { + const config = state.config; + const emitter = new EventEmitter(); + const globalSettingsWriter = createGlobalSettingsWriter(config.globalSettingsPath); + const localSettingsWriter = createLocalSettingsWriter(localSettingsPath(config.cwd)); + const initialHookEnabled: Record = Object.fromEntries( + Object.entries(config.settings?.hooks ?? {}).map(([id, v]) => [id, v.enabled]), + ); + const { hookManager, runSink, cycleRecorder } = await assembleSessionLifecycle({ + cwd: config.cwd, + emitter, + getTelemetry, + getSessionId: () => state.sessionId, + // The lifecycle starts consuming events well after the exit module wires + // persistRunSnapshot onto the state this closure reads it from. + getSource: () => state.liveSource, + initialTurnCount: start.resumeSeed.turnsUsed, + onTurnBoundarySnapshot: () => { + void state.persistRunSnapshot?.("running"); + }, + hookEnabled: initialHookEnabled, + onHookEvent: (event) => emitter.emit("hook", event), + resolveContextDir: () => state.workdir, + }); + + // Shared by the permission gate and every operator-gate emission site: an + // unattended auto-continue run must not park on any gate forever, whichever + // kind it is. No caller arms this today — the goal subsystem was the only + // source of an auto-deny/auto-cancel deadline and has been removed. The + // timeout plumbing (gate-events.ts / request-approval.ts, and every + // OperatorGateEvent/PermissionGateEvent emission site below) stays for a + // future generalized auto-continue mechanism to re-arm by giving this a + // real body again. + const approvalTimeout = (): { timeoutMs: number; timeoutMessage: string } | undefined => + undefined; + + const { gate: permissionGate } = await assembleSessionGate({ + cwd: config.cwd, + sessionId: state.sessionId, + providerName: config.providerName, + model: config.model, + telemetry: liveTelemetry, + requestApproval: createGateRequestApproval({ + emitGate: (event) => emitter.emit("permission.gate", event), + approvalTimeout, + }), + getActiveProviderModel: () => `${state.config.providerName}:${state.config.model}`, + onPersistNotice: (text) => state.approvalPersistNotice.notify?.(text), + interactive: true, + skipPermissions: config.dangerouslySkipPermissions, + auto: config.auto, + // Main session: gating rides the reactor's approval-suspend seam. + reactorGated: true, + onGrant: (approval, covers) => emitter.emit("permission.grant", { approval, covers }), + }); + const approvalResume = createApprovalResume({ + getAgent: () => state.currentAgent, + gate: permissionGate, + }); + + const permissionsAdmin = createPermissionsAdmin(permissionGate, config.cwd); + + // Track the active subagent provider so a live /agent switch (provider, model, + // or reasoning effort) reaches subagents spawned afterward. Derives from the + // live config binding on every spawn, so every switch path that reassigns + // config (model picker, /agent, post-connect refresh) is picked up without + // a separate cache to keep in sync. + const liveSubAgent = createLiveSubAgentSources(() => state.config); + + // Dedicated child-session records for enter-session inspection. Child events + // land here only — never in the parent chat transcript. + const subAgentSessions = createSubAgentSessionStore({ + admission: getProcessAdmissionQueue(), + }); + + const executablePlugins = () => pluginState.modules.filter((m) => m.metadataOnly !== true); + pluginState.webCandidates = collectWebPlugins(executablePlugins()); + // Tool plugins are wired in only when enabled AND consented. + pluginState.toolCandidates = collectToolPlugins(executablePlugins()); + // Web and tool plugin resolution are independent, so resolve them concurrently. + const toolPluginDiag = createPluginLoadDiagnostics(); + const [activeWeb, extraToolPlugins] = await Promise.all([ + resolveWebProviderFromPlugins({ + candidates: pluginState.webCandidates, + pluginConfig: config.settings?.plugins ?? {}, + webOverride: config.settings?.web, + }), + resolveToolPlugins({ + candidates: pluginState.toolCandidates, + pluginConfig: config.settings?.plugins ?? {}, + diagnostics: toolPluginDiag, + }), + ]); + if (activeWeb !== undefined) setActiveWebProviderBrand(webBrand(activeWeb.name)); + emitPluginWarningLog(toolPluginDiag); + state.standingPluginWarnings.push(...toolPluginDiag.warnings); + + // Descriptors mirror the mutable module list so plugins added by path + // mid-session appear without a restart. + pluginState.descriptors = pluginState.modules + .map((m) => buildPluginDescriptor(m)) + .filter((d): d is PluginDescriptor => d !== undefined); + // Attach agent profiles to their descriptors so the /plugins UI can show + // which sub-agents a plugin contributes. + for (const mod of pluginState.modules) { + if (mod.manifest?.kind !== "agent" || mod.agentPlugin === undefined) continue; + const desc = pluginState.descriptors.find((d) => d.id === mod.manifest!.id); + if (desc === undefined) continue; + const agents = Array.isArray(mod.agentPlugin.agents) ? mod.agentPlugin.agents : []; + desc.agentProfiles = agents + .filter((a): a is Record => typeof a === "object" && a !== null && "id" in a) + .map((a) => ({ + id: String(a["id"]), + ...(typeof a["description"] === "string" ? { description: a["description"] } : {}), + })); + } + const notePluginWarnings = (warnings: readonly string[]): void => { + if (warnings.length === 0) return; + state.standingPluginWarnings.push(...warnings); + state.paintPluginAttention?.(state.standingPluginWarnings.length > 0); + }; + const pluginsAdmin = createPluginsAdmin({ + state: pluginState, + globalSettingsPath: config.globalSettingsPath, + globalSettingsWriter, + noteWarnings: notePluginWarnings, + }); + const profilesDir = join(config.cwd, ".agents", "agents"); + const profileDiag = createPluginLoadDiagnostics(); + const pluginAgentProfiles = await resolveAgentPluginProfiles( + executablePlugins(), + config.settings?.plugins ?? {}, + { diagnostics: profileDiag }, + ); + emitPluginWarningLog(profileDiag); + state.standingPluginWarnings.push(...profileDiag.warnings); + const liveAgentProfiles = await loadAgentProfiles(profilesDir, pluginAgentProfiles); + + // Skill directories from enabled plugins, in addition to project-local + // `.agents`/`.claude`/`.codex/skills` that discoverSkills/resolveSkillBody check. + const skillDirs = skillDirsFromEnabledPlugins(executablePlugins(), pluginState.pluginConfig); + + const shellTimeout = shellTimeoutFromSettings(config.settings); + // Mutable so Settings → waitForApproval takes effect on the next tool call + // without rebuilding the toolset. + const liveToolWatchdog: ToolWatchdogConfig = { + ...(toolWatchdogFromSettings(config.settings) ?? {}), + }; + // CL-5814: orchestrator is the only product path — no first-run mode picker. + const liveSessionMode: SessionMode = "orchestrator"; + // Local settings still supply shell env; sessionMode is ignored if present. + const localSettingsForEnv = await loadSessionLocalSettings({ + cwd: config.cwd, + globalSettingsPath: config.globalSettingsPath, + }); + const toolAvailability: ToolAvailability = { + languageServerAvailable: detectLanguageServerAvailable(config.cwd), + }; + // The workflow controller is built below, after the toolset; the holder lets + // submit_output's handler complete the live workflow without a + // construction-order cycle. + const workflowControllerHolder: { instance?: WorkflowController } = {}; + + const toolset = await createAgentToolset({ + cwd: config.cwd, + permissionGate, + skillDirs, + telemetry: liveTelemetry, + isCodex: isCodexProviderName(config.providerName), + ...(shellTimeout !== undefined ? { shellTimeout } : {}), + ...(localSettingsForEnv?.env !== undefined ? { shellEnv: localSettingsForEnv.env } : {}), + toolWatchdog: liveToolWatchdog, + getBlobReader: () => liveAgent(state).blobReader, + getBlobWriter: () => state.currentStorage?.writeBlob, + getContextDir: () => state.workdir, + isWorkflowActive: () => workflowControllerHolder.instance?.isActive() === true, + completeWorkflowStep: (stepId) => + workflowControllerHolder.instance?.complete(stepId) ?? "not-current", + ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), + onOperatorGate: (question, options) => + new Promise((resolve) => { + const { finish, signal } = attachApprovalBudget(resolve, { + tool: "ask_operator", + kind: "operator", + }); + const timeout = approvalTimeout(); + const event: OperatorGateEvent = { + question, + options, + resolve: finish, + ...(timeout !== undefined ? timeout : {}), + ...(signal !== undefined ? { signal } : {}), + }; + emitter.emit("operator.gate", event); + }), + sessionMode: liveSessionMode, + toolAvailability, + ...(config.mcpServers !== undefined ? { mcpServers: config.mcpServers } : {}), + mcpServersSource: config.mcpServersSource ?? "none", + projectTrust: pluginState.projectTrust, + requestMcpTrust: async (server) => { + // TOFU via operator gate: Trust this local MCP server? + const result = await new Promise((resolve) => { + const { finish, signal } = attachApprovalBudget(resolve, { + tool: `mcp:${server.name}`, + kind: "operator", + }); + const timeout = approvalTimeout(); + const event: OperatorGateEvent = { + question: + `Trust local MCP server "${server.name}" for this project?` + + (server.command !== undefined + ? `\nCommand: ${server.command}${(server.args ?? []).length > 0 ? ` ${(server.args ?? []).join(" ")}` : ""}` + : server.url !== undefined + ? `\nURL: ${server.url}` + : ""), + options: ["Trust and connect", "Deny"], + resolve: finish, + ...(timeout !== undefined ? timeout : {}), + ...(signal !== undefined ? { signal } : {}), + }; + emitter.emit("operator.gate", event); + }); + return result.kind === "option" && result.index === 0; + }, + subAgent: { + provider: liveSubAgent.provider, + sessions: subAgentSessions, + getWorkdirBase: () => sessionDir(state.config.cwd, state.sessionId), + // Progress only — not the full event stream. Forwarding every sub-agent + // inference.delta into the parent transcript interleaves worker text with + // the parent turn; progress keeps the status bar alive and the Agents + // strip current without that pollution. + onProgress: (info) => { + emitter.emit("subagent.progress", info); + }, + settings: liveSubAgent.settings, + catalog: liveSubAgent.catalog, + profiles: () => liveAgentProfiles, + }, + }); + + const { systemPrompt } = await loadSessionChatPrompt({ + cwd: config.cwd, + skillDirs, + ...(config.systemPromptExtensions !== undefined + ? { systemPromptExtensions: config.systemPromptExtensions } + : {}), + sessionMode: liveSessionMode, + toolAvailability, + skills: toolset.skills, + }); + + const directorHolder: { instance?: ReturnType } = {}; + const hostHolder: { instance?: RunnerHost } = {}; + + // Owns the workflow lifecycle: slash-command starts, capability overrides, + // resume, and publishing status to the App via the emitter. + const workflowController = new WorkflowController({ + cwd: config.cwd, + emitter, + getSessionId: () => state.sessionId, + getToolDefinitions: () => toolset.dynamicRunner.currentDefinitions(), + getDirector: () => directorHolder.instance, + }); + workflowControllerHolder.instance = workflowController; + + // Dynamic tool discovery: only the fixed built-in prefix plus activated + // tools reach the wire, so the provider cache prefix holds steady; MCP + // tools must be promoted here before the model can invoke them. + const { activated: activatedToolNames, computeAdvertised } = createAdvertisedToolset({ + sessionMode: liveSessionMode, + toolAvailability, + getProvider: () => state.config, + }); + + // Reload, interrupt, compaction continuation, and proxy deliver share one queue + // so a rebuild never races an in-flight deliver. + const sessionOps = createSessionOperationQueue(); + const deliveryGeneration = createDeliveryGeneration(); + state.enqueueAgentDeliver = (deliverToLiveAgent: () => void): void => { + const stillCurrent = deliveryGeneration.capture(); + void sessionOps.enqueue(async () => { + if (!stillCurrent()) return; + // The shell already popped the queue item and painted it as delivered + // by the time this runs, so a failed rebuild must be surfaced here — + // otherwise the message silently never reaches the agent. + await deliverAgentMessage({ + getFatalBuildError: () => state.fatalBuildError, + deliverToLiveAgent, + onDeliverFailure: (text) => state.systemNotice?.(text), + }); + }); + }; + + const buildSessionSources = (): LiveSessionSources => + resolveLiveSessionSources(state.config, state.sessionId); + + // Compaction summarizer: produces a structured, workflow-aware handoff via a + // one-shot call on the live model, falling back to the deterministic summary + // on any failure. Workflow state is read at compaction time so a pass + // mid-/build or mid-/plan still names the active step. + const compactionSummarize = createModelSummarizer({ + getSource: () => state.liveSource, + deps: start.inferenceDeps, + }); + const summaryContext = (): SummaryContext | undefined => { + const status = workflowController.status(); + if (!status.active) return undefined; + return { + workflow: { + ...(status.name !== undefined ? { name: status.name } : {}), + stepLabel: status.label, + stepIndex: status.stepIndex, + total: status.total, + }, + }; + }; + + const chatAgent = assembleChatAgent({ + toolsId: `${ID_PREFIX}/tui-tools`, + agentId: `${ID_PREFIX}/tui-agent`, + systemPrompt, + getDynamicRunner: () => toolset.dynamicRunner, + computeAdvertised, + activateTools: (names) => activatedToolNames.activate(names), + inactivityTimeoutMs: config.inactivityTimeoutMs ?? 750_000, + totalTimeoutMs: config.totalTimeoutMs, + onTasksChange: (tasks) => emitter.emit("tasks", tasks), + requestContinuation: () => { + state.enqueueAgentDeliver?.(() => + liveAgent(state).deliver(buildCompactionContinuationMessage()), + ); + }, + getProvider: () => state.config, + // Live id so mid-session `/model` updates xAI bare-429 remapping + // without rebuilding the agent (aligned with transcript stamp). + getProviderId: () => state.config.providerName, + directorHolder, + onToolsPromoted: () => { + state.pendingReload = true; + state.reloadIfIdle?.(); + }, + getWorkdir: () => state.workdir, + authorize: createReactorAuthorize(permissionGate), + inferenceDeps: start.inferenceDeps, + getSources: () => (state.liveSources.length > 0 ? state.liveSources : [state.liveSource]), + getDefaultSource: () => + state.liveDefaultSource.length > 0 ? state.liveDefaultSource : state.liveSource.id, + getCompactor: () => + createSessionPruningCompactor({ + compactionMode: state.liveCompactionMode, + summarize: compactionSummarize, + summaryContext, + telemetry: liveTelemetry, + // Main-session folds only — exec runner and subagents stay silent. + onFolded: (info) => emitter.emit("compaction", info), + }), + onBuilt: (agent, storage) => { + state.currentAgent = agent; + state.currentStorage = storage; + }, + }); + + const sessionCost = createSessionCostAccumulator({ + pricingCache: getActivePricingCache, + }); + + // Every configured server's latest state, for the /mcp surface. Unlike + // connectedMcpServers (persisted run metadata) this keeps the ones that + // failed or are still waiting on authorization. + const mcpStates = new Map(); + const mcpConnectController = new AbortController(); + + // Cycles persist to the context store only on inference.done; the assembled + // recorder keeps the in-flight cycle's text so an errored or interrupted + // turn leaves its partial output in partial.jsonl instead of vanishing. + const providerFailureAttempts = createProviderFailureAttemptTracker(); + // Tool count before any MCP server connects; a reload is only worthwhile if + // connecting actually added tools. + const baseToolCount = toolset.dynamicRunner.currentDefinitions().length; + + return { + emitter, + globalSettingsWriter, + localSettingsWriter, + hookManager, + runSink, + cycleRecorder, + crashGuard: start.crashGuard, + activeRunHandle: start.activeRunHandle, + inferenceDeps: start.inferenceDeps, + permissionGate, + approvalResume, + permissionsAdmin, + liveSubAgent, + subAgentSessions, + pluginState, + executablePlugins, + pluginsAdmin, + skillDirs, + liveToolWatchdog, + liveSessionMode, + toolAvailability, + toolset, + systemPrompt, + directorHolder, + hostHolder, + workflowControllerHolder, + workflowController, + activatedToolNames, + computeAdvertised, + buildAgent: chatAgent.buildAgent, + sessionCost, + sessionOps, + deliveryGeneration, + buildSessionSources, + providerFailureAttempts, + baseToolCount, + mcpStates, + mcpConnectController, + }; +} diff --git a/src/tui/runner/settings.ts b/src/tui/runner/settings.ts new file mode 100644 index 000000000..dd3503a56 --- /dev/null +++ b/src/tui/runner/settings.ts @@ -0,0 +1,591 @@ +/** + * Settings and config surface for the TUI runner: hook settings, the + * first-run telemetry/changelog onboarding block, global settings writes, + * the model-selection handlers, the Alt+A provider connect flow, and the + * permissions/plugins/hooks/settings host surfaces. (The /mcp surface lives + * in mcp.ts and is composed into the mount by index.ts.) + */ + +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { getLogger } from "@intx/log"; +import { + listFavoriteModels, + listRecentModels, + loadLocalSettings, + loadSettings, + markLastChangelogVersion, + markTelemetryNoticeShown, + pushRecentModel, + setDefaultModel, + toggleFavoriteModel, + type LocalSettings, + type ModelRef, + type ResolvedProvider, + type Settings, +} from "../../config/settings.js"; +import { getTelemetry } from "../../telemetry/singleton.js"; +import { refreshLiveProviderCatalog } from "../../config/index.js"; +import { createTelemetryToggleHandler } from "../../telemetry/toggle.js"; +import { telemetryFirstRunPending } from "../../telemetry/first-run.js"; +import { TELEMETRY_NOTICE } from "../../telemetry/index.js"; +import { loadStartupChangelogMarkdown, stampVersionAfterStartup } from "../../changelog/index.js"; +import pkg from "../../../package.json" with { type: "json" }; +import type { GrantScope } from "../../permission/types.js"; +import { connectProviderInline } from "../provider/connect.js"; +import { persistConnectedSelection } from "../provider/submit.js"; +import { modelOptionId } from "../model-catalog.js"; +import { prefetchGoModels } from "../../provider/opencode-go-models.js"; +import { isOpenCodeGoProvider } from "../../../packages/opencode-go/src/index.js"; +import { applyLiveModelSwitch } from "../../session/live-model-switch.js"; +import { applyFocus } from "../shell/chrome.js"; +import { setShellInputSuspended } from "../shell/prompt.js"; +import { warningsForPluginEntry } from "../../plugins/diagnostics.js"; +import { isPluginEnabledForSurface } from "../plugin-surface.js"; +import { resolveWaitForApproval } from "../tool-execution-watchdog.js"; +import { hostOf, type RunnerServices, type RunnerState } from "./state.js"; +import { LOG_NAMESPACE_ROOT } from "../../branding.js"; + +const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + +const GRANT_SCOPE_LABEL: Record = { + session: "This session", + project: "This project", + global: "Global", + "provider-model": "Provider / model", +}; + +/** + * Resolve the base for a local-settings read-modify-write. + * Absent file → empty object; unreadable/invalid → null (caller must skip write). + */ +export async function loadLocalSettingsWriteBase( + path: string, + load: (path: string) => Promise = loadLocalSettings, +): Promise { + try { + return (await load(path)) ?? {}; + } catch { + return null; + } +} + +/** First-run telemetry disclosure to show before consent-by-proceeding applies. */ +export function telemetryStartupNotice( + globalSettings: Settings | null | undefined, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + return telemetryFirstRunPending(globalSettings, env) ? TELEMETRY_NOTICE : undefined; +} + +export interface SettingsWiring { + telemetryNotice: string | undefined; + onConnectProvider: (providerName: string) => void; + onModelSelect: (id: string) => void; + onFavoriteToggle: (id: string) => void; + onSetDefault: (id: string) => void; + surfaces: { + permissions: ReturnType; + plugins: ReturnType; + hooks: ReturnType; + settings: ReturnType; + }; +} + +/** + * Wire everything settings-shaped in original runTUI order: the hook + * classification + enable persistence, the first-run onboarding block, and + * the host surfaces / model handlers that read it. + */ +export async function wireSettings( + state: RunnerState, + services: RunnerServices, +): Promise { + // Cheap static check, not a real parser: a shell hook always receives the + // lifecycle name as $1, so it can react to either; a TypeScript hook's + // exports tell us which of postTurn/postRun it actually implements. + const hookRunsOn = new Map(); + for (const status of services.hookManager.getStatuses()) { + if (status.type === "shell") { + hookRunsOn.set(status.id, "runs postTurn and postRun (receives the lifecycle name as $1)"); + continue; + } + try { + const source = await readFile(status.path, "utf8"); + const hasPostTurn = /export\s+(async\s+)?function\s+postTurn\b/.test(source); + const hasPostRun = /export\s+(async\s+)?function\s+postRun\b/.test(source); + hookRunsOn.set( + status.id, + hasPostTurn && hasPostRun + ? "runs postTurn and postRun" + : hasPostTurn + ? "runs postTurn" + : hasPostRun + ? "runs postRun" + : "no postTurn/postRun export found — see file", + ); + } catch { + hookRunsOn.set(status.id, "could not read hook file — see file"); + } + } + const persistHookSettings = async (): Promise => { + const result = await services.globalSettingsWriter.mutate((base) => ({ + ...base, + hooks: state.liveHookConfig, + })); + if (result === "skipped") { + tuiLogger.warn("Skipping hook settings write: unreadable global settings at {path}", { + path: state.config.globalSettingsPath, + }); + } + }; + const setHookEnabled = async (id: string, enabled: boolean): Promise => { + services.hookManager.setEnabled(id, enabled); + state.liveHookConfig = { ...state.liveHookConfig, [id]: { enabled } }; + await persistHookSettings(); + }; + + // The `onboarded` flag is global user state: read and written against the TRUE + // global settings file, never config.globalSettingsPath (which is the --config + // file when one was given). This keeps first-run detection consistent and stops + // a --config launch from stamping project-config contents into the global file. + const trueGlobalSettingsPath = state.trueGlobalSettingsPath; + const globalSettingsForOnboarding = await loadSettings(trueGlobalSettingsPath); + + // Consent by proceeding (see telemetry/first-run.ts): on a first run the + // singleton is a held no-op and the passive banner below is the + // disclosure. The first interactively submitted prompt activates telemetry + // and fires the held cli_start; a user who never acts keeps the hold for + // this whole launch, and the render stamp means events start normally on + // the next one. Keyed off the same TRUE global settings file as + // `onboarded` above. + const onChangeTelemetryEnabled = createTelemetryToggleHandler( + trueGlobalSettingsPath, + undefined, + services.globalSettingsWriter.enqueue, + ); + state.telemetryFirstRun = telemetryFirstRunPending(globalSettingsForOnboarding); + const telemetryNotice = telemetryStartupNotice(globalSettingsForOnboarding); + // Tracks the user's intent (persisted opt-in, updated live by the settings + // toggle) rather than the held instance's state, so the settings tab shows + // On during the hold and an opt-out before the first action suppresses + // activation entirely. + state.liveTelemetryIntent = state.telemetryFirstRun || getTelemetry().enabled; + if (state.telemetryFirstRun) { + void services.globalSettingsWriter + .enqueue(() => markTelemetryNoticeShown(trueGlobalSettingsPath)) + .catch(() => { + // Best-effort: worst case the notice shows again next launch. + }); + } + + // Post-upgrade release notes watermark policy (CL-5475): + // - first_install: stamp quietly so later launches do not dump history. + // - upgrade: stamp only when notes were actually shown. The former Ink + // whats-new banner is gone on the OpenTUI path, so notesShown is false + // until a surface is restored — never silently consume upgrade notes. + // - resume / current: leave the watermark alone. + const changelogDecision = loadStartupChangelogMarkdown({ + lastChangelogVersion: globalSettingsForOnboarding?.lastChangelogVersion, + packageVersion: typeof pkg.version === "string" ? pkg.version : "0.0.0", + }); + const notesShown = false; + const stampVersion = stampVersionAfterStartup(changelogDecision, notesShown); + if (stampVersion !== null) { + void services.globalSettingsWriter + .enqueue(() => markLastChangelogVersion(trueGlobalSettingsPath, stampVersion)) + .catch(() => { + // Best-effort watermark. + }); + } + + // Every settings RMW in this runner shares this tail, including writes to + // the true global path during a --config session. + const persistGlobalSettings = async ( + what: string, + apply: (base: Settings) => Settings, + ): Promise => { + const result = await services.globalSettingsWriter.mutate(apply); + if (result === "ok") return true; + tuiLogger.warn("Skipping {what} write: unreadable global settings at {path}", { + what, + path: state.config.globalSettingsPath, + }); + return false; + }; + + const onConnectProvider = (providerName: string): void => { + void (async () => { + let result: Awaited>; + // The setup surface shares the live session's renderer — a second + // CliRenderer cannot exist on the same stdin. Shell input stays + // suspended for the surface's lifetime so its keystrokes (including + // Ctrl+C to cancel the sign-in) never also reach the shell. + setShellInputSuspended(hostOf(state).shell, true); + try { + result = await connectProviderInline({ + providerId: providerName, + settingsPath: trueGlobalSettingsPath, + localSettingsPath: state.localSettingsFile, + existing: state.config.settings ?? null, + persistSettings: async (apply) => { + const next = await services.globalSettingsWriter.updateAt( + trueGlobalSettingsPath, + apply, + ); + if (next === null) throw new Error("global settings are unreadable"); + state.config = { ...state.config, settings: next }; + return next; + }, + createRenderer: () => Promise.resolve(hostOf(state).renderer), + }); + } catch (err) { + state.systemNotice?.( + `Connecting ${providerName} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return; + } finally { + setShellInputSuspended(hostOf(state).shell, false); + // The setup surface focused its own input; hand focus back to + // whatever shell zone owned it before the surface mounted. + applyFocus(hostOf(state).shell); + } + if (!result.connected) return; + + const onDisk = await loadSettings(trueGlobalSettingsPath); + const resolvedForCatalog: ResolvedProvider = { + apiKey: state.config.apiKey, + baseURL: state.config.baseURL, + model: state.config.model, + providerName: state.config.providerName, + ...(state.config.keyless !== undefined ? { keyless: state.config.keyless } : {}), + }; + const providers = await refreshLiveProviderCatalog(onDisk, resolvedForCatalog); + state.config = { + ...state.config, + providers, + ...(onDisk !== null ? { settings: onDisk } : {}), + }; + hostOf(state).refreshModels( + listRecentModels(state.config.settings ?? { providers: {} }), + listFavoriteModels(state.config.settings ?? { providers: {} }), + providers, + ); + // Reopen positioned at the account just connected — the picker's + // default open (top of list) would otherwise leave the operator to + // hunt for the row they just authorized. + const connectedName = result.providerName ?? providerName; + hostOf(state).openModels?.( + result.model !== undefined ? modelOptionId(connectedName, result.model) : undefined, + ); + state.systemNotice?.(`Connected ${connectedName}. Open /model to pick a model.`); + if (isOpenCodeGoProvider({ name: providerName })) { + void prefetchGoModels() + .then(async () => { + if (services.hostHolder.instance === undefined) return; + const nextDisk = await loadSettings(trueGlobalSettingsPath); + const nextProviders = await refreshLiveProviderCatalog(nextDisk, resolvedForCatalog); + state.config = { + ...state.config, + providers: nextProviders, + ...(nextDisk !== null ? { settings: nextDisk } : {}), + }; + services.hostHolder.instance.refreshModels( + listRecentModels(state.config.settings ?? { providers: {} }), + listFavoriteModels(state.config.settings ?? { providers: {} }), + nextProviders, + ); + }) + .catch((err: unknown) => { + tuiLogger.debug("go model prefetch failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + } + })().catch((err: unknown) => { + tuiLogger.debug("provider connect failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }; + + const onModelSelect = (id: string): void => { + const sep = id.indexOf(":"); + if (sep <= 0) return; + const provider = id.slice(0, sep); + const model = id.slice(sep + 1); + applyLiveModelSwitch( + { providerName: provider, model }, + { + applyIdentity: (next) => { + state.config = { ...state.config, providerName: next.providerName, model: next.model }; + }, + setPermissionIdentity: (providerName, modelName) => { + services.permissionGate.setProviderIdentity(providerName, modelName); + }, + rebuildInference: (next) => { + hostOf(state).bridge.setInferenceProviderId( + next.providerName, + state.config.settings?.providers[next.providerName]?.name, + ); + const bundle = services.buildSessionSources(); + state.agentProxy?.setSources(bundle.sources, bundle.defaultSource); + }, + refreshAdvertisedSchemas: () => { + services.directorHolder.instance?.updateToolDefinitions( + services.computeAdvertised(services.toolset.dynamicRunner.currentDefinitions()), + ); + }, + }, + ); + + const ref: ModelRef = { provider, model }; + void (async () => { + let next: Settings | undefined; + const result = await services.globalSettingsWriter.mutateAt( + trueGlobalSettingsPath, + (onDisk) => { + next = pushRecentModel(onDisk, ref); + return next; + }, + ); + if (result === "skipped" || next === undefined) { + throw new Error("global settings are unreadable"); + } + state.config = { ...state.config, settings: next }; + hostOf(state).refreshModels(listRecentModels(next), listFavoriteModels(next)); + })().catch((err: unknown) => { + tuiLogger.debug("model selection persist failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }; + + const onFavoriteToggle = (id: string): void => { + const sep = id.indexOf(":"); + if (sep <= 0) return; + const ref: ModelRef = { provider: id.slice(0, sep), model: id.slice(sep + 1) }; + void (async () => { + let next: Settings | undefined; + const result = await services.globalSettingsWriter.mutateAt( + trueGlobalSettingsPath, + (onDisk) => { + next = toggleFavoriteModel(onDisk, ref); + return next; + }, + ); + if (result === "skipped" || next === undefined) { + throw new Error("global settings are unreadable"); + } + state.config = { ...state.config, settings: next }; + hostOf(state).refreshModels(listRecentModels(next), listFavoriteModels(next)); + })().catch((err: unknown) => { + tuiLogger.debug("favorite toggle persist failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }; + + const onSetDefault = (id: string): void => { + const sep = id.indexOf(":"); + if (sep <= 0) return; + const ref: ModelRef = { provider: id.slice(0, sep), model: id.slice(sep + 1) }; + void (async () => { + let next: Settings | undefined; + const result = await services.globalSettingsWriter.mutateAt( + trueGlobalSettingsPath, + (onDisk) => { + next = setDefaultModel( + onDisk, + ref, + state.config.providers.find((provider) => provider.name === ref.provider), + ); + return next; + }, + ); + if (result === "skipped" || next === undefined) { + throw new Error("global settings are unreadable"); + } + await persistConnectedSelection(state.localSettingsFile, ref.provider, ref.model); + state.config = { ...state.config, settings: next }; + state.systemNotice?.(`Default set to ${ref.model} (${ref.provider})`); + })().catch((err: unknown) => { + tuiLogger.debug("set default persist failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }; + + return { + telemetryNotice, + onConnectProvider, + onModelSelect, + onFavoriteToggle, + onSetDefault, + surfaces: { + permissions: createPermissionsSurface(state, services), + plugins: createPluginsSurface(state, services), + hooks: createHooksSurface(state, services, hookRunsOn, setHookEnabled), + settings: createSettingsSurface( + state, + services, + onChangeTelemetryEnabled, + persistGlobalSettings, + ), + }, + }; +} + +function createPermissionsSurface(state: RunnerState, _services: RunnerServices) { + return { + list: async () => { + state.listedGrants = await _services.permissionsAdmin.list(); + return state.listedGrants.map((entry, index) => ({ + id: String(index), + scopeLabel: GRANT_SCOPE_LABEL[entry.scope], + tool: entry.tool, + pattern: entry.pattern, + ...(entry.providerModel !== undefined ? { providerModel: entry.providerModel } : {}), + })); + }, + revoke: async (id: string) => { + const entry = state.listedGrants[Number(id)]; + if (entry !== undefined) await _services.permissionsAdmin.revoke(entry); + }, + }; +} + +function createPluginsSurface(state: RunnerState, services: RunnerServices) { + return { + cwd: state.config.cwd, + home: homedir(), + list: () => { + const cfg = services.pluginsAdmin.getConfig(); + return services.pluginsAdmin.list().map((p) => { + const mod = services.pluginState.modules.find((m) => m.manifest?.id === p.id); + const attributed = warningsForPluginEntry(state.standingPluginWarnings, { + id: p.id, + ...(p.agentProfiles !== undefined ? { agentProfiles: p.agentProfiles } : {}), + }); + return { + id: p.id, + name: p.name, + origin: p.origin, + enabled: isPluginEnabledForSurface(mod, cfg), + credentials: p.credentials, + credentialValues: cfg[p.id]?.credentials ?? {}, + ...(p.kind !== undefined ? { kind: p.kind } : {}), + ...(p.description !== undefined ? { description: p.description } : {}), + ...(p.needsTrust === true ? { needsTrust: true } : {}), + ...(p.canRevokeTrust === true ? { canRevokeTrust: true } : {}), + ...(p.agentProfiles !== undefined ? { agentProfiles: p.agentProfiles } : {}), + ...(p.pluginPath !== undefined + ? { pluginPath: p.pluginPath, originPath: p.pluginPath } + : mod?.pluginPath !== undefined + ? { pluginPath: mod.pluginPath, originPath: mod.pluginPath } + : {}), + ...(p.source !== undefined + ? { source: p.source } + : mod?.source !== undefined + ? { source: mod.source } + : {}), + ...(attributed.length > 0 ? { warnings: attributed } : {}), + }; + }); + }, + setEnabled: async (id: string, enabled: boolean) => { + const existing = services.pluginsAdmin.getConfig()[id] ?? {}; + return (await services.pluginsAdmin.saveConfig(id, { ...existing, enabled })) ?? undefined; + }, + saveCredentials: async (id: string, credentials: Record) => { + const existing = services.pluginsAdmin.getConfig()[id] ?? {}; + await services.pluginsAdmin.saveConfig(id, { ...existing, credentials }); + }, + verify: (id: string, credentials: Record) => + services.pluginsAdmin.verify(id, credentials), + addPath: (path: string) => services.pluginsAdmin.addPath(path), + remove: (id: string) => services.pluginsAdmin.remove(id), + webProviders: () => services.pluginState.webCandidates.map((c) => ({ id: c.id, name: c.name })), + currentWebProvider: () => services.pluginsAdmin.getWebOverride(), + setWebProvider: (id: string | undefined) => services.pluginsAdmin.setWebOverride(id), + loadWarnings: () => state.standingPluginWarnings, + }; +} + +function createHooksSurface( + _state: RunnerState, + services: RunnerServices, + hookRunsOn: Map, + setHookEnabled: (id: string, enabled: boolean) => Promise, +) { + return { + list: () => + services.hookManager.getStatuses().map((status) => ({ + id: status.id, + name: status.name, + type: status.type, + path: status.path, + enabled: status.enabled, + runsOn: hookRunsOn.get(status.id) ?? "see file", + })), + setEnabled: (id: string, enabled: boolean) => setHookEnabled(id, enabled), + }; +} + +function createSettingsSurface( + state: RunnerState, + services: RunnerServices, + onChangeTelemetryEnabled: (enabled: boolean) => boolean, + persistGlobalSettings: (what: string, apply: (base: Settings) => Settings) => Promise, +) { + return { + read: () => ({ + compactionMode: state.liveCompactionMode, + waitForApproval: resolveWaitForApproval(services.liveToolWatchdog), + telemetryEnabled: state.liveTelemetryIntent, + showPromptCost: state.liveShowPromptCost, + }), + setCompactionMode: (mode: NonNullable) => { + state.liveCompactionMode = mode; + void persistGlobalSettings("compaction mode", (base) => ({ + ...base, + compactionMode: mode, + })); + }, + setWaitForApproval: (value: boolean) => { + services.liveToolWatchdog.waitForApproval = value; + void persistGlobalSettings("wait-for-approval", (base) => ({ + ...base, + tools: { ...base.tools, waitForApproval: value }, + })); + }, + setTelemetryEnabled: (enabled: boolean) => { + // Only flip the live intent when the toggle is accepted. Env kill + // switches refuse re-enable; leaving the UI on while capture stays + // off is a silent lie. + if (!onChangeTelemetryEnabled(enabled)) { + state.systemNotice?.( + "Telemetry stays off — disabled by DO_NOT_TRACK or CORBITS_TELEMETRY.", + ); + return; + } + state.liveTelemetryIntent = enabled; + }, + setShowPromptCost: (value: boolean) => { + state.liveShowPromptCost = value; + hostOf(state).refreshCostContext(); + void persistGlobalSettings("show prompt cost", (base) => ({ + ...base, + showPromptCost: value, + })); + }, + hooksSummary: () => { + const statuses = services.hookManager.getStatuses(); + return { + discovered: statuses.length, + off: statuses.filter((s) => !s.enabled).length, + }; + }, + openHooks: () => state.dispatchCommand?.("hooks", ""), + }; +} diff --git a/src/tui/runtime-shutdown.ts b/src/tui/runner/shutdown.ts similarity index 100% rename from src/tui/runtime-shutdown.ts rename to src/tui/runner/shutdown.ts diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts new file mode 100644 index 000000000..0db63a254 --- /dev/null +++ b/src/tui/runner/state.ts @@ -0,0 +1,322 @@ +/** + * Shared mutable state for the runner split (CL-6791 phase 4), following the + * provider-setup `SetupState` pattern: `runTUI` in index.ts threads one state + * bag plus one const services object through the submit/settings/exit/ + * commands/mcp/session factories so the extracted modules see the same live + * bindings the old closure did. Lives in its own leaf module because the + * sibling modules must not import each other (only index composes them), yet + * need the same contracts. + */ + +import type { Agent } from "@intx/agent"; +import type { ContextStore, InboundMessage, InferenceSource } from "@intx/types/runtime"; +import type { Config } from "../../config/index.js"; +import { codexProfileFromProviderName } from "../../config/codex-providers.js"; +import { xaiProfileFromProviderName } from "../../config/xai-providers.js"; +import type { MCPServerConfig, MCPServerSettingsEntry, Settings } from "../../config/settings.js"; +import { globalSettingsPath, resolveLocalSettingsPath } from "../../config/settings.js"; +import { resolveLiveSessionSources } from "../../session/assemble-runtime.js"; +import type { prepareTUISession } from "../session-start.js"; +import type { PersistMCPServerListResult } from "../../mcp/add-server.js"; +import type { ProviderFailureAttempt } from "../provider/failure-attempt.js"; +import type { ScopedApproval } from "../../permission/admin.js"; +import type { ConnectedMcpServer, RunState } from "../../session/state.js"; +import type { PendingImageAttachment } from "../image-attachments.js"; +import type { SubmitOutcome } from "./submit.js"; +import type { mountRunnerHost } from "./host.js"; +import { EventEmitter } from "node:events"; + +export type TUIStart = NonNullable>>; + +export type RunnerHost = Awaited>; + +/** + * Why a run.json snapshot is being written. Only "run-end" ends the run + * itself and so clears the active-run handle that the crash handler in + * index.ts reads. + * + * RunState.status cannot stand in for this. A /clear or /new rotation + * persists a terminal "done" for the outgoing session while the process + * keeps running under a fresh session id, so inferring "the run is over" + * from a non-"running" status disarms crash finalization for everything + * after the first rotation -- the session that dies then never gets its + * terminal record and reads as "running" forever. + */ +export type SnapshotKind = "progress" | "session-rotation" | "run-end"; + +export type SnapshotStatus = RunState["status"]; + +export type SnapshotExtra = Pick; + +/** The provider/model identity an inference attempt reports failures against. */ +export interface InferenceAttemptIdentity { + providerId: string; + displayLabel?: string; +} + +/** + * The const bindings of the old runTUI closure: services assembled once (by + * assembleTUISession) and never reassigned. index.ts builds the state bag + * first, then threads (state, services) through every runner factory. + */ +export interface RunnerServices { + emitter: EventEmitter; + globalSettingsWriter: ReturnType< + typeof import("../../mcp/add-server.js").createGlobalSettingsWriter + >; + localSettingsWriter: ReturnType< + typeof import("../../mcp/add-server.js").createLocalSettingsWriter + >; + hookManager: Awaited< + ReturnType + >["hookManager"]; + runSink: Awaited< + ReturnType + >["runSink"]; + cycleRecorder: Awaited< + ReturnType + >["cycleRecorder"]; + crashGuard: TUIStart["crashGuard"]; + activeRunHandle: TUIStart["activeRunHandle"]; + inferenceDeps: TUIStart["inferenceDeps"]; + permissionGate: Awaited< + ReturnType + >["gate"]; + approvalResume: ReturnType< + typeof import("../../session/approval-resume.js").createApprovalResume + >; + permissionsAdmin: ReturnType; + liveSubAgent: ReturnType< + typeof import("../../session/runtime-assembly.js").createLiveSubAgentSources + >; + subAgentSessions: ReturnType; + pluginState: ReturnType; + executablePlugins: () => ReturnType< + typeof import("../plugins-admin-backend.js").createPluginsAdminState + >["modules"]; + pluginsAdmin: ReturnType; + skillDirs: ReturnType< + typeof import("../../session/runtime-assembly.js").skillDirsFromEnabledPlugins + >; + liveToolWatchdog: import("../tool-execution-watchdog.js").ToolWatchdogConfig; + liveSessionMode: import("../../config/session-mode.js").SessionMode; + toolAvailability: import("../../agent/tool-search.js").ToolAvailability; + toolset: Awaited>; + systemPrompt: string; + directorHolder: { + instance?: ReturnType; + }; + hostHolder: { instance?: RunnerHost }; + workflowControllerHolder: { instance?: import("../workflow-controller.js").WorkflowController }; + workflowController: import("../workflow-controller.js").WorkflowController; + activatedToolNames: Awaited< + ReturnType + >["activated"]; + computeAdvertised: Awaited< + ReturnType + >["computeAdvertised"]; + buildAgent: ReturnType< + typeof import("../../session/assemble-runtime.js").assembleChatAgent + >["buildAgent"]; + sessionCost: ReturnType; + sessionOps: ReturnType< + typeof import("../session-operation-queue.js").createSessionOperationQueue + >; + deliveryGeneration: ReturnType; + buildSessionSources: () => import("../../session/assemble-runtime.js").LiveSessionSources; + providerFailureAttempts: ReturnType< + typeof import("../provider/failure-attempt.js").createProviderFailureAttemptTracker + >; + baseToolCount: number; + mcpStates: Map; + mcpConnectController: AbortController; +} + +/** + * The mutable bindings of the old runTUI closure: every `let` the split + * modules read or reassign lives here. Late-wired cross-module callbacks + * (systemNotice, persistRunSnapshot, ...) are optional slots invoked with + * `?.` — the same idiom the pre-split code used for stampProvider and + * paintPluginAttention — because they can only fire after index.ts wires + * them, exactly like the TDZ-safe late reads of the old closure. + */ +export interface RunnerState { + config: Config; + sessionId: string; + startedAt: number; + runTaskTitle: string; + workdir: string; + resumeSkipInitialTask: boolean; + localSettingsFile: string | null; + // The TRUE global settings path (never the --config file): telemetry and + // onboarding state are global user state. + trueGlobalSettingsPath: string; + telemetryFirstRun: boolean; + runError: string | undefined; + // A send rejected because the operator interrupted is not a failure to + // report, and it must not settle a UI the interrupt path already settled. + sendAborted: boolean; + // Host mounts later; attention is painted once the shell exists. + paintPluginAttention: ((needs: boolean) => void) | null; + standingPluginWarnings: string[]; + // Saved through onboarding's "save anyway" bypass without a passing + // connection test — surfaced once the shell exists, never before. + startupPluginNotices: string[]; + currentAgent: Agent | undefined; + // Set alongside currentAgent in buildAgent; getters wire the session's own + // blob store into the truncation spill path (see result-truncation-plugin.ts). + currentStorage: ContextStore | null; + streamPromise: Promise | undefined; + // Serial-operation contention flags: a rebuild only runs when idle, and a + // failed build poisons every later send instead of dispatching to a + // closed agent. + inFlight: number; + pendingReload: boolean; + fatalBuildError: Error | null; + // The source the next inference will use, tracked live so the compaction + // summarizer always summarizes with the current model (model switches and + // Codex token refreshes update it). + liveSource: InferenceSource; + liveSources: InferenceSource[]; + liveDefaultSource: string; + // The active Codex/xAI source, tracked whenever an OAuth profile source is + // selected so its access token can be refreshed before each send. + activeCodexSource: { profile: string; source: InferenceSource } | undefined; + activeXaiSource: { profile: string; source: InferenceSource } | undefined; + initialCodexProfile: string | undefined; + initialXaiProfile: string | undefined; + // MCP servers connected so far, keyed by name so a reconnect after a + // failure replaces rather than duplicates the entry. + connectedMcpServers: ConnectedMcpServer[]; + // Every configured server's latest settings entry, for the /mcp surface. + configuredMcpEntries: MCPServerSettingsEntry[]; + liveHookConfig: Record; + // Mutable reference so the compaction summarize callback reads the live + // mode without requiring an agent rebuild on every settings change. + liveCompactionMode: NonNullable; + // Tracks the user's intent (persisted opt-in, updated live by the settings + // toggle) rather than the held instance's state, so the settings tab shows + // On during the first-run hold. + liveTelemetryIntent: boolean; + liveShowPromptCost: boolean; + // The permissions surface addresses grants by their position in the last + // listing, so revoke resolves against the same snapshot the operator saw. + listedGrants: readonly ScopedApproval[]; + // Assigned once mountRunnerHost resolves; callbacks defined before the + // mount read it through here. + host: RunnerHost | undefined; + // Stable handle handed to the App so the underlying agent can be swapped + // out from under it without a remount; stampProvider.fn is wired by index + // once the bridge exists. + stampProvider: { fn: ((id: string | undefined) => void) | undefined }; + // Permission-gate persist notices surface through the shell once it exists. + approvalPersistNotice: { notify?: (text: string) => void }; + + // Late-wired cross-module callbacks, in original wiring order. + enqueueAgentDeliver?: (deliverToLiveAgent: () => void) => void; + reloadIfIdle?: () => void; + systemNotice?: (text: string) => void; + currentAttemptIdentity?: () => InferenceAttemptIdentity; + handleSendFailure?: ( + err: unknown, + attempt: InferenceAttemptIdentity, + providerFailure: ProviderFailureAttempt, + ) => void; + sendWithAttemptIdentity?: (message: InboundMessage) => Promise; + sendUserPrompt?: (text: string, pending: readonly PendingImageAttachment[]) => Promise; + dispatchCommand?: (name: string, args: string) => void; + newSession?: () => void; + interrupt?: () => void; + agentProxy?: Agent; + send?: (text: string, attachments?: readonly PendingImageAttachment[]) => SubmitOutcome; + connectLateMCPServer?: (server: MCPServerConfig) => void; + applyMcpCatalog?: (result: Extract) => void; + persistRunSnapshot?: ( + status: SnapshotStatus, + extra?: SnapshotExtra, + kind?: Exclude, + ) => Promise; + shutdownRuntime?: () => Promise; + stopFleetReporting?: () => void; +} + +export function recordRunError(state: RunnerState, err: unknown): void { + state.runError = err instanceof Error ? err.message : String(err); +} + +/** The live agent; every rebuild swaps the binding this reads. */ +export function liveAgent(state: RunnerState): Agent { + const agent = state.currentAgent; + if (agent === undefined) { + throw new Error("runner: agent accessed before the initial build"); + } + return agent; +} + +export function hostOf(state: RunnerState): RunnerHost { + const host = state.host; + if (host === undefined) { + throw new Error("runner: host accessed before mount"); + } + return host; +} + +/** + * Seed the state bag from the pre-try startup results. Everything here is + * available before the session lifecycle assembles; later sections assign + * the remaining fields in place, mirroring the old closure's `let` order. + * The initial source bundle is resolved eagerly because it is a pure + * function of the still-unmutated config and session id — identical inputs + * to the old closure's first call. + */ +export function createRunnerState(start: TUIStart): RunnerState { + const config = start.config; + const initialBundle = resolveLiveSessionSources(config, start.sessionId); + const state: RunnerState = { + config, + sessionId: start.sessionId, + startedAt: start.startedAt, + runTaskTitle: start.runTaskTitle, + workdir: start.workdir, + resumeSkipInitialTask: start.resumeSkipInitialTask, + localSettingsFile: resolveLocalSettingsPath(config.cwd, config.globalSettingsPath), + trueGlobalSettingsPath: globalSettingsPath(), + telemetryFirstRun: false, + runError: undefined, + sendAborted: false, + paintPluginAttention: null, + standingPluginWarnings: [...start.pluginLoadDiag.warnings], + startupPluginNotices: [], + currentAgent: undefined, + currentStorage: null, + streamPromise: undefined, + inFlight: 0, + pendingReload: false, + fatalBuildError: null, + liveSource: initialBundle.selected, + liveSources: initialBundle.sources, + liveDefaultSource: initialBundle.defaultSource, + activeCodexSource: undefined, + activeXaiSource: undefined, + initialCodexProfile: codexProfileFromProviderName(config.providerName), + initialXaiProfile: xaiProfileFromProviderName(config.providerName), + connectedMcpServers: start.resumeSeed.mcpServers, + configuredMcpEntries: [...config.mcpServerEntries], + liveHookConfig: { ...(config.settings?.hooks ?? {}) }, + liveCompactionMode: config.settings?.compactionMode ?? "llm", + liveTelemetryIntent: false, + liveShowPromptCost: config.settings?.showPromptCost ?? false, + listedGrants: [], + host: undefined, + stampProvider: { fn: undefined }, + approvalPersistNotice: {}, + }; + // Saved through onboarding's "save anyway" bypass without a passing + // connection test — warn now instead of a bare adapter error on first send. + if (config.verified === false) { + state.startupPluginNotices.push( + `We couldn't confirm your "${config.providerName}" key works. If your first message fails with an auth error, double-check the key.`, + ); + } + return state; +} diff --git a/src/tui/runner/submit.ts b/src/tui/runner/submit.ts new file mode 100644 index 000000000..773732905 --- /dev/null +++ b/src/tui/runner/submit.ts @@ -0,0 +1,375 @@ +/** + * Submit path for the TUI runner: composer-line routing/classification, the + * submit handler, the operator inbound-message builder, the send-failure + * settle path, the full user-prompt send, and the host's queued/steer + * deliver routing. + */ + +import { getLogger } from "@intx/log"; +import type { InboundMessage } from "@intx/types/runtime"; +import { OPERATOR_ORIGINATED_FLAG } from "../../agent/message-provenance.js"; +import { appendSentMessage } from "../../session/sent-messages.js"; +import { getTelemetry } from "../../telemetry/singleton.js"; +import { + cancelFeedbackCapture, + captureFeedback, + feedbackResultMessage, + isFeedbackCapturePending, + getLastTurnTraceId, + takeFeedbackCapture, +} from "../../telemetry/feedback.js"; +import { activateHeldTelemetry } from "../../telemetry/first-run.js"; +import { setShellRunState } from "../shell/chrome.js"; +import { surfaceSystemNotice } from "../shell/prompt.js"; +import { + captureAuthFailure, + classifyAgentSendFailure, + shouldSettleUiAfterSendFailure, +} from "../session-chrome.js"; +import { ingestOperatorPrompt } from "../prompt-attachments.js"; +import { imageAttachmentFromPath, type PendingImageAttachment } from "../image-attachments.js"; +import { + createLeftoverSend, + createLiveSteerDeliver, + routeQueuedDelivery, +} from "../queued-delivery.js"; +import type { InferenceAttemptIdentity } from "./state.js"; +import { tuiSendFailureMessage } from "./send-failure-message.js"; +import type { ProviderFailureAttempt } from "../provider/failure-attempt.js"; +import type { Agent } from "@intx/agent"; +import { hostOf, type RunnerServices, type RunnerState } from "./state.js"; +import { LOG_NAMESPACE_ROOT } from "../../branding.js"; + +const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + +export type SubmissionRoute = + | { kind: "empty" } + | { kind: "command"; name: string; args: string } + | { kind: "prompt"; text: string }; + +/** + * Decide what a submitted composer line is. A leading `/` means a slash command + * — it must never reach the model as a prompt, whether it was typed directly or + * picked from the palette. + */ +export function routeSubmission(raw: string): SubmissionRoute { + const trimmed = raw.trim(); + if (trimmed.length === 0) return { kind: "empty" }; + const body = trimmed.startsWith("/") ? trimmed.slice(1).trim() : trimmed; + if (!trimmed.startsWith("/")) return { kind: "prompt", text: trimmed }; + if (body.length === 0) return { kind: "empty" }; + const sep = body.search(/\s/); + return sep === -1 + ? { kind: "command", name: body, args: "" } + : { kind: "command", name: body.slice(0, sep), args: body.slice(sep + 1).trim() }; +} + +export interface SubmitHandlerDeps { + dispatchCommand: (name: string, args: string) => void; + sendPrompt: (text: string, attachments?: readonly PendingImageAttachment[]) => void; + /** Consent-by-proceeding hook: runs only for real prompts, never commands. */ + onPromptSubmitted?: () => void; + /** + * When true, the next non-command submit is treated as intentional feedback + * text (bare `/feedback` multi-turn mode) instead of a model prompt. + */ + isFeedbackCapturePending?: () => boolean; + /** Consume the pending feedback arm and handle the text; return operator message. */ + onFeedbackText?: (text: string) => string; + /** Drop a pending multi-turn /feedback arm (empty Enter cancel). */ + cancelFeedbackCapture?: () => void; + /** Surface a local system notice (feedback thanks / blocked / cancelled). */ + onSystemNotice?: (text: string) => void; +} + +/** + * Composer submit handler. Slash input is dispatched against the command + * registry instead of being sent to the model. When feedback capture is armed + * (bare `/feedback`), the next non-command line is captured as survey text. + * + * Returns an outcome so the session bridge can keep local-only submits off the + * agent busy path and out of the mid-run queue. + */ +export type SubmitOutcome = "agent" | "local" | "empty"; + +/** + * Classify a composer line without side effects. Local = slash command or + * armed multi-turn feedback text; empty = no-op (or cancel-feedback); agent = + * real model turn. + */ +export function classifySubmission( + text: string, + options: { + hasAttachments?: boolean; + feedbackPending?: boolean; + feedbackCaptureEnabled?: boolean; + } = {}, +): SubmitOutcome { + const route = routeSubmission(text); + const hasAttachments = options.hasAttachments === true; + if (route.kind === "empty" && !hasAttachments) return "empty"; + if (route.kind === "command") return "local"; + if ( + route.kind === "prompt" && + options.feedbackPending === true && + options.feedbackCaptureEnabled === true + ) { + return "local"; + } + return "agent"; +} + +export function createSubmitHandler( + deps: SubmitHandlerDeps, +): (text: string, attachments?: readonly PendingImageAttachment[]) => SubmitOutcome { + return (text, attachments) => { + const route = routeSubmission(text); + const hasAttachments = attachments !== undefined && attachments.length > 0; + const feedbackPending = deps.isFeedbackCapturePending?.() === true; + const feedbackCaptureEnabled = deps.onFeedbackText !== undefined; + const outcome = classifySubmission(text, { + hasAttachments, + feedbackPending, + feedbackCaptureEnabled, + }); + + // Empty Enter while /feedback is armed cancels instead of trapping the + // operator until they type free text or /clear. + if (outcome === "empty") { + if (feedbackPending) { + deps.cancelFeedbackCapture?.(); + deps.onSystemNotice?.("Feedback cancelled."); + } + return "empty"; + } + if (route.kind === "command") { + // Any other slash command drops a bare-/feedback arm so the next + // free-text line is not mis-routed as survey text. + if (feedbackPending && route.name !== "feedback") { + deps.cancelFeedbackCapture?.(); + } + deps.dispatchCommand(route.name, route.args); + return "local"; + } + // Multi-turn /feedback: next Enter is survey text, not a model prompt. + if (outcome === "local" && deps.onFeedbackText !== undefined) { + const notice = deps.onFeedbackText(route.kind === "prompt" ? route.text : text); + deps.onSystemNotice?.(notice); + return "local"; + } + deps.onPromptSubmitted?.(); + deps.sendPrompt(route.kind === "prompt" ? route.text : "", attachments); + return "agent"; + }; +} + +/** Text sent alongside an image when the operator attached one without a prompt. */ +export const IMAGE_ONLY_PROMPT = "Please inspect the attached image."; + +/** + * Build the inbound message for a genuine operator submit — the real + * prompt-submit path in the TUI (sendUserPrompt / the "send" command + * result), with or without attachments. Carries OPERATOR_ORIGINATED_FLAG so + * director.ts's loop-protection backstop can tell this apart from + * system-originated sends (compaction continuations, retries, nudges). + */ +export function userInboundMessage( + text: string, + attachments: readonly PendingImageAttachment[], +): InboundMessage { + return { + ref: { uid: 1, mailbox: "INBOX" }, + headers: { + from: "user@local", + to: ["agent@local"], + date: new Date().toISOString(), + messageId: `<${crypto.randomUUID()}@local>`, + interchangeType: "conversation.message", + }, + flags: [OPERATOR_ORIGINATED_FLAG], + signatureStatus: "missing", + content: text.length > 0 ? text : IMAGE_ONLY_PROMPT, + attachments: attachments.map((a) => ({ + name: a.name, + contentType: a.contentType, + data: a.data, + })), + }; +} + +/** + * Wire the runtime submit path: system notices, the send-failure settle + * path, attempt-tracked sends, and the full user-prompt send. + */ +export function createSubmitPath( + state: RunnerState, + services: RunnerServices, + live: { attemptIdentity: () => InferenceAttemptIdentity; agentProxy: Agent }, +): { send: (text: string, attachments?: readonly PendingImageAttachment[]) => SubmitOutcome } { + // Routed through the shell's notice path rather than straight into the + // transcript: anything the runner says before the first turn arrives while + // the landing hero still owns the screen, and a transcript row there wipes + // the whole composition. Once a session row has ended the landing this is an + // ordinary system row, so there is no second behaviour to reason about. + const systemNotice = (text: string): void => { + surfaceSystemNotice(hostOf(state).shell, text); + }; + state.systemNotice = systemNotice; + state.approvalPersistNotice.notify = systemNotice; + + const isCodexAuthError = (err: unknown): boolean => + err instanceof Error && err.name === "CodexAuthError"; + const isXaiAuthError = (err: unknown): boolean => + err instanceof Error && err.name === "XaiAuthError"; + + /** Settle the shell after a rejected send so the run does not look live. */ + const handleSendFailure = ( + err: unknown, + attempt: InferenceAttemptIdentity, + providerFailure: ProviderFailureAttempt, + ): void => { + const failure = classifyAgentSendFailure( + err, + state.sendAborted, + isCodexAuthError, + isXaiAuthError, + ); + captureAuthFailure(getTelemetry(), failure); + if (!shouldSettleUiAfterSendFailure(failure.kind)) return; + if (failure.kind === "abort") return; + state.runError = err instanceof Error ? err.message : String(err); + if (!providerFailure.presented) { + systemNotice( + tuiSendFailureMessage( + err, + failure.kind, + providerFailure.observed, + attempt, + providerFailure.error, + ), + ); + services.providerFailureAttempts.markPresented(providerFailure); + } + setShellRunState(hostOf(state).shell, "idle"); + }; + state.handleSendFailure = handleSendFailure; + + const sendWithAttemptIdentity = async (message: InboundMessage): Promise => { + const attempt = live.attemptIdentity(); + const providerFailure = services.providerFailureAttempts.begin(attempt); + try { + const result = await live.agentProxy.send(message); + // An ask-tier call parked on the reactor's approval gate settles the + // send early; resolve the operator surface here and deliver the + // decision on the correlationId signal channel so the parked run + // resumes. + await services.approvalResume.handle(result); + } catch (error) { + handleSendFailure(error, attempt, providerFailure); + } finally { + services.providerFailureAttempts.sendSettled(providerFailure); + } + }; + state.sendWithAttemptIdentity = sendWithAttemptIdentity; + + /** + * Full user-prompt send path: inline image paths become attachments, + * @mentions are expanded, and the message is recorded for Up/Down recall. + */ + const sendUserPrompt = async ( + text: string, + pending: readonly PendingImageAttachment[], + ): Promise => { + state.sendAborted = false; + if (text.trim().length > 0) { + void appendSentMessage(state.config.cwd, state.sessionId, text).catch((err: unknown) => { + tuiLogger.debug("sent-message append failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + } + const ingested = await ingestOperatorPrompt( + text, + state.config.cwd, + imageAttachmentFromPath, + pending, + ); + await sendWithAttemptIdentity(userInboundMessage(ingested.text, ingested.attachments)); + }; + state.sendUserPrompt = sendUserPrompt; + + const send = createSubmitHandler({ + dispatchCommand: (name, args) => state.dispatchCommand?.(name, args), + sendPrompt: (text, attachments) => { + void sendUserPrompt(text, attachments ?? []).catch((error: unknown) => { + handleSendFailure(error, live.attemptIdentity(), { + observed: false, + presented: false, + error: undefined, + }); + }); + }, + onPromptSubmitted: () => { + if (state.telemetryFirstRun && state.liveTelemetryIntent) { + void activateHeldTelemetry(state.trueGlobalSettingsPath, () => state.liveTelemetryIntent); + } + }, + isFeedbackCapturePending, + cancelFeedbackCapture, + onFeedbackText: (text) => { + takeFeedbackCapture(); + const status = captureFeedback(getTelemetry(), text, { + turnTraceId: getLastTurnTraceId(), + }); + return feedbackResultMessage(status); + }, + onSystemNotice: systemNotice, + }); + state.send = send; + return { send }; +} + +/** Queued-drain and live-steer deliver routing handed to the host mount. */ +export function createDeliverRouting( + state: RunnerState, + services: RunnerServices, + live: { attemptIdentity: () => InferenceAttemptIdentity; agentProxy: Agent }, +): ReturnType { + const failureStub = (): ProviderFailureAttempt => ({ + observed: false, + presented: false, + error: undefined, + }); + return routeQueuedDelivery({ + send: createLeftoverSend({ + enqueue: services.sessionOps.enqueue, + ingest: (text, pending) => + ingestOperatorPrompt(text, state.config.cwd, imageAttachmentFromPath, pending), + send: (text, pending) => { + state.sendAborted = false; + void state.sendWithAttemptIdentity?.(userInboundMessage(text, pending)); + }, + recordSent: (text) => { + if (text.trim().length === 0) return; + void appendSentMessage(state.config.cwd, state.sessionId, text).catch((err: unknown) => { + tuiLogger.debug("sent-message append failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + }, + captureGeneration: services.deliveryGeneration.capture, + onFailure: (error) => state.handleSendFailure?.(error, live.attemptIdentity(), failureStub()), + }), + parentCycleLive: () => hostOf(state).bridge.parentCycleLive, + deliverSteer: createLiveSteerDeliver({ + enqueue: services.sessionOps.enqueue, + ingest: (text, pending) => + ingestOperatorPrompt(text, state.config.cwd, imageAttachmentFromPath, pending), + deliver: (text, pending) => { + live.agentProxy.deliver(userInboundMessage(text, pending)); + }, + captureGeneration: services.deliveryGeneration.capture, + onFailure: (error) => state.handleSendFailure?.(error, live.attemptIdentity(), failureStub()), + }), + }); +} diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts new file mode 100644 index 000000000..687b45cfc --- /dev/null +++ b/src/tui/runner/wiring.ts @@ -0,0 +1,279 @@ +/** + * Post-mount startup wiring for the TUI runner: go-model prefetch, runtime + * shutdown registration, provider-id stamping, mention/prompt recognition, + * the fleet watch, the effort-cycle chord, resume hydration, initial MCP + * connect, and startup notices. Owns the fleet timers so the quit path can + * stop them through the state slot. + */ + +import { getLogger } from "@intx/log"; +import { loadSettings, listFavoriteModels, listRecentModels } from "../../config/settings.js"; +import { refreshLiveProviderCatalog } from "../../config/index.js"; +import type { ResolvedProvider } from "../../config/settings.js"; +import { prefetchGoModels } from "../../provider/opencode-go-models.js"; +import { isOpenCodeGoProvider } from "../../../packages/opencode-go/src/index.js"; +import { loadRecentTurns } from "../../session/optimized-context-store.js"; +import { loadSentMessages } from "../../session/sent-messages.js"; +import { setActiveDisposeHost } from "../../session/active-host.js"; +import { + createFleetWatch, + FLEET_REPORT_SETTLE_MS, + FLEET_STALL_POLL_MS, + liveFleetCount, + observeFleet, +} from "../../subagent/index.js"; +import { scheduleUpgradeNotice } from "../../upgrade/index.js"; +import pkg from "../../../package.json" with { type: "json" }; +import { hydrateTasksFromTurns } from "../../agent/director.js"; +import { cycleReasoningEffort } from "../../provider/reasoning-effort.js"; +import { isCodexProviderName } from "../../config/codex-providers.js"; +import { RUNTIME_FLASH_MS } from "../runtime-notices.js"; +import { RESUME_TRANSCRIPT_BLOCK_LIMIT, turnsToContentBlocks } from "../turns-to-blocks.js"; +import { setPluginNeedsAttention, setStatusFlash } from "../shell/chrome.js"; +import { + setEffortCycleHandler, + setMentionSuggestionSource, + setPromptRecognitionSource, +} from "../shell/internals.js"; +import { + setPromptModelLabel, + setSentMessageHistory, + surfaceSystemNotice, +} from "../shell/prompt.js"; +import { listPathSuggestions } from "../components/at-mention/list.js"; +import { listCommands } from "../commands/registry.js"; +import type { MCPConnectCallbacks } from "../../agent/tools.js"; +import { createRuntimeShutdown } from "./shutdown.js"; +import { resumeTranscriptLoadErrorBlock } from "./exit.js"; +import { userInboundMessage } from "./submit.js"; +import { hostOf, liveAgent, type RunnerServices, type RunnerState } from "./state.js"; +import { LOG_NAMESPACE_ROOT } from "../../branding.js"; + +const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + +export function wirePostStartup( + state: RunnerState, + services: RunnerServices, + mcpConnectCallbacks: MCPConnectCallbacks, +): void { + if (state.config.providers.some((p) => isOpenCodeGoProvider(p))) { + void prefetchGoModels() + .then(async () => { + if (services.hostHolder.instance === undefined) return; + const onDisk = await loadSettings(state.trueGlobalSettingsPath); + const resolvedForCatalog: ResolvedProvider = { + apiKey: state.config.apiKey, + baseURL: state.config.baseURL, + model: state.config.model, + providerName: state.config.providerName, + ...(state.config.keyless !== undefined ? { keyless: state.config.keyless } : {}), + }; + const providers = await refreshLiveProviderCatalog(onDisk, resolvedForCatalog); + state.config = { + ...state.config, + providers, + ...(onDisk !== null ? { settings: onDisk } : {}), + }; + services.hostHolder.instance.refreshModels( + listRecentModels(state.config.settings ?? { providers: {} }), + listFavoriteModels(state.config.settings ?? { providers: {} }), + providers, + ); + }) + .catch((err: unknown) => { + tuiLogger.debug("go model prefetch failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + } + + const shutdownRuntime = createRuntimeShutdown({ + disposeHost: hostOf(state).dispose, + cancelWorkers: () => { + services.subAgentSessions.cancelAll("Session closed"); + }, + closeAgent: () => liveAgent(state).close(), + }); + state.shutdownRuntime = shutdownRuntime; + services.crashGuard.setDisposeHost(() => { + void shutdownRuntime(); + }); + setActiveDisposeHost(() => services.crashGuard.invokeDisposeHost()); + + // Harness inference.error events omit providerId; stamp the live catalog id + // onto the stream map so transcript copy can identify known-xAI short 429s. + state.stampProvider.fn = (id) => + hostOf(state).bridge.setInferenceProviderId( + id, + id === undefined ? undefined : state.config.settings?.providers[id]?.name, + ); + state.stampProvider.fn(state.config.providerName); + + setMentionSuggestionSource(hostOf(state).shell, (prefix) => + listPathSuggestions(prefix, state.config.cwd), + ); + + // The fleet reports itself. Store changes drive it, so a lane finishing or + // failing is on screen the moment it happens rather than at the next turn + // boundary. The settle timer coalesces a parallel burst into one observation; + // the stall poll re-runs so a lane that goes quiet with no further store + // event is still announced once. `observeFleet` decides what is worth saying. + let fleetWatch = createFleetWatch(); + const reportFleet = (): void => { + const observation = observeFleet(fleetWatch, services.subAgentSessions.list(), Date.now()); + fleetWatch = observation.watch; + for (const update of observation.updates) surfaceSystemNotice(hostOf(state).shell, update); + }; + let fleetSettle: ReturnType | null = null; + // Live-lane count feeds the bridge's idle-with-fleet hold (CL-7057): the + // run stays busy after the parent turn settles until the last lane + // terminalizes. Store notifications fire per child event, not per status + // flip, so emit only when the count itself moves. + let lastLiveFleet = 0; + const unsubscribeFleetReport = services.subAgentSessions.subscribe(() => { + const fleet = liveFleetCount(services.subAgentSessions.list()); + if (fleet !== lastLiveFleet) { + lastLiveFleet = fleet; + services.emitter.emit("event", { type: "fleet", running: fleet }); + } + if (fleetSettle !== null) return; + fleetSettle = setTimeout(() => { + fleetSettle = null; + reportFleet(); + }, FLEET_REPORT_SETTLE_MS); + if (typeof fleetSettle.unref === "function") fleetSettle.unref(); + }); + const fleetStallPoll = setInterval(reportFleet, FLEET_STALL_POLL_MS); + if (typeof fleetStallPoll.unref === "function") fleetStallPoll.unref(); + state.stopFleetReporting = (): void => { + clearInterval(fleetStallPoll); + if (fleetSettle !== null) clearTimeout(fleetSettle); + unsubscribeFleetReport(); + }; + + // Registered slash-command names only — bare skill/agent words stay unstyled. + setPromptRecognitionSource(hostOf(state).shell, () => ({ + commandNames: listCommands().map((command) => command.name), + })); + + // Shift+Tab: cycle reasoning effort for the live model and rebuild sources so + // the next inference turn picks up the new providerOptions.reasoning_effort. + setEffortCycleHandler(hostOf(state).shell, () => { + const next = cycleReasoningEffort( + state.config.model, + state.config.reasoningEffort, + isCodexProviderName(state.config.providerName), + ); + if (next === undefined) { + setStatusFlash(hostOf(state).shell, "this model has no reasoning effort levels", { + ttlMs: RUNTIME_FLASH_MS, + }); + return; + } + state.config = { ...state.config, reasoningEffort: next }; + const bundle = services.buildSessionSources(); + state.agentProxy?.setSources(bundle.sources, bundle.defaultSource); + setPromptModelLabel(hostOf(state).shell, { + profile: state.config.providerName, + model: state.config.model, + effort: next, + }); + setStatusFlash(hostOf(state).shell, `reasoning effort: ${next}`, { + ttlMs: RUNTIME_FLASH_MS, + }); + }); + + // Recall spans the whole session, including what was sent before a resume. + void loadSentMessages(state.config.cwd, state.sessionId) + .then((sent) => setSentMessageHistory(hostOf(state).shell, sent)) + .catch(() => undefined); + + if (!state.resumeSkipInitialTask && state.config.task.trim().length > 0) { + // The operator's initial task, typed as a CLI argument before launch — + // same provenance as a prompt submit. + void state.sendWithAttemptIdentity?.(userInboundMessage(state.config.task.trim(), [])); + } + + // Hydrate a resumed session's transcript after first paint. Reading history and + // mapping it to content blocks is pure I/O with no bearing on the shell, so the + // App renders empty immediately and fills in the past turns once they are ready. + // Only the tail needed to fill RESUME_TRANSCRIPT_BLOCK_LIMIT blocks is read from + // disk — a long session's full history is not needed just to paint a transcript + // that itself caps how much it displays. + void loadRecentTurns(state.workdir, RESUME_TRANSCRIPT_BLOCK_LIMIT) + .then((turns) => { + const blocks = turnsToContentBlocks(turns, { maxBlocks: RESUME_TRANSCRIPT_BLOCK_LIMIT }); + const tasks = hydrateTasksFromTurns(turns); + // Restored tasks go to the panel only. They are live state, not something + // that happened in the conversation, so putting them in scrollback as well + // renders the same list twice on one screen. + if (tasks.length > 0) services.directorHolder.instance?.restoreTasks(tasks); + if (blocks.length > 0) services.emitter.emit("history.hydrate", blocks); + }) + .catch((err: unknown) => { + // Resume still works without painted history, but a silent empty + // transcript looks like a brand-new session. Log and surface a one-line + // error block so the operator knows history failed to load. + const block = resumeTranscriptLoadErrorBlock(err); + tuiLogger.warn("Failed to load resume transcript from {workdir}: {error}", { + workdir: state.workdir, + error: err instanceof Error ? err.message : String(err), + }); + services.emitter.emit("history.hydrate", [block]); + }); + + // Connect MCP servers after the TUI is up so the UI is usable immediately and + // any OAuth authorization is surfaced as a copyable link rather than a browser + // pop. Each connected server's tools land on the live runner and are + // dispatchable the same turn (createAgentWithLiveToolDispatch). They stay + // unadvertised until tool_search promotes them. When every server has + // settled, reload-if-idle so construction-time maps match, then resume any + // persisted workflow. Aborted on exit so an unfinished auth wait does not + // keep the process alive. + void services.toolset + .connectMCP(mcpConnectCallbacks, services.mcpConnectController.signal) + .then(async () => { + if (services.toolset.dynamicRunner.currentDefinitions().length > services.baseToolCount) { + state.pendingReload = true; + state.reloadIfIdle?.(); + } + // Now that the capability map reflects connected MCP servers, restore any + // persisted workflow. New workflows are manual-only slash commands. + await services.workflowController.resume(); + }) + .catch((err: unknown) => { + // Fire-and-forget: an aborted connect on exit is expected and ignored; + // any other failure is logged rather than raised as an unhandled rejection. + if (err instanceof Error && err.name === "AbortError") return; + getLogger([LOG_NAMESPACE_ROOT, "tui", "mcp"]).error("MCP connect failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); + + // Surface fire-and-forget startup notices now that there is a shell (queued + // above, before `host` existed). Plugin load warnings are NOT notices — they + // drive `plugin !` and `/plugins` instead. + for (const notice of state.startupPluginNotices) surfaceSystemNotice(hostOf(state).shell, notice); + state.paintPluginAttention = (needs) => setPluginNeedsAttention(hostOf(state).shell, needs); + state.paintPluginAttention(state.standingPluginWarnings.length > 0); + + // The persisted /yolo default is otherwise silent: nothing on screen would + // otherwise tell the operator that permission prompts are off for a repo + // they never ran --dangerously-skip-permissions or /yolo in. + if (state.config.skipPermissionsFromSettings) { + surfaceSystemNotice( + hostOf(state).shell, + "Permission prompts are disabled by your saved default (/yolo off to re-enable).", + ); + } + + // Soft upgrade check: never blocks startup; offline / rate-limit is a quiet skip. + // surfaceSystemNotice keeps the landing hero up and flushes into the transcript + // once a session row ends the landing (same path as MCP startup chatter). + scheduleUpgradeNotice({ + notify: (text) => surfaceSystemNotice(hostOf(state).shell, text), + options: { + currentVersion: typeof pkg.version === "string" ? pkg.version : "0.0.0", + }, + }); +} diff --git a/src/tui/runtime-bridge-coalesce.test.ts b/src/tui/runtime-bridge-coalesce.test.ts new file mode 100644 index 000000000..4615aa2cf --- /dev/null +++ b/src/tui/runtime-bridge-coalesce.test.ts @@ -0,0 +1,135 @@ +/** + * Perf gate for CL-6791 P5-J1: stream deltas must coalesce to one row retext + * per renderer frame instead of one full-row reparse per token, and every + * close/settle seam must apply the accumulated tail exactly. + */ +import { describe, expect, test } from "bun:test"; +import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; +import { createAppShell } from "./shell/index"; +import { streamRowAt, streamRowCount } from "./shell/transcript"; +import { withTestRenderer } from "./harness"; +import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js"; +import type { AppShell } from "./shell/internals.js"; +import type { StreamRow } from "./stream.js"; + +type ChromeModule = typeof import("./shell/chrome.js"); +interface ReplaceCalls { + count: number; +} + +/** + * Count `replaceStreamRowAt` invocations (the retext seam the bridge drives) + * by passing the real chrome module through, so counts reflect production + * behavior while remaining observable. + */ +async function withCountedReplaceStreamRowAt( + run: (calls: ReplaceCalls) => Promise, +): Promise { + const calls: ReplaceCalls = { count: 0 }; + return withMockedModuleDuring( + import.meta.resolve("./shell/chrome.js"), + (real) => ({ + ...real, + replaceStreamRowAt: (shell: AppShell, index: number, row: StreamRow) => { + calls.count++; + real.replaceStreamRowAt(shell, index, row); + }, + }), + () => run(calls), + ); +} + +describe("runtime-bridge stream row coalescing", () => { + test("assistant deltas within one frame retext once, idle frame retexts none", async () => { + await withCountedReplaceStreamRowAt(async (calls) => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const clock = { ms: 1000 }; + const bridge = attachSessionBridge(shell, createRecordingPort(), { + now: () => clock.ms, + schedule: () => () => {}, + }); + try { + const tokens = ["The ", "quick ", "brown ", "fox ", "jumps."]; + for (const token of tokens) { + bridge.handle({ type: "assistant.delta", text: token }); + } + // Deltas only accumulate: no retext has happened, and the row the + // first delta appended still carries only that first token. + expect(calls.count).toBe(0); + expect(streamRowCount(shell)).toBe(1); + expect(streamRowAt(shell, 0)?.text).toBe(tokens[0]); + + await h.renderOnce(); + expect(calls.count).toBe(1); + expect(streamRowAt(shell, 0)?.text).toBe(tokens.join("")); + + // A frame with no new deltas repaints nothing. + await h.renderOnce(); + expect(calls.count).toBe(1); + + // Turn end closes the row with the full accumulated text. + bridge.handle({ type: "system", text: "done" }); + const finalRow = streamRowAt(shell, 0); + expect(finalRow?.text).toBe(tokens.join("")); + expect(finalRow?.streaming).not.toBe(true); + expect(calls.count).toBe(2); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + }); + + test("thinking deltas coalesce the same way and flush their tail on close", async () => { + await withCountedReplaceStreamRowAt(async (calls) => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const clock = { ms: 1000 }; + const bridge = attachSessionBridge(shell, createRecordingPort(), { + now: () => clock.ms, + schedule: () => () => {}, + }); + try { + const tokens = ["reason ", "one ", "two ", "three."]; + for (const token of tokens) { + bridge.handle({ type: "thinking.delta", text: token }); + } + expect(calls.count).toBe(0); + + // Reveal is time-bounded; a frame with elapsed clock retexts once. + clock.ms += 500; + await h.renderOnce(); + expect(calls.count).toBe(1); + + // Idle frame: reveal position already caught up, nothing new. + await h.renderOnce(); + expect(calls.count).toBe(1); + + bridge.handle({ type: "system", text: "done" }); + const finalRow = streamRowAt(shell, 0); + expect(finalRow?.text).toBe(tokens.join("")); + expect(finalRow?.streaming).not.toBe(true); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + }); +}); diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 9a5dae2db..7ec4262d8 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -7,7 +7,9 @@ import { type TaskProgressSession, } from "./runtime-bridge"; import { DEFAULT_STALL_MS } from "./agent-progress"; -import { appendStreamRow, createAppShell, paintChrome, streamRowCount } from "./shell"; +import { appendStreamRow, paintChrome } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import { streamRowCount } from "./shell/transcript"; import { STEER_WAIT_NOTICE_MS } from "./notice-line"; import { withTestRenderer } from "./harness"; import { badgeCount } from "./session-queue"; @@ -616,6 +618,9 @@ describe("attachSessionBridge", () => { type: "inference.text.delta", data: { token: "the answer." }, }); + // Deltas coalesce: the accumulated text lands at the next renderer + // frame, not per token. + await h.renderOnce(); const assistant = shell.streamLog.filter((r) => r.role === "assistant"); expect(assistant).toHaveLength(1); @@ -1416,11 +1421,8 @@ describe("syncAgentProgress", () => { wireKeys: false, run: "busy", }); - // Padding rows ahead of the dispatch: proves churn stays bounded by - // outstanding task calls, not by transcript length. - for (let i = 0; i < 40; i++) { + for (let i = 0; i < 40; i++) appendStreamRow(shell, { role: "assistant", text: `filler ${i}` }); - } let nowMs = 0; const bridge = attachSessionBridge(shell, createRecordingPort(), { now: () => nowMs, @@ -1443,9 +1445,9 @@ describe("syncAgentProgress", () => { bridge.syncAgentProgress([ taskSession({ currentToolName: "grep", lastActivityAt: nowMs }), ]); + await h.renderOnce(); expect(streamRowCount(shell)).toBe(rowCountBefore); - // One rewrite per changed tick, never proportional to the 40 padding rows. expect(removeSpy.mock.calls.length).toBeLessThanOrEqual(2); const row = shell.streamLog[rowCountBefore - 1]!; @@ -1457,6 +1459,7 @@ describe("syncAgentProgress", () => { bridge.syncAgentProgress([ taskSession({ currentToolName: "grep", lastActivityAt: 42_000 }), ]); + await h.renderOnce(); const stalledRow = shell.streamLog[rowCountBefore - 1]!; expect(stalledRow.agentWorking).toBe(false); @@ -1542,6 +1545,7 @@ describe("syncAgentProgress", () => { const index = shell.streamLog.length - 1; nowMs = 42_000; bridge.syncAgentProgress([taskSession({ lastActivityAt: nowMs })]); + await h.renderOnce(); const row = shell.streamLog[index]!; expect(row.agentWorking).toBe(true); expect(row.stat).toContain("grep"); @@ -1586,6 +1590,7 @@ describe("in-flight tool row elapsed time", () => { nowMs = 65_000; tick?.(); + await h.renderOnce(); expect(shell.streamLog[index]!.stat).toBe("1:05"); bridge.handle({ diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 3a9b9ebb2..c105ca6dd 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -18,19 +18,16 @@ import { } from "./session-queue.js"; import { appendStreamRow, - applyShellInterrupt, - clearShellBridgeHooks, paintChrome, paintLanding, replaceStreamRowAt, setLockupFrame, - setShellBridgeHooks, setStatusFlash, - streamRowAt, - streamRowCount, truncateStreamRows, - type AppShell, -} from "./shell.js"; +} from "./shell/chrome.js"; +import { clearShellBridgeHooks, setShellBridgeHooks, type AppShell } from "./shell/internals.js"; +import { applyShellInterrupt } from "./shell/prompt.js"; +import { streamRowAt, streamRowCount } from "./shell/transcript.js"; import { rampAnimating } from "./ramp.js"; import { onTurnBoundary } from "../agent/reactor-events.js"; import { resolveRampPhase, resolveTurnLabel, sendFailureText } from "./session-chrome.js"; @@ -61,6 +58,7 @@ import { userRowText, type PendingImageAttachment } from "./image-attachments.js import { toolCallRow } from "./diff.js"; import { toolResultRow } from "./mcp-view.js"; import { canCoalesceCall, coalesceCallRows, mergeToolRows } from "./tool-rows.js"; +import * as rowUpdates from "./row-update-queue.js"; import type { StreamRow } from "./stream.js"; import { advanceRevealChars, flattenReasoningText, type Thought } from "./thinking.js"; import { @@ -336,7 +334,7 @@ interface TurnThinking { /** Blank line between the fragments a turn thought at different moments. */ const THINKING_FRAGMENT_SEPARATOR = "\n\n"; -interface BridgeBag { +export interface BridgeBag { port: SessionPort; openRow: OpenStreamRow | null; /** @@ -409,6 +407,13 @@ interface BridgeBag { * Last-hop routing (routeQueuedDelivery) reads this when deliver runs. */ liveSteerInject: boolean; + /** + * The open row has accumulated deltas since its last paint; the retext + * happens once per renderer frame or at the next close/settle seam. + */ + dirtyOpenRow: boolean; + /** Tool-row repaints waiting for the frame flush (row-update-queue.ts). */ + pendingRowUpdates: rowUpdates.PendingRowUpdates; } const bridges = new WeakMap(); @@ -495,6 +500,7 @@ function closeOpenRow(shell: AppShell, bag: BridgeBag): void { const open = bag.openRow; if (open === null) return; bag.openRow = null; + bag.dirtyOpenRow = false; // Reasoning stops scrolling and keeps its opening line; the elapsed time and // the full chain of thought stay on the row, behind the expand key. const thought = open.kind === "thinking" ? thoughtOf(bag, open) : undefined; @@ -512,9 +518,8 @@ function growOpenRow(shell: AppShell, bag: BridgeBag, kind: OpenRowKind, text: s const open = bag.openRow; if (open !== null && open.kind === kind) { open.text += text; - if (open.folded) paintFoldedRow(shell, bag, open); - else if (kind === "thinking") advanceOpenReveal(shell, open, bag.now()); - else replaceStreamRowAt(shell, open.index, openRowContent(kind, open.text, true)); + // One repaint per renderer frame, not one per token (see flushOpenRow). + bag.dirtyOpenRow = true; return; } closeOpenRow(shell, bag); @@ -553,11 +558,16 @@ function growOpenRow(shell: AppShell, bag: BridgeBag, kind: OpenRowKind, text: s /** * Advance a "thinking" row's reveal position at the bounded rate and repaint - * if it moved. Called on every delta and on the animation tick, so the line - * both grows with new tokens and keeps crawling through buffered text during - * a pause in arrival — capped either way by what has actually arrived. + * if it moved. Called from the coalesced frame flush and the animation tick, + * so the line both grows with new tokens and keeps crawling through buffered + * text during a pause in arrival — capped either way by what has arrived. */ -function advanceOpenReveal(shell: AppShell, open: OpenStreamRow, nowMs: number): void { +function advanceOpenReveal( + shell: AppShell, + bag: BridgeBag, + open: OpenStreamRow, + nowMs: number, +): void { // A folded row is settled text above the turn's tool rows; it has no scroll // line to advance. if (open.folded) return; @@ -566,6 +576,7 @@ function advanceOpenReveal(shell: AppShell, open: OpenStreamRow, nowMs: number): open.revealAt = nowMs; if (revealed === open.revealChars) return; open.revealChars = revealed; + bag.dirtyOpenRow = false; replaceStreamRowAt( shell, open.index, @@ -573,10 +584,45 @@ function advanceOpenReveal(shell: AppShell, open: OpenStreamRow, nowMs: number): ); } +/** + * Apply the coalesced open-row paint. Deltas only accumulate text and mark the + * row dirty; this is the single retext — once per renderer frame (via + * `flushStreamRowUpdates` on the shell's frame hook) or at the next + * close/settle seam, whichever comes first. + */ +function flushOpenRow(shell: AppShell, bag: BridgeBag): void { + const open = bag.openRow; + if (open === null || !bag.dirtyOpenRow) return; + if (open.folded) { + bag.dirtyOpenRow = false; + paintFoldedRow(shell, bag, open); + return; + } + if (open.kind === "thinking") { + // Repaints iff the reveal position moved; a no-op leaves the row dirty so + // the next tick/frame retries, and closeOpenRow always applies the tail. + advanceOpenReveal(shell, bag, open, bag.now()); + return; + } + bag.dirtyOpenRow = false; + replaceStreamRowAt(shell, open.index, openRowContent(open.kind, open.text, true)); +} + +/** + * Flush the shell's dirty rows — the open streaming row (J1) and coalesced + * tool-row repaints (J3) — once per renderer frame from the shell's frame + * hook, so each coalesces to one application per frame. + */ +export function flushStreamRowUpdates(shell: AppShell): void { + const bag = bridges.get(shell); + if (bag === undefined || bag.disposed) return; + flushOpenRow(shell, bag); + rowUpdates.applyPendingRowUpdates(shell, bag); +} + /** * Paint a tool call. A repeat of the call the previous row already painted - * collapses onto that row instead of opening a new one — a model that asks the - * same question sixteen times should cost the transcript one line, not sixteen. + * collapses onto that row instead of opening a new one. */ function applyToolCall( shell: AppShell, @@ -598,7 +644,9 @@ function applyToolCall( const tail = streamRowAt(shell, count - 1); const index = canCoalesceCall(tail, row) ? count - 1 : count; if (tail !== undefined && index < count) { - replaceStreamRowAt(shell, index, coalesceCallRows(tail, row)); + // Frame-coalesced: repeat calls fold onto the pending snapshot. + const effective = bag.pendingRowUpdates.get(index) ?? tail; + rowUpdates.scheduleRowUpdate(bag, index, coalesceCallRows(effective, row)); } else { appendStreamRow(shell, row); } @@ -653,7 +701,8 @@ function applyToolResult( } if (bag.toolRows.size === 0) shell.inFlightTool = null; const index = tracked ?? bag.lastToolRow; - const rawCall = streamRowAt(shell, index); + // A close seam: apply any coalesced update first so the merge reads it. + const rawCall = rowUpdates.takePendingRowUpdate(bag, index) ?? streamRowAt(shell, index); const call = clockOwned && rawCall !== undefined ? omitStat(rawCall) : rawCall; if (call === undefined || call.pending !== true) { appendStreamRow(shell, result); @@ -664,11 +713,8 @@ function applyToolResult( /** * Refresh every tracked `spawn_agent` row with its worker's live progress — - * elapsed time, current tool, and whether it has gone quiet. Tracking lasts - * the worker lifetime, not the immediate spawn_agent tool_result. Rewrites - * each row in place through `replaceStreamRowAt`; a session that finished, - * or is missing from `sessions`, leaves its row untouched rather than - * reverting to a bare pending mark. + * elapsed time, current tool, and whether it has gone quiet — for the worker + * lifetime, not just the spawn_agent tool_result. Repaints are frame-coalesced. */ function syncAgentProgress( shell: AppShell, @@ -698,9 +744,10 @@ function syncAgentProgress( bag.spawnProgressRows.delete(callId); continue; } - if (row.stat === progress.stat && row.agentWorking === progress.working) continue; - replaceStreamRowAt(shell, index, { - ...row, + const current = bag.pendingRowUpdates.get(index) ?? row; + if (current.stat === progress.stat && current.agentWorking === progress.working) continue; + rowUpdates.scheduleRowUpdate(bag, index, { + ...current, stat: progress.stat, agentWorking: progress.working, }); @@ -715,11 +762,8 @@ function omitStat(row: StreamRow): StreamRow { /** * Refresh every plain in-flight tool call's row with how long it has been - * running. A `spawn_agent` dispatch already gets this (and more) from - * `syncAgentProgress`, so those calls are skipped here rather than double - * painted. Without a live clock an ordinary call's row sits on a static - * pending mark for however long the tool takes — indistinguishable from a - * hung turn once that stretches past a few seconds. + * running, frame-coalesced. `spawn_agent` dispatches already get this (and + * more) from `syncAgentProgress`, so they are skipped here. */ function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void { if (bag.toolCallStartedAt.size === 0) return; @@ -735,9 +779,10 @@ function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void { bag.toolCallStartedAt.delete(callId); continue; } + const current = bag.pendingRowUpdates.get(index) ?? row; const stat = clockLabel(nowMs - startedAt); - if (row.stat === stat) continue; - replaceStreamRowAt(shell, index, { ...row, stat }); + if (current.stat === stat) continue; + rowUpdates.scheduleRowUpdate(bag, index, { ...current, stat }); } } @@ -765,6 +810,7 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void { streamRowAt(shell, boundary + i), ).filter((row): row is StreamRow => row !== undefined && isLocallyQueuedUserRow(row)); truncateStreamRows(shell, boundary); + rowUpdates.dropPendingRowUpdatesFrom(bag, boundary); for (const row of localRows) appendStreamRow(shell, row); for (const [callId, index] of [...bag.toolRows]) { if (index >= boundary) { @@ -843,6 +889,10 @@ function drainLiveSteersAtBoundary(shell: AppShell, bag: BridgeBag): void { */ function settleRunToIdle(shell: AppShell, bag: BridgeBag): void { if (shell.session.run !== "busy") return; + // The turn is settling: whatever the open row accumulated must be on it + // before the settle paints, even if no renderer frame ran between the last + // delta and here. + flushOpenRow(shell, bag); bag.turnThinking = null; shell.inFlightTool = null; if (bag.liveFleet > 0) { @@ -974,6 +1024,8 @@ export function attachSessionBridge( attemptRow: null, turnThinking: null, liveSteerInject: false, + dirtyOpenRow: false, + pendingRowUpdates: new Map(), }; bridges.set(shell, bag); @@ -1053,7 +1105,7 @@ export function attachSessionBridge( // mark: it needs to keep crawling through already-arrived text even when // no new delta has landed this tick. if (bag.openRow !== null && bag.openRow.kind === "thinking") { - advanceOpenReveal(shell, bag.openRow, nowMs); + advanceOpenReveal(shell, bag, bag.openRow, nowMs); } syncToolElapsed(shell, bag, nowMs); const input = { @@ -1276,12 +1328,12 @@ export function attachSessionBridge( bag.turn = turnStateOnInterrupt(bag.turn, now()); paintPhase(); }; - const clearQueuedDelivery = (): void => { if (bag.disposed) return; shell.session = createSessionQueue("idle"); bag.pendingEchoes.length = 0; bag.liveFleet = 0; + bag.pendingRowUpdates.clear(); paintChrome(shell); }; @@ -1421,6 +1473,7 @@ export function attachSessionBridge( } }, dispose: () => { + flushOpenRow(shell, bag); bag.disposed = true; applyCadence(null); clearShellBridgeHooks(shell); diff --git a/src/tui/runtime-channels.test.ts b/src/tui/runtime-channels.test.ts index d3934edc5..6e21ae1af 100644 --- a/src/tui/runtime-channels.test.ts +++ b/src/tui/runtime-channels.test.ts @@ -13,7 +13,7 @@ import { describe, expect, test } from "bun:test"; import { createHarness } from "./harness.js"; import { mountProductHost, type ProductHostConfig } from "./product-host.js"; -import { isLanding } from "./shell.js"; +import { isLanding } from "./shell/internals.js"; async function mountHeadless(overrides: Partial = {}): Promise<{ host: Awaited>; @@ -285,9 +285,17 @@ describe("agents chrome (live strip above the prompt)", () => { */ describe("every emitted runtime channel has a subscriber", () => { const srcDir = fileURLToPath(new URL("../", import.meta.url)); - const runner = readFileSync(`${srcDir}tui/runner.ts`, "utf8"); + // CL-6791 phase 4 split src/tui/runner.ts into src/tui/runner/*; the + // emitted-channel set now spans every module in that directory. + const runnerDir = fileURLToPath(new URL("./runner/", import.meta.url)); + const runnerSources = Array.from(new Bun.Glob("*.ts").scanSync({ cwd: runnerDir })) + .filter((f) => !f.endsWith(".test.ts")) + .map((f) => readFileSync(`${runnerDir}${f}`, "utf8")) + .join("\n"); - const emitted = new Set([...runner.matchAll(/emitter\.emit\("([a-z.]+)"/g)].map((m) => m[1]!)); + const emitted = new Set( + [...runnerSources.matchAll(/emitter\.emit\("([a-z.]+)"/g)].map((m) => m[1]!), + ); // Progress pings are store-mirrored chrome, not a host paint path. emitted.delete("subagent.progress"); diff --git a/src/tui/runtime-shutdown.test.ts b/src/tui/runtime-shutdown.test.ts index f253904f0..208c36811 100644 --- a/src/tui/runtime-shutdown.test.ts +++ b/src/tui/runtime-shutdown.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { createRuntimeShutdown } from "./runtime-shutdown.js"; +import { createRuntimeShutdown } from "./runner/shutdown.js"; describe("runtime shutdown", () => { test("restores the terminal, cancels workers, and closes the primary agent", async () => { diff --git a/src/tui/shell.test.ts b/src/tui/shell.test.ts index 2d6cf52e7..9beb2f64e 100644 --- a/src/tui/shell.test.ts +++ b/src/tui/shell.test.ts @@ -10,21 +10,17 @@ import { paintStreamRow } from "./stream"; import { appendStreamRow, appendTranscript, - applyShellCancelLast, - closeInsetOverlay, - createAppShell, - interruptShell, - isTranscriptFollowing, noticeText, - openInsetOverlay, setPendingQueue, shellFocusPrompt, shellFocusTranscript, - stickyMode, - submitPrompt, toggleShellFocus, - transcriptRowLayout, -} from "./shell"; +} from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import { isTranscriptFollowing, stickyMode } from "./shell/internals"; +import { closeInsetOverlay, openInsetOverlay } from "./shell/overlay-host"; +import { applyShellCancelLast, interruptShell, submitPrompt } from "./shell/prompt"; +import { transcriptRowLayout } from "./shell/transcript"; /** The transient notice row sits directly above the prompt box's top rule. */ function noticeRow(frame: string): string { diff --git a/src/tui/shell.ts b/src/tui/shell.ts deleted file mode 100644 index f0833e95e..000000000 --- a/src/tui/shell.ts +++ /dev/null @@ -1,6139 +0,0 @@ -/** - * OpenTUI app shell — sticky transcript, prompt chrome, inset overlay. - * - * Functional wrappers around @opentui/core class renderables. This is the - * production interactive CLI surface (Ink is no longer the live path). - */ - -import { unlinkSync } from "node:fs"; -import { homedir } from "node:os"; -import { - clampBoardRows, - type AgentPanelRow, - type ChromeZoneContent, - type TaskPanelRow, -} from "./chrome-state.js"; - -import { - BoxRenderable, - CliRenderEvents, - MarkdownRenderable, - ScrollBoxRenderable, - SyntaxStyle, - TextRenderable, - TextTableRenderable, - StyledText, - bold as boldChunk, - fg as fgChunk, - type BaseRenderable, - type CliRenderer, - type KeyEvent, - type MouseEvent, - type Selection, - type TextChunk, -} from "@opentui/core"; - -import { isExitCommand } from "./exit-command.js"; -import { - composePromptActionBarModelLabel, - type PromptActionBarModelLabelInput, -} from "./components/prompt-action-bar-label.js"; -import { sliceTailToWidth, sliceToWidth, stringWidth } from "./view/height.js"; -import { listPathSuggestions } from "./components/at-mention/list.js"; -import { parseAtState, type AtState } from "./components/at-mention/parse.js"; -import { - findDuplicateAttachment, - readClipboardImage, - userRowText, - type ClipboardImageResult, - type PendingImageAttachment, -} from "./image-attachments.js"; -import { - createSentHistoryBrowse, - sentHistoryOnEdit, - stepSentHistoryDown, - stepSentHistoryUp, - type SentHistoryBrowse, -} from "./sent-message-history.js"; -import { spliceMentionCompletion } from "./prompt-attachments.js"; -import { - resolvePromptHighlightSpans, - resolvePromptRecognitionMatcher, - type PromptRecognitionSource, -} from "./prompt-recognition.js"; -import { - createPromptInput, - promptCaretAtFirstRow, - promptCaretAtLastRow, - promptRowCount, - type PromptInput, -} from "./prompt-input.js"; -import { promptBoxRows } from "./prompt-rows.js"; -import { composeNoticeLine, resolveWaitingOn } from "./notice-line.js"; -import { lockupCells, lockupText, lockupWidth, type LockupInput } from "./lockup.js"; -import type { RampPhase, StallAge } from "./ramp.js"; -import { RUNTIME_FLASH_MS } from "./runtime-notices.js"; -import type { ActivityState } from "./session-chrome.js"; -import { - BORDER, - composeAttentionLabel, - composeCostContextMeter, - composeRule, - composeWorkspaceLabel, - costContextText, - meterEquals, - type CostContextMeter, - type RulePart, -} from "./prompt-border.js"; -import { viewToTableContent, type McpStructuredView } from "./mcp-view.js"; -import { - canPopFocus, - createFocusState, - focusOwner, - focusPrompt, - focusTranscript, - openObserve, - openOverlay, - popFocus, - type FocusState, -} from "./focus/index.js"; -import { - FLEET_FLOOR_MIN_LANES, - FLEET_TRANSCRIPT_FLOOR, - OVERLAY_MAX_FRACTION, - PROMPT_BASE_ROWS, - PROMPT_IDLE_ROWS, - resolveBottomMarginRows, - resolveGeometry, - resolveTopPadRows, - type GeometryLayout, - type OverlayMode, - type ZoneVisibility, -} from "./geometry/index.js"; -import { - createLandingAbove, - createLandingBelow, - fitLandingMark, - LANDING_VERSION, - landingBelowContent, - landingSuggestionFor, - paintLandingBelow, - paintLandingMark, - resolveMarkGrid, - splitLandingRows, - versionBadgeVisible, - type LandingAbove, - type LandingBelowContent, -} from "./landing.js"; -import { - createListViewport, - moveActive, - page as pageList, - setCount as setListCount, - setHeight as setListHeight, - type ListViewportState, -} from "./list-viewport.js"; -import { evictedRowsNotice, trimRetainedLog } from "./long-log.js"; -import { filterPaletteCommands, paletteLabels, type PaletteCommand } from "./command-catalog.js"; -import { helpItems } from "./keybindings.js"; -import { destroySubtree } from "./teardown.js"; -import { filterMentionSuggestions, splitMentionToken } from "./mention-filter.js"; -import { splitAtSettledHeading, withholdIncompleteHeading } from "./markdown-parser.js"; -import { type ObserveSession } from "./residuals.js"; -import { - buildCopyTargets, - createRecordingClipboard, - streamLogMarkdown, - writeClipboard, - type ClipboardPort, - type CopyTarget, -} from "./copy-path.js"; -import { copyFinishedSelection } from "./selection-copy.js"; -import { - badgeCount, - cancelLast, - clearInterruptFlash, - createSessionQueue, - enqueue, - enqueueSteer, - interrupt, - queueCount, - setRunState, - steerCount, - type RunState, - type SessionQueueState, -} from "./session-queue.js"; -import { - agentVoicesIn, - blockLabel, - EXPAND_KEY, - expandedRowLines, - isCollapsibleRow, - splitTrailingArrow, - isExpansionRow, - isMarkdownRow, - isSentenceRow, - MAIN_AGENT, - paintStreamRow, - rowGroupGap, - streamRowGutter, - toolRowLines, - toolSentenceLines, - transcriptSyntaxStyle, - type PaintedStreamLine, - type RowLayout, - type StreamRow, - type StyledBodyLine, -} from "./stream.js"; -import { UI } from "./theme.js"; -import { - createOverlayView, - isDecisionOverlay, - overlayRowWidth, - overlayRowsPerItem, - overlayTitleRows, - overlayChromeRows, - overlayMinHostRows, - OVERLAY_HOST_BORDER_ROWS, -} from "./overlay-view.js"; -import { - composeDecisionBody, - decisionContextBudget, - overlayChoiceText, - overlayKindWord, - wrapOverlayText, -} from "./overlay-body.js"; -import { - beginYank, - breakKillSequence, - emptyKillRing, - killedTextBackward, - killedTextForward, - recordKill, - rotateYank, - type KillRing, -} from "./prompt-kill-ring.js"; - -const shellExitHandlers = new WeakMap void>(); - -/** - * Register the host's quit path (the same one Ctrl+C twice runs) so a bare `exit` / - * `quit` typed at the prompt tears down through finalize instead of a second, - * cleanup-skipping exit route. - */ -export function setShellExitHandler(shell: AppShell, onExit: () => void): void { - shellExitHandlers.set(shell, onExit); -} - -export function clearShellExitHandler(shell: AppShell): void { - shellExitHandlers.delete(shell); -} - -const effortCycleHandlers = new WeakMap void>(); - -/** Shift+Tab host callback: cycle reasoning effort for the live session. */ -export function setEffortCycleHandler(shell: AppShell, onCycle: () => void): void { - effortCycleHandlers.set(shell, onCycle); -} - -/** Optional Wave-4 bridge hooks (runtime-bridge attaches exclusively). */ -export interface ShellBridgeHooks { - onSubmit: ( - text: string, - kind: "queue" | "steer" | "immediate" | "reinject", - attachments?: readonly PendingImageAttachment[], - ) => void; - onInterrupt: () => void; - exclusive: boolean; -} - -const shellBridgeHooks = new WeakMap(); - -export function setShellBridgeHooks(shell: AppShell, hooks: ShellBridgeHooks): void { - shellBridgeHooks.set(shell, hooks); -} - -export function clearShellBridgeHooks(shell: AppShell): void { - shellBridgeHooks.delete(shell); -} - -export function getShellBridgeHooks(shell: AppShell): ShellBridgeHooks | undefined { - return shellBridgeHooks.get(shell); -} - -/** - * What the focused overlay row is, and what choosing it costs. Painted in the - * fixed description zone under every overlay list that opts in via `describe`. - */ -export interface ItemDescription { - /** What the focused thing is. One line. */ - readonly what: string; - /** What choosing it costs or changes. One line. Omit when there is nothing true to say. */ - readonly impact?: string; - /** "consequence" paints impact in UI.warning — billing, trust, anything that spends or extends reach. */ - - readonly tone?: "plain" | "consequence"; -} - -/** - * Payload delivered when the operator accepts an overlay list selection. - * Hosts map this into ApprovalOutcome / OperatorResult / model switch. - */ -export interface OverlaySelection { - readonly kind: PrimaryOverlayKind; - readonly index: number; - readonly label: string; - /** Stable id when the host provided `itemIds`; otherwise omitted. */ - readonly id?: string; - /** Plain chosen value when the host provided `itemValues`; otherwise omitted. */ - readonly value?: string; -} - -/** - * Shell-level overlay accept hooks. Host binds authz / ask_operator / settings. - * Kind-specific hooks win over `onSelect`. Per-open `onAccept` (on open opts) - * takes precedence for that open's lifetime. - */ -export interface ShellOverlayHooks { - readonly onPermission?: (selection: OverlaySelection) => void; - readonly onOperator?: (selection: OverlaySelection) => void; - readonly onModel?: (selection: OverlaySelection) => void; - readonly onSettings?: (selection: OverlaySelection) => void; - readonly onHelp?: (selection: OverlaySelection) => void; - readonly onPlugins?: (selection: OverlaySelection) => void; - readonly onResume?: (selection: OverlaySelection) => void; - readonly onMentions?: (selection: OverlaySelection) => void; - /** Catch-all for non-palette kinds when no kind-specific hook is set. */ - readonly onSelect?: (selection: OverlaySelection) => void; -} - -const shellOverlayHooks = new WeakMap(); - -export function setShellOverlayHooks(shell: AppShell, hooks: ShellOverlayHooks): void { - shellOverlayHooks.set(shell, hooks); -} - -export function clearShellOverlayHooks(shell: AppShell): void { - shellOverlayHooks.delete(shell); -} - -export function getShellOverlayHooks(shell: AppShell): ShellOverlayHooks | undefined { - return shellOverlayHooks.get(shell); -} - -/** - * Injectable handler for registry-backed palette selections (`dispatch: "command"`). - * Residual openers still go through `runPaletteAction`. Host binds real handlers - * (slash command run, overlay open, etc.) without the palette importing the registry. - */ -export type PaletteOnCommand = (name: string) => void; - -const shellPaletteOnCommand = new WeakMap(); - -export function setPaletteOnCommand(shell: AppShell, handler: PaletteOnCommand | undefined): void { - if (handler) shellPaletteOnCommand.set(shell, handler); - else shellPaletteOnCommand.delete(shell); -} - -export function getPaletteOnCommand(shell: AppShell): PaletteOnCommand | undefined { - return shellPaletteOnCommand.get(shell); -} - -/** - * Clipboard image reader behind Ctrl+P. Injectable so tests (and non-macOS - * hosts) can supply their own source instead of shelling out to osascript. - */ -export type PromptImageSource = () => Promise; - -const shellPromptImageSource = new WeakMap(); - -export function setPromptImageSource(shell: AppShell, source: PromptImageSource | undefined): void { - if (source) shellPromptImageSource.set(shell, source); - else shellPromptImageSource.delete(shell); -} - -/** Filesystem suggestions behind the @-mention overlay. */ -export type MentionSuggestionSource = (prefix: string) => Promise; - -const shellMentionSource = new WeakMap(); - -export function setMentionSuggestionSource( - shell: AppShell, - source: MentionSuggestionSource | undefined, -): void { - if (source) shellMentionSource.set(shell, source); - else shellMentionSource.delete(shell); -} - -/** Names the prompt is allowed to highlight as leading `/command` tokens. */ -const shellRecognitionSource = new WeakMap(); - -export function setPromptRecognitionSource( - shell: AppShell, - source: PromptRecognitionSource | undefined, -): void { - if (source) shellRecognitionSource.set(shell, source); - else shellRecognitionSource.delete(shell); -} - -/** - * Injectable handler for the palette "observe" action. Host resolves a live - * `ObserveSession` (or `null` when no subagent is running). Demo/smoke keep - * using `makeObserveFixture()` by leaving this unset. - */ -export type PaletteOnObserveRequest = () => ObserveSession | null; - -const shellPaletteOnObserveRequest = new WeakMap(); - -export function setPaletteOnObserveRequest( - shell: AppShell, - handler: PaletteOnObserveRequest | undefined, -): void { - if (handler) shellPaletteOnObserveRequest.set(shell, handler); - else shellPaletteOnObserveRequest.delete(shell); -} - -export function getPaletteOnObserveRequest(shell: AppShell): PaletteOnObserveRequest | undefined { - return shellPaletteOnObserveRequest.get(shell); -} - -/** Dispatch accept to per-open callback, then shell-level kind hooks. */ -function dispatchOverlayAccept( - shell: AppShell, - selection: OverlaySelection, - perOpen: ((selection: OverlaySelection) => void) | null, -): void { - if (perOpen) { - perOpen(selection); - return; - } - const hooks = getShellOverlayHooks(shell); - if (!hooks) return; - switch (selection.kind) { - case "permissions": - if (hooks.onPermission) { - hooks.onPermission(selection); - return; - } - break; - case "operator": - if (hooks.onOperator) { - hooks.onOperator(selection); - return; - } - break; - case "model_picker": - if (hooks.onModel) { - hooks.onModel(selection); - return; - } - break; - case "settings": - if (hooks.onSettings) { - hooks.onSettings(selection); - return; - } - break; - case "help": - if (hooks.onHelp) { - hooks.onHelp(selection); - return; - } - break; - case "plugins": - if (hooks.onPlugins) { - hooks.onPlugins(selection); - return; - } - break; - case "resume": - if (hooks.onResume) { - hooks.onResume(selection); - return; - } - break; - case "mentions": - if (hooks.onMentions) { - hooks.onMentions(selection); - return; - } - break; - default: - break; - } - hooks.onSelect?.(selection); -} - -/** Renderer surface required by the shell (CliRenderer / createTestRenderer). */ -export type ShellRenderer = Pick< - CliRenderer, - "root" | "width" | "height" | "keyInput" | "on" | "off" | "isDestroyed" | "clearSelection" ->; - -export interface AppShellOptions { - /** Session name. Default "corbits". Not painted as chrome. */ - readonly title?: string; - /** Working directory carried by the prompt box's bottom border. */ - readonly cwd?: string; - /** Zone visibility overrides for resolveGeometry. Optional strips off by default. */ - readonly visibility?: ZoneVisibility; - /** Requested prompt content rows (geometry caps at 40%). Default 3. */ - readonly promptContentRows?: number; - /** Pending queue count seed. Default 0. */ - readonly pendingQueue?: number; - /** Wire Tab + product keys (Enter/Alt+Enter/Ctrl+C/Esc/overlay). Default true. */ - readonly wireKeys?: boolean; - /** Mount shell.root on renderer.root. Default true. */ - readonly mount?: boolean; - /** Initial terminal size override (tests). Defaults to renderer.width/height. */ - readonly terminal?: { readonly columns: number; readonly rows: number }; - /** Simulated agent run state. Default "busy" (queue-default mid-run). */ - readonly run?: RunState; - /** Overlay list labels for inset demo. */ - readonly overlayItems?: readonly string[]; - /** - * Default palette catalog when `openPalette` is called without `catalog`. - * Host typically passes `buildPaletteCatalog({ commands: listCommands() })`. - * Static array or lazy builder. Defaults to residual openers only. - */ - readonly paletteCatalog?: readonly PaletteCommand[] | (() => readonly PaletteCommand[]); - /** - * Invoked when a registry-backed palette item is accepted (`dispatch: "command"`). - * Residual openers never hit this path. - */ - readonly onCommand?: PaletteOnCommand; - /** - * Invoked when the palette "observe" action runs. Returns the live - * `ObserveSession` to enter, or `null` when no subagent is running. - * Unset (demo/smoke) falls back to `makeObserveFixture()`. - */ - readonly onObserveRequest?: PaletteOnObserveRequest; - /** - * First-run telemetry disclosure for the landing screen. Omitted once the - * notice has been shown, so it is not permanent chrome. - */ - readonly telemetryNotice?: string; - /** - * Suppress landing snow and mountain motion. The idle timer is not - * armed, and `paintLanding` holds a still mountain with no flakes. - */ - readonly reducedMotion?: boolean; - /** - * Clipboard port for Alt+C and drag-select auto-copy. Defaults to an - * in-memory recorder so tests and demos never shell out; the product host - * injects the system clipboard. - */ - readonly clipboard?: ClipboardPort; - /** - * Mouse-reporting switch behind Alt+M. Absent means the shell has no - * renderer-level control (tests, demos) and reports the toggle unavailable. - * While reporting is on, OpenTUI owns drag-select and auto-copies on - * mouse-up; Alt+M hands the mouse back for native terminal selection. - */ - readonly mouseCapture?: MouseCapturePort; - /** - * How timed flashes arm their expiry. Injectable so tests can lapse a - * confirmation window without waiting out `RUNTIME_FLASH_MS`. - */ - readonly flashSchedule?: FlashSchedule; -} - -/** - * Renderer-level DEC mouse reporting control. While reporting is on the - * terminal hands drags to OpenTUI (drag-to-copy on mouse-up); Alt+M hands - * reporting back so the terminal can run its own selection again. - */ -export interface MouseCapturePort { - readonly get: () => boolean; - readonly set: (enabled: boolean) => void; -} - -export interface AppShell { - readonly renderer: ShellRenderer; - readonly root: BoxRenderable; - /** Blank rows above the first transcript row (0 on short terminals). */ - readonly topPad: BoxRenderable; - /** Blank row below the prompt box (0 on short terminals). */ - readonly bottomPad: BoxRenderable; - /** - * Build version's row, pinned to the terminal's last line and right-aligned - * (persistent chrome, not part of the landing composition — visible - * whether or not landing is showing). Hides on a narrow/short terminal, - * ahead of anything actionable (`versionBadgeVisible`). - */ - readonly versionRow: BoxRenderable; - /** - * Optional chrome zones (constitution task/agents). Distinct panels: a - * task is a unit of work with a status, an agent is an executor. - * One row per rendered task-panel line; rebuilt whenever the line count - * or any row's status changes. - */ - readonly taskBox: BoxRenderable; - /** One row per rendered agents-panel line; rebuilt whenever the line count changes. */ - readonly agentsBox: BoxRenderable; - readonly transcript: ScrollBoxRenderable; - readonly overlayView: ReturnType; - readonly overlayHost: BoxRenderable; - readonly overlayTitle: TextRenderable; - readonly overlayBody: BoxRenderable; - readonly prompt: PromptInput; - readonly promptBox: BoxRenderable; - /** The input's own row, bordered left and right only. */ - readonly promptField: BoxRenderable; - /** Top border of the prompt box — carries the model label. */ - readonly promptTopRule: TextRenderable; - /** Bottom border — carries the brand lockup and the workspace label. */ - readonly promptBottomRule: TextRenderable; - /** Transient state row above the prompt box (hidden when it has nothing to say). */ - readonly notice: TextRenderable; - /** Latest geometry resolution (updated on resize / relayout). */ - layout: GeometryLayout; - /** Focus tree + scroll lease (updated by shell helpers). */ - focus: FocusState; - /** Session queue / steer / interrupt bag. */ - session: SessionQueueState; - /** Pending queue count (mirrors badgeCount(session)). */ - pendingQueue: number; - /** Transcript line count (append counter / full log length). */ - lineCount: number; - /** - * Retained tail of the stream log — capped at MAX_RETAINED_STREAM_ROWS, so - * this is never the full session history on a long run. - */ - streamLog: StreamRow[]; - /** - * Absolute index of `streamLog[0]`. Every index the bridge holds onto - * across calls (tool-call rows, the open streaming row, the retry - * boundary) is absolute, so it stays valid once eviction has shifted the - * array itself. Bumped by the number of rows dropped on each trim. - */ - streamLogBase: number; - /** - * Distinct writers in the visible transcript. Rows carry a name and icon only - * once this holds more than one, so identity appears where it disambiguates. - */ - agentVoices: Set; - /** - * Session name. Held for hosts that rename a session; it is not chrome — - * an unnamed session shows nothing rather than a placeholder. - */ - baseTitle: string; - /** Composed `profile · model · effort` label carried by the top border. */ - modelLabel: string | null; - /** Working directory and git branch carried by the bottom border. */ - workspace: { cwd: string; branch: string | null }; - /** Overlay list viewport (null when closed). */ - overlayList: ListViewportState | null; - /** Overlay item labels currently shown. */ - overlayItems: readonly string[]; - /** Which primary overlay is open (null when closed). */ - overlayKind: PrimaryOverlayKind | null; - /** Optional long body lines painted above the list (operator question). */ - overlayBodyLines: readonly string[]; - /** Palette role per body line, aligned with overlayBodyLines. */ - overlayBodyFgs: readonly string[]; - /** Palette command ids aligned with overlayItems when kind is palette. */ - paletteCommands: readonly PaletteCommand[]; - /** Clipboard port for keyboard copy (tests inject recording port). */ - clipboard: ClipboardPort; - /** Mouse-reporting control for Alt+M, or null when the host has none. */ - mouseCapture: MouseCapturePort | null; - /** - * Frozen copy targets while the copy overlay is open (null when closed). - * Confirm writes from this snapshot, not live streamLog. - */ - copyTargets: readonly CopyTarget[] | null; - /** - * Short transient flash (copy feedback, etc.). Cleared when replaced or - * set to null; never appended to the stream log. - */ - statusFlash: string | null; - /** MCP servers awaiting authorization; the top rule carries `mcp !`. */ - mcpNeedsAuth: readonly string[]; - /** - * Plugin load left standing warnings (skill misses, failed tool starts, …). - * The top rule carries `plugin !` (or `mcp ! · plugin !` with MCP). Cleared - * only when the warning set is empty — not merely dismissed. - */ - pluginNeedsAttention: boolean; - /** - * Clock, motion and content state for the bottom-left status slot. The bridge - * pushes all of it off its existing monitor tick (`setLockupFrame`); the - * shell never reads a clock of its own, so a shell without a bridge simply - * paints the settled idle slot. - */ - lockupNowMs: number; - /** - * Parent tool currently in flight, for the steer `waiting on` notice. - * Null when no parent tools remain or the run is idle. Not TurnState. - */ - inFlightTool: { name: string; startedAt: number } | null; - lockupAnimating: boolean; - /** - * Live activity state the slot shows, or null for the idle wordmark. - * Typed to the closed set (not `string`) so a raw tool/MCP/plugin - * identifier reaching this field is a compile error, not just a test one. - */ - lockupPhase: ActivityState | null; - /** Clock reading when `lockupPhase` last changed — the fade's origin. */ - lockupChangedMs: number; - /** Density ramp phase for the same turn — drives the slot's pulse cell and tint. */ - lockupRampPhase: RampPhase | null; - /** How long the turn has been stalled, or null when it is not — bounds the blink. */ - lockupStalledForMs: StallAge; - /** - * Cost/context meter carried by the bottom border, or null when the active - * session has nothing to report (context window unknown). Pushed by the - * host whenever the run sink's usage changes — no timer of its own. - */ - costContext: CostContextMeter | null; - /** - * Active subagent observe session (null when viewing parent). - * Independent stream window; Esc restores parent lease. - */ - observe: { - sessionId: string; - agentId: string; - description: string; - lines: StreamRow[]; - } | null; - /** Parent stream snapshot while observe is active. */ - parentStreamLog: StreamRow[] | null; - /** Absolute base for `parentStreamLog`, saved/restored across observe (see `streamLogBase`). */ - parentStreamLogBase: number | null; - /** - * Readline kill ring backing Ctrl+Y/Alt+Y. Ctrl+K/U/W and Alt+D feed it; - * the text widget itself has no concept of a kill ring (see - * ./prompt-kill-ring.js). - */ - promptKillRing: KillRing; - /** Images attached with Ctrl+P, sent with the next prompt submit. */ - pendingAttachments: PendingImageAttachment[]; - /** Up/Down recall of messages already sent in this session. */ - sentHistory: SentHistoryBrowse; - /** Detach key/resize listeners and unmount root. */ - dispose: () => void; - /** - * True once `dispose` has run. Paint entry points read this: a caller that - * outlives the shell — a poll timer, a resolved async continuation — would - * otherwise write into renderables whose native buffers are already freed. - */ - disposed: boolean; -} - -export type PrimaryOverlayKind = - | "permissions" - | "operator" - | "model_picker" - | "add_provider" - | "demo" - | "palette" - | "settings" - | "help" - | "plugins" - | "resume" - | "mentions" - | "copy" - | "hooks" - | "mcp" - | "plugin_credentials"; - -// Human keystrokes land tens of milliseconds apart at the fastest; a paste -// replayed onto stdin without bracketed-paste framing lands effectively all -// at once. 15ms is an empirical guess at a gap comfortably under normal -// typing and comfortably over a replayed paste, not a measured figure -- -// too high false-positives on a very fast typist's real Enter (read as -// paste, so it inserts a newline instead of sending); too low misses a -// slow paste replay (read as typing, so a bare CR mid-paste still -// submits). Only matters before this terminal's first real paste event; -// see `sawBracketedPaste` below. -const PASTE_BURST_MS = 15; - -/** A single unmodified character, as opposed to a control chord or named key. */ -function isPrintableInsertKey(key: KeyEvent): boolean { - return ( - !key.ctrl && - !key.meta && - !key.option && - typeof key.sequence === "string" && - key.sequence.length === 1 && - key.sequence >= " " - ); -} - -const DEFAULT_TITLE = "corbits"; -const DEFAULT_OVERLAY_ITEMS = [ - "Allow bash: ls", - "Allow bash: cat README", - "Deny this tool", - "Always allow bash", -] as const; - -function terminalOf( - renderer: ShellRenderer, - override?: { readonly columns: number; readonly rows: number }, -): { columns: number; rows: number } { - if (override) { - return { - columns: Math.max(1, Math.floor(override.columns)), - rows: Math.max(1, Math.floor(override.rows)), - }; - } - return { - columns: Math.max(1, Math.floor(renderer.width || 80)), - rows: Math.max(1, Math.floor(renderer.height || 24)), - }; -} - -/** - * The version row is real chrome, not a float — it holds its own reserved - * row at the foot of the shell rather than painting into the optical bottom - * pad (`BOTTOM_MARGIN_ROWS`), which is blank breathing room, not a content - * slot. - * - * This genuinely costs the rest of the shell a row, not just the space it - * paints in: the geometry resolver is handed `terminal.rows - 1`, so every - * height it derives from that — including `PROMPT_CAP_FRACTION * - * terminal.rows`, which runs before collapse and outside `COLLAPSE_ORDER` — - * is computed one row short of the real terminal. The badge does not sit in - * the collapse order and does not give the row back under prompt-growth - * pressure; it is not "free" chrome, it is chrome the operator pays a row - * for on the landing screen, same as the task or agents panel would. - */ -function terminalForGeometry(terminal: { readonly columns: number; readonly rows: number }): { - columns: number; - rows: number; -} { - if (!versionBadgeVisible(terminal.columns, terminal.rows)) return terminal; - return { columns: terminal.columns, rows: Math.max(1, terminal.rows - 1) }; -} - -function defaultVisibility(visibility?: ZoneVisibility): ZoneVisibility { - return { - notice: false, - progress: false, - progressDivider: false, - // Explicit 0 rather than left undefined: task and agents are row - // counts, and setChromeZones compares them by ===, so an undefined - // start forces one needless relayout the first time either is compared. - task: 0, - agents: 0, - ...visibility, - }; -} - -/** Whether the transcript viewport is stuck to the bottom (FOLLOW vs PINNED). */ -export function isTranscriptFollowing(shell: AppShell): boolean { - const { transcript } = shell; - const max = Math.max(0, transcript.scrollHeight - transcript.height); - return transcript.scrollTop >= max - 1; -} - -/** Sticky-scroll mode label (surfaced on the notice row only when PINNED). */ -export function stickyMode(shell: AppShell): "FOLLOW" | "PINNED" { - return isTranscriptFollowing(shell) ? "FOLLOW" : "PINNED"; -} - -function syncPending(shell: AppShell): void { - shell.pendingQueue = badgeCount(shell.session); -} - -/** The transient row's text for the current state ("" when it has nothing to say). */ -export function noticeText(shell: AppShell): string { - return composeNoticeLine({ - steer: steerCount(shell.session), - followUp: queueCount(shell.session), - waitingOn: resolveWaitingOn(steerCount(shell.session), shell.inFlightTool, shell.lockupNowMs), - interrupt: shell.session.interruptFlash, - pinned: !isTranscriptFollowing(shell), - flash: shell.statusFlash, - attachments: shell.pendingAttachments.length, - }); -} - -/** Which MCP servers are waiting on authorization. Repaints on change. */ -export function setMcpNeedsAuth(shell: AppShell, names: readonly string[]): void { - const next = [...names]; - if ( - shell.mcpNeedsAuth.length === next.length && - next.every((name) => shell.mcpNeedsAuth.includes(name)) - ) { - return; - } - shell.mcpNeedsAuth = next; - paintChrome(shell); -} - -/** Whether plugin load warnings still need attention. Repaints on change. */ -export function setPluginNeedsAttention(shell: AppShell, needs: boolean): void { - if (shell.pluginNeedsAttention === needs) return; - shell.pluginNeedsAttention = needs; - paintChrome(shell); -} - -/** Repaint the prompt borders and the transient notice row from live state. */ -export function paintChrome(shell: AppShell): void { - if (shell.disposed) return; - // Headless tests often destroy the renderer without dispose - // (`withTestRenderer` cleanup). A TTL flash armed before that teardown - // must not write a TextBuffer the harness already freed. - if (shell.renderer.isDestroyed || shell.notice.isDestroyed) return; - syncPending(shell); - const notice = noticeText(shell); - shell.notice.content = new StyledText([ - fgChunk(UI.textDim)(notice.length > 0 ? ` ${notice}` : ""), - ]); - paintPromptBorder(shell); - syncLandingSuggestions(shell); - syncNoticeRow(shell, notice); -} - -/** - * Give the notice row a row only while it has something to say, and take it - * back the moment it does not. The relayout re-enters paintChrome, which then - * finds the visibility already correct and stops. - */ -function syncNoticeRow(shell: AppShell, notice: string): void { - paintedNotice.set(shell, notice); - const bag = internals.get(shell); - if (bag === undefined) return; - const wanted = notice.length > 0; - if ((bag.visibility.notice ?? false) === wanted) return; - relayout(shell, { visibility: { ...bag.visibility, notice: wanted } }); -} - -/** - * Re-read the notice once the layout pass has run. - * - * `pinned` is derived from the scroll box's own numbers, and those describe the - * *last completed* layout: chrome painted at row-mutation time can read a - * transcript that is following its tail as pinned, for the one frame between a - * row landing and sticky-scroll re-applying. Repaints only when the wording - * actually changed, so a settled frame costs a string compare. - */ -function syncNoticeAfterLayout(shell: AppShell): void { - if (noticeText(shell) !== paintedNotice.get(shell)) paintChrome(shell); -} - -/** Notice wording currently on the row, for the post-layout re-read. */ -const paintedNotice = new WeakMap(); - -/** Withdraw or restore the landing starters as the prompt fills and empties. */ -function syncLandingSuggestions(shell: AppShell): void { - const bag = internals.get(shell); - if (!bag) return; - const landing = bag.landing; - const content = bag.landingBelow; - if (landing === null || content === null) return; - const visible = shell.prompt.value.length === 0; - if (visible === bag.landingSuggestionsVisible) return; - bag.landingSuggestionsVisible = visible; - paintLandingBelow(landing.below, content, visible); -} - -/** - * Advance the status slot's clock and publish what it says. Callers own the - * tick; the shell only repaints when the frame it would draw can actually - * differ. - * - * A change of phase stamps the fade's origin, so the crossfade runs off the - * frames the monitor is already scheduling for the live turn. Settling snaps - * straight to the idle slot rather than fading into it: the tick stops on the - * frame the turn ends, and a transition with no frames left to draw is worse - * than none. - */ -export interface LockupFrame { - readonly nowMs: number; - readonly animating: boolean; - /** - * Live activity state, or null for the idle wordmark. Typed to the closed - * set so the caller cannot hand this a raw tool identifier. - */ - readonly phase: ActivityState | null; - /** The turn's ramp phase, or null when idle. */ - readonly rampPhase: RampPhase | null; - /** How long the turn has been stalled, or null when it is not stalled. */ - readonly stalledForMs: StallAge; -} - -export function setLockupFrame(shell: AppShell, frame: LockupFrame): void { - const settled = !frame.animating && !shell.lockupAnimating; - shell.lockupNowMs = frame.nowMs; - const phaseChanged = frame.phase !== shell.lockupPhase; - if (phaseChanged) { - shell.lockupPhase = frame.phase; - shell.lockupChangedMs = frame.nowMs; - } - const changed = - phaseChanged || - frame.rampPhase !== shell.lockupRampPhase || - frame.stalledForMs !== shell.lockupStalledForMs; - shell.lockupRampPhase = frame.rampPhase; - shell.lockupStalledForMs = frame.stalledForMs; - if (settled && !changed && shell.lockupAnimating === frame.animating) return; - shell.lockupAnimating = frame.animating; - paintChrome(shell); -} - -/** Queue an image for the next submit and reflect it on the notice row. */ -export function addPendingAttachment(shell: AppShell, attachment: PendingImageAttachment): void { - shell.pendingAttachments = [...shell.pendingAttachments, attachment]; - paintChrome(shell); -} - -export function clearPendingAttachments(shell: AppShell): void { - const pending = shell.pendingAttachments; - shell.pendingAttachments = []; - paintChrome(shell); - for (const attachment of pending) { - const ephemeral = attachment.ephemeralPath; - if (ephemeral === undefined) continue; - try { - unlinkSync(ephemeral); - } catch (err) { - if (!isENOENT(err)) throw err; - } - } -} - -function isENOENT(err: unknown): boolean { - return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT"; -} - -/** - * Ctrl+P: read an image off the clipboard into the pending set. - * Resolves false (with a status flash) when nothing was attached. - */ -export async function attachClipboardImage(shell: AppShell): Promise { - const source = shellPromptImageSource.get(shell) ?? readClipboardImage; - // Sticky until the read resolves — mid-async progress, not a confirmation. - setStatusFlash(shell, "reading clipboard image…"); - const result = await source(); - // Quitting while the clipboard read is pending tears down the shell's - // renderables; a stale continuation must not mutate them on resume. - if (shell.disposed) return false; - if (!result.ok) { - setStatusFlash(shell, `image attach failed: ${result.reason}`, { - ttlMs: RUNTIME_FLASH_MS, - }); - return false; - } - const duplicate = findDuplicateAttachment(shell.pendingAttachments, result.attachment); - if (duplicate !== undefined) { - setStatusFlash(shell, `${duplicate.name} is already attached`, { - ttlMs: RUNTIME_FLASH_MS, - }); - return false; - } - addPendingAttachment(shell, result.attachment); - setStatusFlash(shell, `attached ${result.attachment.name}`, { - ttlMs: RUNTIME_FLASH_MS, - }); - return true; -} - -/** Seed the Up/Down recall list (host replays persisted session messages). */ -export function setSentMessageHistory(shell: AppShell, sent: readonly string[]): void { - shell.sentHistory = createSentHistoryBrowse(sent); -} - -function recordSentMessage(shell: AppShell, text: string): void { - shell.sentHistory = createSentHistoryBrowse([...shell.sentHistory.sent, text]); -} - -/** - * How a timed flash arms its own expiry. Injectable so tests can lapse a - * window without waiting out its real duration; returns the cancel. - */ -export type FlashSchedule = (fn: () => void, ms: number) => () => void; - -const defaultFlashSchedule: FlashSchedule = (fn, ms) => { - const timer = setTimeout(fn, ms); - // A pending flash must never be the reason the process stays alive. - (timer as { unref?: () => void }).unref?.(); - return () => { - clearTimeout(timer); - }; -}; - -export interface FlashOptions { - /** Lifetime of the flash; omitted means it stays until something replaces it. */ - readonly ttlMs?: number; - readonly schedule?: FlashSchedule; -} - -/** Cancel for the flash currently counting down, per shell. */ -const flashTimers = new WeakMap void>(); - -/** Per-shell override for how timed flashes arm their expiry (tests). */ -const shellFlashSchedules = new WeakMap(); - -/** - * Set a non-destructive flash and repaint (does not touch streamLog). - * - * A flash with a `ttlMs` clears itself when its window lapses. Anything whose - * wording is only true for a moment ("press ctrl+c again to exit") must say so - * for exactly that moment: left on screen it becomes a claim about a keypress - * the operator never made, and it holds a transcript row hostage for it. - * Omit `ttlMs` for live conditions that stay true until something replaces them - * (stall notice, landing hold). - */ -export function setStatusFlash( - shell: AppShell, - message: string | null, - options?: FlashOptions, -): void { - flashTimers.get(shell)?.(); - flashTimers.delete(shell); - shell.statusFlash = message; - paintChrome(shell); - const ttlMs = options?.ttlMs; - if (message === null || ttlMs === undefined || ttlMs <= 0) return; - if (shell.disposed || shell.renderer.isDestroyed) return; - const schedule = options?.schedule ?? shellFlashSchedules.get(shell) ?? defaultFlashSchedule; - flashTimers.set( - shell, - schedule(() => { - flashTimers.delete(shell); - // Only this flash expires: a later one has its own window, and the row - // it is holding is not this one's to take back. - if (shell.statusFlash !== message) return; - shell.statusFlash = null; - paintChrome(shell); - }, ttlMs), - ); -} - -/** Apply focus state to OpenTUI focusables. */ -export function applyFocus(shell: AppShell): void { - const owner = focusOwner(shell.focus); - // Observe is a read-only child view: the parent prompt must not swallow the - // keystrokes, so it is blurred exactly as an overlay blurs it. - if (owner === "overlay" || owner === "palette" || owner === "observe") { - if (typeof shell.prompt.blur === "function") { - shell.prompt.blur(); - } - } else if (owner === "transcript") { - shell.transcript.focus(); - } else { - shell.prompt.focus(); - } - paintChrome(shell); -} - -export function shellFocusPrompt(shell: AppShell): void { - shell.focus = focusPrompt(shell.focus); - applyFocus(shell); -} - -export function shellFocusTranscript(shell: AppShell): void { - shell.focus = focusTranscript(shell.focus); - applyFocus(shell); -} - -export function toggleShellFocus(shell: AppShell): void { - const owner = focusOwner(shell.focus); - if (owner === "overlay" || owner === "palette") return; - if (owner === "transcript") { - shellFocusPrompt(shell); - } else { - shellFocusTranscript(shell); - } -} - -/** - * Free-text answer field an overlay can offer alongside (or instead of) its - * choices. `active` is whether keystrokes are going into it rather than into - * list navigation — the row is painted either way, so the affordance is on - * screen rather than behind a chord nobody knows about. - */ -interface OverlayAnswerState { - text: string; - active: boolean; - readonly onSubmit: (text: string) => void; -} - -function overlayAnswerState(shell: AppShell): OverlayAnswerState | null { - return internals.get(shell)?.overlayAnswer ?? null; -} - -/** - * Stacking order for the floated overlay host. Only the landing composition - * sits under it, and that has no z-index of its own, so one step is enough. - */ -const OVERLAY_FLOAT_Z = 10; - -/** - * Lift the overlay host out of the root's column, or drop it back in. - * - * On the landing the host is a modal: the mark and the disclosure are the - * screen, and shoving them around to open a command list would make every - * overlay feel like a navigation. Absolute positioning takes the host out of - * flow so the composition beneath is untouched, anchored above the chrome the - * host used to sit on top of. With a transcript on screen the opposite is - * true — rows there are content the operator is reading, and covering them is - * worse than pushing them — so the host goes back into the column. - */ -function floatOverlayHost(shell: AppShell, floating: boolean, top: number): void { - const host = shell.overlayHost; - if (!floating) { - host.position = "relative"; - host.zIndex = 0; - // A previous landing float left absolute insets behind. Under relative - // positioning those same values act as offsets from the in-flow slot, so - // a stale top pushes the band that many rows below the prompt — clear - // them so the band sits where the flow put it. - host.top = 0; - host.left = 0; - host.width = "100%"; - return; - } - host.position = "absolute"; - // Absolute positioning escapes root's padding, so the same sideMargin the - // prompt box gets for free in normal flow has to be given back explicitly. - // width is set to the same contentWidth the prompt box resolves to via - // "100%" of root's padded box — one source, not a second computed here — - // rather than left+right insets, since those combine with the existing - // width:"100%" to overshoot the right edge. - host.left = shell.layout.sideMargin; - host.width = shell.layout.contentWidth; - host.top = top; - host.zIndex = OVERLAY_FLOAT_Z; -} - -/** - * Recompute the overlay host's row budget from the current item count and - * relayout into it. Callers that refresh an already-open overlay's items in - * place (rather than reopening) must call this themselves — a filter that - * narrows a list and then widens it again would otherwise stay pinned at - * whatever size it first opened at. - */ -function relayoutOverlayHost(shell: AppShell, itemCount: number): void { - const perItem = overlayRowsPerItem( - shell.overlayKind, - shell.overlayItems, - shell.layout.contentWidth, - ); - const chrome = overlayChromeRows( - shell.overlayKind, - shell.overlayBodyLines.length, - !!internals.get(shell)?.primaryBindings.describe, - overlayAnswerState(shell) !== null, - ); - const hostRows = chrome + itemCount * perItem; - const minHostRows = overlayMinHostRows(chrome, perItem, itemCount > 0); - relayout(shell, { - overlayMode: "inset", - overlayBodyRows: hostRows, - overlayMinBodyRows: minHostRows, - }); -} - -function refreshOverlayTitle(shell: AppShell): void { - const bag = internals.get(shell); - if (!bag) return; - shell.overlayView.paintTitle( - { - title: bag.overlayTitleText, - kind: shell.overlayKind, - hasChoices: shell.overlayItems.length > 0, - answer: overlayAnswerState(shell), - addProviderHint: bag.primaryBindings.addProviderHint, - setDefaultHint: bag.primaryBindings.setDefaultHint, - mcpManageHint: bag.primaryBindings.mcpManageHint, - mcpAddHint: bag.primaryBindings.mcpAddHint, - }, - shell.layout.contentWidth, - ); -} - -/** Stable id for the focused row: `itemIds[index]` when supplied, else its label. */ -function activeOverlayItemId(shell: AppShell, list: ListViewportState): string { - const bag = internals.get(shell); - return ( - bag?.primaryBindings.itemIds[list.activeIndex] ?? - shell.overlayItems[list.activeIndex] ?? - String(list.activeIndex) - ); -} - -function paintOverlayList(shell: AppShell): void { - const list = shell.overlayList; - shell.overlayView.paintList( - { - kind: shell.overlayKind, - items: shell.overlayItems, - paletteCommands: shell.paletteCommands, - viewport: list, - bodyLines: shell.overlayBodyLines, - bodyFgs: shell.overlayBodyFgs, - answer: overlayAnswerState(shell), - describe: () => { - const describe = internals.get(shell)?.primaryBindings.describe; - return describe && list ? describe(activeOverlayItemId(shell, list)) : undefined; - }, - }, - shell.layout.contentWidth, - ); -} - -/** - * Colour a composed rule. The frame stays faint so the labels it carries read - * as the brighter thing on the row; the brand run is swapped for the lockup's - * own cells, which is the only part of the border that animates. - */ -function ruleChunks(shell: AppShell, parts: readonly RulePart[]): TextChunk[] { - const chunks: TextChunk[] = []; - for (const part of parts) { - if (part.role === "brand") { - const cells = lockupCells(lockupFrameInput(shell)); - chunks.push(fgChunk(UI.textFaint)(" ")); - for (const cell of cells) chunks.push(fgChunk(cell.fg)(cell.char)); - chunks.push(fgChunk(UI.textFaint)(" ")); - continue; - } - if (part.role === "meter") { - chunks.push(...meterChunks(shell, part.text)); - continue; - } - if (part.role === "attention") { - chunks.push(fgChunk(UI.warning)(part.text)); - continue; - } - chunks.push(fgChunk(part.role === "label" ? UI.textDim : UI.textFaint)(part.text)); - } - return chunks; -} - -/** - * Color a meter cell: the percent takes the band color (quiet `textDim`, - * warning sand, danger red) and the optional cost suffix stays dim chrome. - */ -function meterChunks(shell: AppShell, cell: string): TextChunk[] { - const meter = shell.costContext; - const percentFg = - meter?.band === "danger" ? UI.error : meter?.band === "warning" ? UI.warning : UI.textDim; - if (meter === null) return [fgChunk(percentFg)(cell)]; - const percent = meter.percentLabel; - const idx = cell.indexOf(percent); - if (idx === -1) return [fgChunk(percentFg)(cell)]; - const before = cell.slice(0, idx); - const after = cell.slice(idx + percent.length); - const chunks: TextChunk[] = []; - if (before.length > 0) chunks.push(fgChunk(UI.textFaint)(before)); - chunks.push(fgChunk(percentFg)(percent)); - if (after.length > 0) chunks.push(fgChunk(UI.textDim)(after)); - return chunks; -} - -/** The status slot's state, as the lockup renderer wants it. */ -function lockupFrameInput(shell: AppShell): LockupInput { - return { - nowMs: shell.lockupNowMs, - still: !shell.lockupAnimating, - phase: shell.lockupPhase, - changedMs: shell.lockupChangedMs, - rampPhase: shell.lockupRampPhase, - stalledForMs: shell.lockupStalledForMs, - }; -} - -/** - * Repaint both border rules. Recomposed on every pass rather than cached: a - * resize changes the column budget without changing any label, and the lockup - * changes every animation frame without changing the geometry. - */ -export function paintPromptBorder(shell: AppShell): void { - const width = shell.layout.contentWidth; - const attention = composeAttentionLabel({ - mcp: shell.mcpNeedsAuth.length > 0, - plugin: shell.pluginNeedsAttention, - }); - const top = composeRule({ - width, - corners: [BORDER.topLeft, BORDER.topRight], - ...(attention !== undefined ? { attention } : {}), - ...(shell.modelLabel !== null ? { label: shell.modelLabel } : {}), - }); - shell.promptTopRule.content = new StyledText(ruleChunks(shell, top)); - - // Corners, both rule margins, the gap and the spaces around each label are - // what the workspace has to fit inside — with the lockup if the rule can - // seat both, without it if it cannot. Where the row can only afford one, the - // information wins and the mark goes. - const withBrand = Math.max(0, width - 9 - lockupWidth(lockupFrameInput(shell))); - const alone = Math.max(0, width - 6); - const workspaceInput = { - cwd: shell.workspace.cwd, - branch: shell.workspace.branch, - home: homedir(), - }; - // A workspace that has lost its path is a branch floating with no context, - // which is worth less than the mark it displaced. So the mark yields not just - // when the label cannot fit at all, but when keeping it would starve the path. - const roomyRaw = composeWorkspaceLabel({ ...workspaceInput, maxWidth: withBrand }); - const roomy = roomyRaw.startsWith("(") ? "" : roomyRaw; - const workspace = - roomy.length > 0 ? roomy : composeWorkspaceLabel({ ...workspaceInput, maxWidth: alone }); - const brand = lockupText(lockupCells(lockupFrameInput(shell))); - const meter = shell.costContext; - const bottom = composeRule({ - width, - corners: [BORDER.bottomLeft, BORDER.bottomRight], - ...(roomy.length > 0 || workspace.length === 0 ? { brand } : {}), - ...(meter !== null - ? { meter: costContextText(meter, true), meterCompact: costContextText(meter, false) } - : {}), - ...(workspace.length > 0 ? { label: workspace } : {}), - }); - shell.promptBottomRule.content = new StyledText(ruleChunks(shell, bottom)); -} - -/** Publish the `profile · model · effort` label carried by the top border. */ -export function setPromptModelLabel(shell: AppShell, input: PromptActionBarModelLabelInput): void { - const label = composePromptActionBarModelLabel(input) ?? null; - if (label === shell.modelLabel) return; - shell.modelLabel = label; - paintPromptBorder(shell); -} - -/** Publish the working directory and git branch carried by the bottom border. */ -export function setPromptWorkspace( - shell: AppShell, - input: { readonly cwd?: string; readonly branch?: string | null }, -): void { - const cwd = input.cwd ?? shell.workspace.cwd; - const branch = input.branch === undefined ? shell.workspace.branch : input.branch; - if (cwd === shell.workspace.cwd && branch === shell.workspace.branch) return; - shell.workspace = { cwd, branch }; - paintPromptBorder(shell); -} - -/** - * Publish the cost/context meter carried by the bottom border. Driven by - * usage changes (a completed turn), not a timer: the percentage does not move - * between turns, so there is nothing to animate on the idle tick. - */ -export function setPromptCostContext( - shell: AppShell, - input: { - readonly contextPercentUsed: number | null; - readonly costLabel?: string | null; - readonly contextIsEstimate: boolean; - }, -): void { - const meter = composeCostContextMeter(input); - if (meterEquals(meter, shell.costContext)) return; - shell.costContext = meter; - paintPromptBorder(shell); -} - -/** - * How the landing divides its rows around the prompt box. - * - * A floated overlay is clipped to the rows above the box so it never covers the - * thing the operator types into. Losing the tail of a long body to that clip is - * survivable; losing every choice is not, because then the surface cannot be - * answered. So the box slides down just far enough to keep the overlay's full, - * already fraction-capped height on screen, and the starters below it pay for - * the move. - */ -function landingSplitFor( - landingRows: number, - minOverlayRows: number, - padRows: number, -): { readonly above: number; readonly below: number } { - const even = splitLandingRows(landingRows); - const needed = Math.min(landingRows, minOverlayRows - padRows); - if (minOverlayRows <= 0 || even.above >= needed) return even; - return { above: needed, below: Math.max(0, landingRows - needed) }; -} - -export function applyLayout(shell: AppShell, layout: GeometryLayout): void { - // Rows lay themselves out against the column budget (right-aligned bubbles, - // pre-wrapped reasoning blocks), so a width change invalidates every painted - // row rather than just reflowing it. - const widthChanged = - shell.layout.contentWidth !== layout.contentWidth || - shell.layout.chatWidth !== layout.chatWidth || - shell.layout.layoutMode !== layout.layoutMode; - shell.layout = layout; - const h = layout.heights; - - shell.root.paddingLeft = layout.sideMargin; - shell.root.paddingRight = layout.sideMargin; - - // Raw renderer size, not `layout.terminal` — that is already net of the row - // this badge itself reserves (see `terminalForGeometry`), which would make - // the threshold check its own effect. Landing-only: see `relayout`. - shell.versionRow.visible = - isLanding(shell) && versionBadgeVisible(shell.renderer.width, shell.renderer.height); - - const taskH = Math.max(0, h.task); - shell.taskBox.height = taskH > 0 ? taskH : 1; - shell.taskBox.visible = taskH > 0; - - const agentsH = Math.max(0, h.agents); - shell.agentsBox.visible = agentsH > 0; - - // Both pads are taken out of the transcript residual, never out of chrome, - // so the resolver's row budget still sums to the terminal height. - const transcriptH = Math.max(0, h.transcript); - const padH = resolveTopPadRows(transcriptH); - shell.topPad.height = padH > 0 ? padH : 1; - shell.topPad.visible = padH > 0; - - const bottomPadH = resolveBottomMarginRows(layout.terminal.rows); - shell.bottomPad.height = bottomPadH > 0 ? bottomPadH : 1; - shell.bottomPad.visible = bottomPadH > 0; - - const overlayH = Math.max(0, h.overlay_host); - - // The landing splits the transcript residual around the prompt box so the box - // sits on the terminal's middle row instead of at its foot. An open overlay - // floats over that composition rather than displacing it, so the rows the - // resolver took for the overlay host are handed back to the split. - const bag = internals.get(shell); - const landing = bag?.landing ?? null; - const landingRows = transcriptH - padH - bottomPadH + (landing === null ? 0 : overlayH); - // The resolver already sized overlayH to the overlay's real content (list - // included) and capped it against the fraction/floor limits, so it is the - // correct minimum to ask the landing split to make room for — asking for - // less (e.g. just enough for one choice row) starves the list underneath - // the title down to nearly nothing once floatOverlayHost pins the host to it. - const split = landing === null ? null : landingSplitFor(landingRows, overlayH, padH); - if (bag !== undefined && landing !== null && split !== null) { - landing.above.box.height = Math.max(1, split.above); - // A new zone can seat a different tier, and a tier is a different grid, so - // the mark is redrawn rather than left showing the previous size's frame. - fitLandingMark(landing.above, resolveMarkGrid(split.above, layout.contentWidth)); - paintLandingMark(landing.above, bag.landingNowMs, !bag.landingAnimating, bag.reducedMotion); - landing.below.height = Math.max(0, split.below); - landing.below.visible = split.below > 0; - } - - const transcriptBody = - split === null ? transcriptH - padH - bottomPadH : Math.max(1, split.above); - shell.transcript.height = transcriptBody > 0 ? transcriptBody : 1; - shell.transcript.visible = transcriptBody > 0; - syncTranscriptSpacer(shell); - - // Agents strip: full-width flex stack under the transcript when present. - // Live chrome keeps the zone empty (spawn_agent transcript rows instead). - shell.agentsBox.position = "relative"; - shell.agentsBox.left = 0; - shell.agentsBox.top = 0; - shell.agentsBox.width = "100%"; - shell.agentsBox.height = agentsH > 0 ? agentsH : 1; - shell.agentsBox.zIndex = 0; - shell.transcript.width = "100%"; - - const noticeH = Math.max(0, h.notice); - shell.notice.height = noticeH > 0 ? noticeH : 1; - shell.notice.visible = noticeH > 0; - - const promptH = Math.max(1, h.prompt); - shell.promptBox.height = promptH; - shell.promptBox.visible = promptH > 0; - // The field takes whatever the box has left once both labelled rules are paid. - const promptInnerH = Math.max(1, promptH - 2); - shell.promptField.height = promptInnerH; - // Sized explicitly rather than left to grow with its content: past the cap the - // input has to scroll inside a fixed window instead of pushing the frame open. - shell.prompt.height = promptInnerH; - - // Sized last: the float is anchored against chrome sized earlier in this - // pass. Modal over the landing, an in-flow band once there is a transcript - // to push. - const floating = landing !== null && overlayH > 0; - // Rows the flow spends before the prompt box — where a floated host's bottom - // edge has to land, since the landing's box sits mid-screen rather than at - // the foot and covering it would hide the thing the operator types into. - // Stack: topPad, transcript, agents, task, then prompt (notice omitted — - // same as before; it is transient chrome between task and prompt). - const promptTop = padH + transcriptBody + agentsH + taskH; - const hostH = floating ? Math.min(overlayH, Math.max(1, promptTop)) : overlayH; - floatOverlayHost(shell, floating, Math.max(0, promptTop - hostH)); - shell.overlayHost.height = hostH > 0 ? hostH : 1; - shell.overlayHost.visible = hostH > 0; - if (hostH > 0 && shell.overlayList) { - const chrome = overlayChromeRows( - shell.overlayKind, - shell.overlayBodyLines.length, - !!bag?.primaryBindings.describe, - overlayAnswerState(shell) !== null, - ); - const bodyH = Math.max(1, hostH - chrome); - // The viewport counts items, not rows; a decision overlay spends several - // rows per item, so the row budget has to be divided back down. - const perItem = overlayRowsPerItem( - shell.overlayKind, - shell.overlayItems, - shell.layout.contentWidth, - ); - shell.overlayList = setListHeight(shell.overlayList, Math.max(1, Math.floor(bodyH / perItem))); - paintOverlayList(shell); - } - - paintPromptBorder(shell); - - // The landing owns the transcript's children until the first row lands, so a - // resize there must not rebuild them out from under it. - if (widthChanged && shell.streamLog.length > 0 && !isLanding(shell)) { - repaintTranscriptWindow(shell); - } - - // Width change changes the column budget chrome rows fit to. Content may - // be unchanged, so setChromeZones would skip the rebuild — do it here. - if (widthChanged && bag !== undefined) { - if (bag.chrome.task.length > 0) { - renderTasksRows(shell, bag.chrome.task, layout.contentWidth); - } - if (bag.chrome.agents.length > 0) { - renderAgentsRows(shell, clampBoardRows(bag.chrome.agents, agentsH), layout.contentWidth); - } - } - - paintChrome(shell); -} - -/** - * Re-size the prompt box for what is now in it. Cheap enough to run on every - * content change: it re-resolves geometry only when the row count actually - * moves, which is once per wrapped line gained or lost. - */ -export function syncPromptRows(shell: AppShell): void { - const rows = promptBoxRows(promptRowCount(shell.prompt), shell.renderer.height); - if (rows === shell.layout.heights.prompt) return; - relayout(shell, { promptContentRows: rows }); -} - -let cachedPromptSyntaxStyle: SyntaxStyle | null = null; -let cachedPromptRecognizedStyleId: number | null = null; - -/** - * The style registry backing the prompt's highlights, plus the one style id - * this feature uses. Lazy for the same reason as `transcriptSyntaxStyle`: - * construction reaches into the native render lib. - */ -function promptRecognizedStyleId(): number { - if (cachedPromptSyntaxStyle === null) { - cachedPromptSyntaxStyle = SyntaxStyle.fromStyles({ - recognized: { fg: UI.action }, - }); - } - if (cachedPromptRecognizedStyleId === null) { - cachedPromptRecognizedStyleId = cachedPromptSyntaxStyle.resolveStyleId("recognized") ?? 0; - } - return cachedPromptRecognizedStyleId; -} - -const promptHighlightedValue = new WeakMap(); - -/** - * Re-mark leading slash commands and @mentions in the prompt. Runs once per frame - * (see `onFrame` in `createShell`), and only does anything when the prompt's - * text actually changed since the last frame — typing that doesn't touch a - * token, and every non-typing frame, is a no-op string comparison. - */ -export function syncPromptHighlights(shell: AppShell): void { - const source = shellRecognitionSource.get(shell); - if (source === undefined) return; - const value = shell.prompt.value; - if (promptHighlightedValue.get(shell) === value) return; - promptHighlightedValue.set(shell, value); - - const styleId = promptRecognizedStyleId(); - shell.prompt.syntaxStyle = cachedPromptSyntaxStyle; - shell.prompt.clearAllHighlights(); - const matcher = resolvePromptRecognitionMatcher(source); - for (const span of resolvePromptHighlightSpans(value, matcher)) { - shell.prompt.addHighlightByCharRange({ start: span.start, end: span.end, styleId }); - } -} - -export interface RelayoutOpts { - readonly columns?: number; - readonly rows?: number; - readonly visibility?: ZoneVisibility; - readonly promptContentRows?: number; - readonly overlayMode?: OverlayMode; - readonly overlayBodyRows?: number; - /** - * Rows the open overlay cannot render without: border + title + at least - * one content row. Below this, the box paints past whatever height it was - * assigned instead of shrinking, so the resolver must never starve it here. - */ - readonly overlayMinBodyRows?: number; -} - -interface PrimaryOverlayBindings { - /** Optional stable ids aligned with overlayItems for the open primary. */ - itemIds: readonly string[]; - /** Optional plain chosen values aligned with overlayItems for the open primary. */ - itemValues: readonly (string | undefined)[]; - /** Per-open accept callback; cleared on close without invoke (Esc path). */ - onAccept: ((selection: OverlaySelection) => void) | null; - /** Per-open expand/collapse hook for the open primary overlay. */ - onToggleExpand: (() => void) | null; - /** Per-open ← → cycle hook for the open primary overlay (settings inline cycling). */ - onCycle: ((itemId: string, direction: -1 | 1) => void) | null; - /** Per-open description-zone source; null keeps the zone off (no rows charged). */ - describe: ((itemId: string) => ItemDescription | null) | null; - /** Per-open bare-key claim for the open primary overlay. */ - onAction: ((itemId: string, key: KeyEvent) => boolean) | null; - /** Per-open bracketed-paste owner for synthetic text panes. */ - onPaste: ((text: string) => void) | null; - /** - * Per-open dismiss hook for promise-backed overlays (permissions, operator). - * Esc/closeInsetOverlay invokes this instead of silently dropping the - * pending promise the way palette/mentions/copy overlays correctly do. - */ - onCancel: (() => void) | null; - /** - * Per-open cleanup for a replaced or dismissed overlay (MCP unsubscribe). - * closeReplaceableOverlay still runs this; it skips onCancel so - * Esc-only navigation (add-provider back to models) does not fire. - */ - onDispose: (() => void) | null; - /** True while the open primary is a decision gate that must not be replaced. */ - isGate: boolean; - /** Whether the open primary advertises Alt+A and yields å/Å from type-to-filter. */ - addProviderHint: boolean; - /** Whether the open primary advertises Alt+D in the footer hints. */ - setDefaultHint: boolean; - /** Whether the open `/mcp` list advertises Alt+D / Alt+R. */ - mcpManageHint: boolean; - /** Whether the open `/mcp` list advertises Alt+A add. */ - mcpAddHint: boolean; -} - -const EMPTY_PRIMARY_BINDINGS: Readonly = { - itemIds: [], - itemValues: [], - onAccept: null, - onToggleExpand: null, - onCycle: null, - describe: null, - onAction: null, - onPaste: null, - onCancel: null, - onDispose: null, - isGate: false, - addProviderHint: false, - setDefaultHint: false, - mcpManageHint: false, - mcpAddHint: false, -}; - -interface PriorOverlaySnapshot { - readonly kind: PrimaryOverlayKind | null; - readonly items: readonly string[]; - readonly bodyLines: readonly string[]; - readonly bodyFgs: readonly string[]; - readonly list: ListViewportState; - readonly title: string; - readonly paletteCommands: readonly PaletteCommand[]; - readonly primaryBindings: Readonly; - readonly answer: OverlayAnswerState | null; - readonly titleText: string; -} - -interface ShellInternals { - visibility: ZoneVisibility; - promptContentRows: number | undefined; - overlayMode: OverlayMode; - overlayBodyRows: number | undefined; - overlayMinBodyRows: number | undefined; - /** - * Raw (unwrapped) text last passed to `applyOverlayBodyText`, kept so a - * resize can re-shape a decision overlay's body against the new height's - * context budget instead of leaving it fixed at whatever it opened with. - */ - overlayRawBodyText: string; - /** Snapshot when palette stacks over another primary overlay. */ - priorOverlay: PriorOverlaySnapshot | null; - /** Advances on a new overlay taking the host, and when the host empties. */ - overlayGeneration: number; - primaryBindings: PrimaryOverlayBindings; - /** False while an overlay that reports its own outcome is open. */ - overlayEchoChoice: boolean; - /** - * While true the shell ignores its own key/paste/submit handlers. Set for - * the lifetime of a full-screen surface (inline provider connect) that - * shares this renderer — two live key handlers on one stdin would both - * act on every keystroke. - */ - inputSuspended: boolean; - /** Per-open free-text answer field, when the overlay opted into one. */ - overlayAnswer: OverlayAnswerState | null; - /** Bare title of the open overlay, so its key hints can be re-composed. */ - overlayTitleText: string; - /** Fired once the overlay host is idle, so queued gates can re-open. */ - overlayClosedListeners: Set<() => void>; - /** - * Command-surface open while a live overlay still holds the host. One slot; - * a newer command replaces an older one. Flushed only after that overlay - * has actually closed and the host is idle — never from idle-notify, which - * would let wireGates drain a queued gate onto the same host. - */ - deferredCommandOverlay: OpenListOverlayOpts | null; - /** True while a microtask to flush deferredCommandOverlay is queued. */ - deferredFlushScheduled: boolean; - /** - * Host-owned holds that outlive overlayList being null (async /settings - * list(), etc.). While > 0, idle-notify must not fire so a queued gate - * cannot drain into the gap before the surface paints. - */ - overlayHostReservations: number; - /** - * Advanced when Esc aborts in-flight reservations so a stale `release()` - * cannot decrement a newer hold. - */ - overlayReservationEpoch: number; - /** - * Registry-backed `/` command catalog (static or lazy), host-injected. Empty - * when unset. - */ - paletteCatalog: readonly PaletteCommand[] | (() => readonly PaletteCommand[]) | null; - /** Live filter state for the open palette, so typing can re-filter it. */ - paletteFilter: PaletteFilterState | null; - /** Live type-to-filter state for a non-palette list overlay (model picker). */ - listFilter: ListFilterState | null; - /** - * Landing composition shown while the transcript has no content: the mark - * above the prompt box, the disclosure and starters below it. Dropped (not - * hidden) on the first row so it never occupies a transcript line later. - */ - landing: { readonly above: LandingAbove; readonly below: BoxRenderable } | null; - /** - * The disclosure the landing is showing. Re-appended to the transcript when - * the landing tears down so consent-by-proceeding leaves a durable record - * rather than a screen the first prompt wipes. - */ - landingNotice: string | null; - /** - * System/runtime notices that arrived while the landing was still up (MCP - * load failures, width-contract warnings, hook failures). Held here and - * painted on the notice strip so they never call `clearLandingMark`; flushed - * into the transcript when the first real session row ends the landing. - */ - landingDeferredRows: StreamRow[]; - /** What the rows below the box are painting, so they can be repainted. */ - landingBelow: LandingBelowContent | null; - /** Starters are offered only while the prompt is empty. */ - landingSuggestionsVisible: boolean; - /** Whether the last painted mark frame was a moving one. */ - landingAnimating: boolean; - /** Clock of the last painted mark frame, so a resize can redraw in place. */ - landingNowMs: number; - /** - * Mount-time reduced-motion flag. When true, the idle snow timer is - * never armed and every landing paint holds a still mountain with no - * flakes. Set once at `createAppShell`; not a per-paint argument. - */ - reducedMotion: boolean; - /** - * Cancels the mount-scoped idle repaint timer armed in `createAppShell`, - * or null while none is armed. Cleared by whichever teardown happens - * first — the landing going away (`clearLandingMark`) or the whole shell - * disposing (`dispose`) — so it can never outlive either. - */ - landingIdleTimerCancel: (() => void) | null; - /** Chrome content (empty array = zone off). */ - chrome: { - /** - * Rendered task rows — empty when there is nothing to show OR the panel - * is hidden by the operator toggle. `tasksRaw` holds the live data - * independent of that toggle, so un-hiding shows the current list - * without waiting on the next manage_tasks write. - */ - task: readonly TaskPanelRow[]; - /** Last live task rows pushed via setChromeZones, regardless of hidden state. */ - tasksRaw: readonly TaskPanelRow[]; - /** Agents panel rows (empty array = zone off), one row per rendered line. */ - agents: readonly AgentPanelRow[]; - }; - /** Operator toggle for the task panel; in-memory, held for the life of the shell. */ - tasksPanelHidden: boolean; -} - -const internals = new WeakMap(); - -/** - * Leading filler row inside the transcript's scroll content. Bottom-anchors a - * short transcript against the prompt box below: sized to the leftover - * viewport space so few rows sit at the foot of the zone instead of stranded - * at its top. Once rows fill the viewport the filler settles at zero and - * sticky-scroll behaves exactly as it did before this existed. - * - * A real child rather than padding: the content box's `minHeight: "100%"` - * (`@opentui/core`'s own default, so it never reads shorter than the - * viewport) means padding cannot be measured back out of `scrollHeight` — - * it always reads as the viewport height regardless of how little real - * content there is. A child's own height is unaffected by that floor, so - * `scrollHeight - spacer.height` reliably isolates the rows' real height. - * - * This does cost every row-index code path (`getChildren()`-based lookups - * below, and the two external tests noted at their call sites) one constant - * offset: index 0 is always the spacer, never a row. - */ -const transcriptSpacers = new WeakMap(); - -/** - * Resize the transcript's leading filler to soak up leftover viewport space. - * Reads `scrollHeight` (content height, filler included) net of the filler's - * own last-applied height, so it stays correct regardless of wrapping, - * markdown, or windowed long-log rebuilds. - * - * Deliberately NOT called at row-mutation time: `scrollHeight` reflects the - * last completed layout, not the tree as it stands the instant a row lands — - * a row whose own box needs a layout pass to size itself (structured/tool/ - * collapsible rows) reads back as shorter than it really is for one frame. - * Growing the filler on that stale reading would claim room the row still - * needs and bury it. Called from the render-frame hook instead, once that - * pass has actually run. - */ -function syncTranscriptSpacer(shell: AppShell): void { - const spacer = transcriptSpacers.get(shell); - if (spacer === undefined) return; - // The landing screen already bottom-anchors its own mark against the box - // via the above/below split; a filler competing for the same content box - // would double-count that space and squeeze the mark. - if (isLanding(shell)) { - if (spacer.height !== 0) spacer.height = 0; - return; - } - const rowsHeight = Math.max(0, shell.transcript.scrollHeight - spacer.height); - const nextHeight = Math.max(0, shell.transcript.height - rowsHeight); - if (spacer.height !== nextHeight) spacer.height = nextHeight; -} - -export function relayout(shell: AppShell, opts?: RelayoutOpts): GeometryLayout { - const bag = internals.get(shell); - const visibility = opts?.visibility ?? bag?.visibility ?? defaultVisibility(); - const promptContentRows = opts?.promptContentRows ?? bag?.promptContentRows; - const overlayMode = opts?.overlayMode ?? bag?.overlayMode ?? "closed"; - const overlayBodyRows = opts?.overlayBodyRows ?? bag?.overlayBodyRows; - const overlayMinBodyRows = opts?.overlayMinBodyRows ?? bag?.overlayMinBodyRows; - if (bag) { - bag.visibility = visibility; - bag.promptContentRows = promptContentRows; - bag.overlayMode = overlayMode; - bag.overlayBodyRows = overlayBodyRows; - bag.overlayMinBodyRows = overlayMinBodyRows; - } - - const columns = opts?.columns ?? shell.renderer.width; - const rows = opts?.rows ?? shell.renderer.height; - const terminal = terminalOf(shell.renderer, { columns, rows }); - // Only the landing screen ever gives up a row for the version badge — once - // a session has real transcript content every row is that content's, and - // the badge simply stops showing (see `applyLayout`) rather than taking - // space back from it. - const versionReserved = isLanding(shell); - const layout = resolveGeometry({ - terminal: versionReserved ? terminalForGeometry(terminal) : terminal, - visibility, - overlay: - overlayMode === "closed" - ? { mode: "closed" } - : { - mode: overlayMode, - ...(overlayBodyRows !== undefined ? { bodyRows: overlayBodyRows } : {}), - ...(overlayMinBodyRows !== undefined ? { minBodyRows: overlayMinBodyRows } : {}), - }, - ...(promptContentRows !== undefined ? { promptContentRows } : {}), - // The landing owns the screen until the first transcript row lands, so - // holding rows back for a transcript that does not exist would only clip - // whatever the operator opened over it. An open overlay is the exception: - // it asks for exactly as many rows as it has content, and without the floor - // a long list would claim the whole screen instead of scrolling. - ...(isLanding(shell) && overlayMode === "closed" - ? { transcriptFloor: 0 } - : fleetTranscriptFloor(shell)), - }); - applyLayout(shell, layout); - return layout; -} - -/** - * Rows the transcript holds back once a fleet is running. - * - * With several lanes live the operator is managing a fleet rather than reading - * a conversation, so the transcript gives up its idle floor to the board. It - * keeps enough to stay a live tail — the orchestrator reporting back and asking - * questions is still the main way the operator learns anything. - */ -function fleetTranscriptFloor(shell: AppShell): { transcriptFloor?: number } { - const bag = internals.get(shell); - if (!bag) return {}; - const lanes = bag.chrome.agents.filter((row) => row.kind === "lane").length; - return lanes >= FLEET_FLOOR_MIN_LANES ? { transcriptFloor: FLEET_TRANSCRIPT_FLOOR } : {}; -} - -/** - * Append a raw line to the sticky transcript ScrollBox. - * stickyScroll + stickyStart "bottom" auto-follow until the operator scrolls up. - */ -export function appendTranscript( - shell: AppShell, - line: string, - opts?: { readonly fg?: string }, -): void { - clearLandingMark(shell); - shell.lineCount += 1; - shell.transcript.add( - new TextRenderable(shell.renderer as CliRenderer, { - content: ` ${line}`, - fg: opts?.fg ?? UI.text, - }), - ); - paintChrome(shell); -} - -/** - * Append a role-styled stream row to the **parent** transcript. - * While subagent observe is active, rows go to the parent snapshot only - * (not painted); leave restores them with the parent lease. - */ -export function appendStreamRow(shell: AppShell, row: StreamRow): void { - if (shell.observe !== null && shell.parentStreamLog !== null) { - shell.parentStreamLog.push(row); - shell.parentStreamLogBase = trimRetainedLog( - shell.parentStreamLog, - shell.parentStreamLogBase ?? 0, - ); - return; - } - paintAppendStreamRow(shell, row); -} - -/** - * Append a child stream row while observing a subagent. - * Host-pushed live events (not only fixture seed lines). No-op when not observing. - * @returns true when the row was applied to the observe view - */ -export function appendObserveStreamRow(shell: AppShell, row: StreamRow): boolean { - if (shell.observe === null) return false; - shell.observe.lines.push(row); - paintAppendStreamRow(shell, row); - return true; -} - -/** - * Surface every row is laid out against: the transcript's own column budget - * (rows right-align and wrap themselves) and whether writers need naming. - * The scroll bars are hidden, so the transcript owns the whole content zone. - */ -export function transcriptRowLayout(shell: AppShell): RowLayout { - return { - width: Math.max(1, shell.layout.contentWidth), - multiAgent: shell.agentVoices.size > 1, - }; -} - -/** - * Record a row's writer. Returns true when the transcript has just gained a - * second voice — every earlier row now needs the label it was painted without. - */ -function noteAgentVoice(shell: AppShell, row: StreamRow): boolean { - if (row.role === "user") return false; - const before = shell.agentVoices.size; - shell.agentVoices.add(row.agent ?? MAIN_AGENT); - return before === 1 && shell.agentVoices.size === 2; -} - -/** Row immediately before `index` in the log, or undefined at the start. */ -function rowBefore(shell: AppShell, index: number): StreamRow | undefined { - return index > 0 ? shell.streamLog[index - 1] : undefined; -} - -/** Blank rows the row at `index` claims above itself. */ -function gapBefore(shell: AppShell, index: number): number { - const row = shell.streamLog[index]; - if (row === undefined) return 0; - return rowGroupGap(rowBefore(shell, index), row); -} - -/** - * Writer label the row at `index` carries above it, or null mid-block. - * A block is exactly a gap-free run from one writer, so this tracks - * `gapBefore` rather than keeping its own notion of block boundaries. - */ -function labelBefore(shell: AppShell, index: number): string | null { - const row = shell.streamLog[index]; - if (row === undefined) return null; - return blockLabel(rowBefore(shell, index), row, transcriptRowLayout(shell)); -} - -/** - * Surface a runtime/load notice without stealing the landing hero. - * - * MCP connection failures, hook failures and similar startup chatter used to - * call `appendStreamRow` → `clearLandingMark`, wiping the mountain the moment - * anything went wrong on load (CL-5618 / CL-5600). While the landing is still - * mounted the wording rides the notice strip and the row is held for flush - * once a real session row ends the landing; after that it is a normal system - * row. - * - * Every producer of a system-class row belongs here rather than at - * `appendStreamRow`. CL-5618 fixed the MCP and hook producers one at a time - * and the plugin producer kept the defect, which is what per-call-site rules - * buy you. Reaching for `appendStreamRow` directly is the bug. - */ -/** - * Suspend or resume the shell's own key/paste/submit handling. A full-screen - * surface that borrows this renderer (the inline provider connect) owns the - * keyboard for its lifetime; without this, Ctrl+C during a sign-in would - * also reach the shell and interrupt the running agent. - */ -export function setShellInputSuspended(shell: AppShell, suspended: boolean): void { - const bag = internals.get(shell); - if (bag !== undefined) bag.inputSuspended = suspended; -} - -export function surfaceSystemNotice(shell: AppShell, text: string): void { - if (isLanding(shell)) { - const bag = internals.get(shell); - if (bag !== undefined) { - bag.landingDeferredRows.push({ role: "system", text }); - } - setStatusFlash(shell, text); - return; - } - appendStreamRow(shell, { role: "system", text }); -} - -/** - * Paint + push onto the visible streamLog (child while observing, parent - * otherwise). The paint tree stays 1:1 with the (retention-capped) log — - * CL-5551 already bounds `streamLog` to `MAX_RETAINED_STREAM_ROWS`, so there - * is no separate, smaller window to maintain on top of it: every retained - * row gets a node, which is also what makes all of it reachable by - * scrolling (CL-5553). A trim past the cap costs one node removal here, not - * a rebuild. - */ -function paintAppendStreamRow(shell: AppShell, row: StreamRow): void { - clearLandingMark(shell); - const gainedVoice = noteAgentVoice(shell, row); - shell.streamLog.push(row); - const baseBefore = shell.streamLogBase; - shell.streamLogBase = trimRetainedLog(shell.streamLog, shell.streamLogBase); - shell.lineCount = shell.streamLog.length; - - if (gainedVoice) { - repaintTranscriptWindow(shell); - paintChrome(shell); - return; - } - - const dropped = shell.streamLogBase - baseBefore; - if (dropped > 0) { - for (const evicted of transcriptRowChildren(shell).slice(0, dropped)) { - shell.transcript.remove(evicted); - destroySubtree(evicted); - } - const marker = transcriptMarker(shell); - if (marker instanceof TextRenderable) { - marker.content = evictedRowsNotice(shell.streamLogBase); - } else { - const node = new TextRenderable(shell.renderer as CliRenderer, { - content: evictedRowsNotice(shell.streamLogBase), - fg: UI.textDim, - }); - evictionMarkers.add(node); - shell.transcript.add(node, 1); - } - } - - const index = shell.streamLog.length - 1; - shell.transcript.add( - createStreamRowRenderable( - shell, - row, - gapBefore(shell, index), - labelBefore(shell, index), - shell.streamLogBase + index, - ), - ); - paintChrome(shell); -} - -/** Row count of the log `appendStreamRow` currently targets (parent or observe). */ -export function streamRowCount(shell: AppShell): number { - return shell.observe !== null && shell.parentStreamLog !== null - ? shell.parentStreamLog.length - : shell.streamLogBase + shell.streamLog.length; -} - -/** - * Row at absolute `index` on the log `appendStreamRow` currently targets. A - * tool result rewrites the call row it answers rather than appending its - * own, and needs to read that row back to fold into it. - * - * `index` is absolute (see `streamLogBase`); a row already evicted by the - * retention cap reads back as undefined, same as one past the end. - */ -export function streamRowAt(shell: AppShell, index: number): StreamRow | undefined { - if (shell.observe !== null && shell.parentStreamLog !== null) { - const local = index - (shell.parentStreamLogBase ?? 0); - return local >= 0 && local < shell.parentStreamLog.length - ? shell.parentStreamLog[local] - : undefined; - } - const local = index - shell.streamLogBase; - return local >= 0 && local < shell.streamLog.length ? shell.streamLog[local] : undefined; -} - -/** - * Drop every row from absolute `length` onward on the log `appendStreamRow` - * targets. - * - * A committed inference attempt that fails is re-streamed from scratch, so the - * transcript has to retract what the failed attempt already painted instead of - * letting the replay pile up underneath it. A boundary the retention cap has - * already evicted has nothing left to retract, so this is a no-op rather than - * mis-truncating the tail that replaced it. - */ -export function truncateStreamRows(shell: AppShell, length: number): void { - const observing = shell.observe !== null && shell.parentStreamLog !== null; - const log = observing ? shell.parentStreamLog! : shell.streamLog; - const base = observing ? (shell.parentStreamLogBase ?? 0) : shell.streamLogBase; - const local = length - base; - if (local < 0 || local >= log.length) return; - log.length = local; - if (log !== shell.streamLog) return; - shell.lineCount = shell.streamLog.length; - repaintTranscriptWindow(shell); - paintChrome(shell); -} - -/** - * Empty the visible transcript for a fresh session (/clear, /new). - * - * Backend session rotation lives in the runner; this is only the on-screen wipe - * the OpenTUI host must own after the Ink App path went away. Observe mode is - * dropped first so a child view cannot keep painting into a cleared parent. - * Retention base resets so the screen matches a brand-new session, not a window - * over an empty retained log with a stale eviction marker. - */ -export function clearTranscript(shell: AppShell): void { - if (shell.observe !== null) { - // Drop observe without the "left observe" system row — the whole log is - // about to go and a farewell row would only flash then vanish. - shell.observe = null; - shell.parentStreamLog = null; - shell.parentStreamLogBase = null; - let guard = 4; - while (guard-- > 0 && focusOwner(shell.focus) === "observe") { - shell.focus = popFocus(shell.focus); - } - const frames = shell.focus.frames.filter((f) => f.target !== "observe"); - if (frames.length !== shell.focus.frames.length) { - shell.focus = { frames }; - } - setChromeZones(shell, { agents: null }); - applyFocus(shell); - } - shell.streamLog.length = 0; - shell.streamLogBase = 0; - shell.lineCount = 0; - shell.parentStreamLog = null; - shell.parentStreamLogBase = null; - repaintTranscriptWindow(shell); - paintChrome(shell); -} - -/** - * Identifies a transcript child as the eviction notice rather than a row. - * Identity, not position or state, is the source of truth: `streamLogBase` - * flips to nonzero the instant a trim happens, one step before the notice - * node itself exists in the paint tree, so deriving "is there a marker" - * from state would misalign row indices for exactly that transitional call. - */ -const evictionMarkers = new WeakSet(); - -/** - * Row-index code paths (below, and the two windowed-rebuild callers) treat - * `getChildren()` as a 1:1 array with `streamLog`. The leading bottom-anchor - * spacer (see `transcriptSpacers`) and, once retention has evicted anything, - * the eviction notice above the oldest retained row both break that — every - * consumer that needs the row-only view goes through here rather than the - * raw call. - */ -function transcriptRowChildren(shell: AppShell): readonly BaseRenderable[] { - const children = shell.transcript.getChildren().slice(1); - return children.length > 0 && evictionMarkers.has(children[0]!) ? children.slice(1) : children; -} - -/** The eviction-notice node, if the retention cap has dropped anything. */ -function transcriptMarker(shell: AppShell): BaseRenderable | undefined { - const children = shell.transcript.getChildren().slice(1); - return children.length > 0 && evictionMarkers.has(children[0]!) ? children[0] : undefined; -} - -/** Raw child-list offset before the first row: the spacer, plus the notice if present. */ -function transcriptRowOffset(shell: AppShell): number { - return transcriptMarker(shell) === undefined ? 1 : 2; -} - -/** - * Rewrite an already-appended transcript row in place. - * - * Streaming assistant and thinking bodies grow token by token; the bridge keeps - * one open row and replaces it on every delta rather than appending a row per - * token. Repaints only the affected node while the log fits without windowing. - * - * `index` is absolute (see `streamLogBase`); a row the retention cap has - * already evicted is a no-op rather than corrupting an unrelated row at the - * same array slot. - */ -export function replaceStreamRowAt(shell: AppShell, index: number, row: StreamRow): void { - if (shell.observe !== null && shell.parentStreamLog !== null) { - const parentLocal = index - (shell.parentStreamLogBase ?? 0); - if (parentLocal >= 0 && parentLocal < shell.parentStreamLog.length) { - shell.parentStreamLog[parentLocal] = row; - } - return; - } - const local = index - shell.streamLogBase; - if (local < 0 || local >= shell.streamLog.length) return; - shell.streamLog[local] = row; - - const children = transcriptRowChildren(shell); - // A raw appendTranscript line breaks the 1:1 node↔row mapping; fall back to - // a full repaint, which derives every node from the log. - if (children.length !== shell.streamLog.length) { - repaintTranscriptWindow(shell); - paintChrome(shell); - return; - } - - const stale = children[local]; - if (stale && retextStreamRow(shell, stale, row, labelBefore(shell, local))) { - paintChrome(shell); - return; - } - if (stale) { - shell.transcript.remove(stale); - destroySubtree(stale); - } - // Raw child list is spacer (+ eviction notice, if any) then rows; see - // `transcriptRowOffset` (see `transcriptRowChildren` for why row 0 is not - // simply index 1). - shell.transcript.add( - createStreamRowRenderable( - shell, - row, - gapBefore(shell, local), - labelBefore(shell, local), - index, - ), - local + transcriptRowOffset(shell), - ); - paintChrome(shell); -} - -/** - * Rewrite a row's body on its existing paint node. - * - * Streaming rows are replaced on every token, and tearing the node down each - * time would drop the markdown parser's block state — the very thing that makes - * incremental rendering stable. Returns false when the node shape does not - * match the row and the caller must rebuild it — including a row whose block - * label just appeared or disappeared, since that changes the node's shape. - */ -function retextStreamRow( - shell: AppShell, - node: BaseRenderable, - row: StreamRow, - label: string | null, -): boolean { - if (row.diff !== undefined || row.structured !== undefined) return false; - // A sentence-style tool row paints via styled lines (verb + coloured - // subject, and a diff/detail tail once expanded) rather than a single-fg - // TextRenderable, so it always rebuilds like diff/structured rows do. - if (isSentenceRow(row)) return false; - // Expanding swaps a text row for a styled-lines box: a different node shape - // and a different height, so the caller must rebuild rather than re-text. - if (isExpansionRow(row)) return false; - const layout = transcriptRowLayout(shell); - - if (label !== null) { - if (!(node instanceof BoxRenderable)) return false; - const [headerNode, innerNode] = node.getChildren(); - if (!(headerNode instanceof TextRenderable) || innerNode === undefined) return false; - if (!retextStreamRowBody(innerNode, row, layout)) return false; - headerNode.content = label; - return true; - } - - return retextStreamRowBody(node, row, layout); -} - -/** The shape-matching rewrite shared by labelled and unlabelled rows. */ -function retextStreamRowBody(node: BaseRenderable, row: StreamRow, layout: RowLayout): boolean { - if (node instanceof TextRenderable) { - if (isMarkdownRow(row)) return false; - node.content = paintStreamRow(row, layout).content; - return true; - } - - if (!(node instanceof BoxRenderable) || !isMarkdownRow(row)) return false; - const [gutterNode, bodyNode] = node.getChildren(); - if (!(gutterNode instanceof TextRenderable)) return false; - const gutter = streamRowGutter(row, layout); - gutterNode.content = gutter.content; - gutterNode.width = stringWidth(gutter.content); - const width = markdownBodyColumns(gutter, layout); - const content = markdownContent(row); - const split = splitAtSettledHeading(content); - - // No settled heading behind the tail: a lone renderer, same as an unsplit - // body. A shape change (a heading just closed, or one just left the window - // a full rebuild trimmed) falls through to the caller's rebuild. - if (split === null) { - if (!(bodyNode instanceof MarkdownRenderable)) return false; - bodyNode.width = width; - bodyNode.content = content; - bodyNode.streaming = row.streaming === true; - return true; - } - - if (!(bodyNode instanceof BoxRenderable)) return false; - const [frozenNode, liveNode] = bodyNode.getChildren(); - if (!(frozenNode instanceof MarkdownRenderable) || !(liveNode instanceof MarkdownRenderable)) { - return false; - } - bodyNode.width = width; - frozenNode.width = width; - frozenNode.content = split.frozen; - liveNode.width = width; - liveNode.content = split.live; - liveNode.streaming = row.streaming === true; - liveNode.marginTop = split.gapRows; - return true; -} - -/** - * Rebuild the transcript paint tree from `streamLog` — every retained row, - * not a smaller window of it. `streamLog` is already capped at - * `MAX_RETAINED_STREAM_ROWS`, so this is O(cap), and painting all of it is - * what makes the full retained history reachable by scrolling. - */ -export function repaintTranscriptWindow(shell: AppShell): void { - clearLandingMark(shell); - shell.agentVoices = new Set(agentVoicesIn(shell.streamLog)); - // The bottom-anchor spacer (index 0) stays; the eviction notice (if any) - // and every row get torn down and rebuilt from the log. - for (const child of shell.transcript.getChildren().slice(1)) { - shell.transcript.remove(child); - destroySubtree(child); - } - - // Rows evicted by the retention cap are gone for good, not just scrolled - // past — say so, or the boundary reads as the true start of history. - if (shell.streamLogBase > 0) { - const marker = new TextRenderable(shell.renderer as CliRenderer, { - content: evictedRowsNotice(shell.streamLogBase), - fg: UI.textDim, - }); - evictionMarkers.add(marker); - shell.transcript.add(marker); - } - - shell.streamLog.forEach((row, local) => { - shell.transcript.add( - createStreamRowRenderable( - shell, - row, - gapBefore(shell, local), - labelBefore(shell, local), - shell.streamLogBase + local, - ), - ); - }); -} - -/** - * Tear the landing down on the first transcript row. - * - * The prompt box travels from the middle of the screen to the bottom, which is - * a jump; it happens on the same frame as the operator's own first row so it - * reads as the screen answering them rather than as the layout twitching. - * - * System/runtime notices deferred while the hero was up are flushed into the - * transcript here so they stay durable once the session has content, without - * ever having stolen the mountain on the way in. - */ -function clearLandingMark(shell: AppShell): void { - const bag = internals.get(shell); - const landing = bag?.landing; - if (bag === undefined || landing === null || landing === undefined) return; - bag.landing = null; - bag.landingIdleTimerCancel?.(); - bag.landingIdleTimerCancel = null; - shell.transcript.remove(landing.above.box); - destroySubtree(landing.above.box); - shell.root.remove(landing.below); - destroySubtree(landing.below); - relayout(shell); - - const notice = bag.landingNotice; - if (notice !== null) { - bag.landingNotice = null; - appendStreamRow(shell, { role: "system", text: notice }); - } - - const deferred = bag.landingDeferredRows; - if (deferred.length > 0) { - bag.landingDeferredRows = []; - // The notice strip held the latest wording while the mark was up; the - // rows themselves are durable now, so drop the flash rather than double-paint. - setStatusFlash(shell, null); - for (const row of deferred) appendStreamRow(shell, row); - } -} - -/** - * Cadence of the mount-scoped idle repaint timer armed in `createAppShell`. - * The snow only needs to advance about half a row per second, so 8fps is - * comfortably enough to read as motion. - */ -export const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; - -/** - * Repaint the landing mark for `nowMs`. `animating` runs the mountain's - * draw/fill/fade timeline; anything else holds its filled frame. No-op once - * the landing is gone, so the caller can drive it unconditionally. - * - * Always repaints while the landing is up, even when `animating` is false: - * the landing is idle by definition (no turn processing), and snow still - * needs to drift across a frozen mountain. Driven by the mount-scoped timer - * armed in `createAppShell` rather than a render event, so the repaint - * cadence is independent of however often the renderer happens to paint. - * - * Reduced motion is a mount-time flag on the shell, not a per-paint - * argument: it freezes the mountain and drops snow even when a caller - * asks for `animating`. - */ -export function paintLanding(shell: AppShell, nowMs: number, animating: boolean): void { - const bag = internals.get(shell); - const landing = bag?.landing; - if (bag === undefined || landing === null || landing === undefined) return; - const motion = bag.reducedMotion ? false : animating; - bag.landingAnimating = motion; - bag.landingNowMs = nowMs; - paintLandingMark(landing.above, nowMs, !motion, bag.reducedMotion); -} - -/** True while the landing composition is still mounted. */ -export function isLanding(shell: AppShell): boolean { - return (internals.get(shell)?.landing ?? null) !== null; -} - -/** - * Fill the prompt from a landing starter. Returns false when the key selects - * nothing, the landing is gone, or the operator has already typed. - */ -export function applyLandingSuggestion(shell: AppShell, key: string): boolean { - if (!isLanding(shell) || shell.prompt.value.length > 0) return false; - const suggestion = landingSuggestionFor(key); - if (suggestion === null) return false; - shell.prompt.value = suggestion.prompt; - return true; -} - -/** - * Prefix column beside a body the renderer owns. Width is pinned to the painted - * columns so an empty gutter — a lone agent's own prose — costs none, and the - * answer starts on the transcript's first column. - */ -function gutterNode(ctx: CliRenderer, gutter: PaintedStreamLine): TextRenderable { - return new TextRenderable(ctx, { - content: gutter.content, - fg: gutter.fg, - flexShrink: 0, - width: stringWidth(gutter.content), - }); -} - -/** - * Columns a markdown body may paint into: the transcript budget less the - * row's own prefix. Pinned rather than left to `flexGrow`, which reports the - * body's intrinsic width to yoga and lets a wide table paint past the edge. - */ -function markdownBodyColumns(gutter: PaintedStreamLine, layout: RowLayout): number { - return Math.max(1, layout.width - stringWidth(gutter.content)); -} - -/** - * Markdown tables shrink to the row's column budget rather than overflowing: - * columns are fitted proportionally and cells wrap on word boundaries. A table - * still too wide for its narrowest fit is clipped by the body's pinned width, - * which keeps it inside the transcript instead of painting over the chrome. - */ -const TRANSCRIPT_TABLE_OPTIONS = { - wrapMode: "word", - columnFitter: "proportional", -} as const; - -function markdownContent(row: StreamRow): string { - if (row.streaming !== true) return row.text; - return withholdIncompleteHeading(row.text); -} - -/** - * Build the row-shaped paint node: a MarkdownRenderable body next to a plain - * gutter for markdown-bearing rows (assistant replies), a TextTableRenderable - * for structured rows (MCP results), a coloured diff body for edit-tool rows, - * and literal text for everything else. - */ -function buildRowNode( - ctx: CliRenderer, - row: StreamRow, - layout: RowLayout, - onToggle?: () => void, -): TextRenderable | BoxRenderable { - if (isSentenceRow(row)) { - // The sentence is one line: it is cut to the columns beside the marker - // rather than wrapped, so a long URL or query cannot double the row. - const columns = Math.max(1, layout.width - stringWidth(streamRowGutter(row, layout).content)); - if (row.structured !== undefined) { - // The table is what the sentence hides; collapsed, the sentence is the row. - return row.expanded === true - ? createStructuredRowRenderable( - ctx, - row, - layout, - row.structured, - toolSentenceLines(row, columns), - onToggle, - ) - : createStyledLinesRowRenderable( - ctx, - row, - layout, - toolSentenceLines(row, columns), - onToggle, - ); - } - return createStyledLinesRowRenderable(ctx, row, layout, toolRowLines(row, columns), onToggle); - } - - if (row.diff !== undefined) { - return createStyledLinesRowRenderable(ctx, row, layout, row.diff.lines); - } - - const expanded = expandedRowLines(row, layout); - if (expanded !== null) { - return createStyledLinesRowRenderable(ctx, row, layout, expanded); - } - - if (row.structured !== undefined) { - return createStructuredRowRenderable(ctx, row, layout, row.structured); - } - - if (!isMarkdownRow(row)) { - const painted = paintStreamRow(row, layout); - return new TextRenderable(ctx, { content: painted.content, fg: painted.fg }); - } - - const gutter = streamRowGutter(row, layout); - const wrapper = new BoxRenderable(ctx, { flexDirection: "row", width: "100%" }); - wrapper.add(gutterNode(ctx, gutter)); - wrapper.add(createMarkdownBody(ctx, row, gutter, layout)); - return wrapper; -} - -/** Shared construction options for a transcript markdown body's renderer. */ -function markdownBodyOptions(gutter: PaintedStreamLine, width: number) { - return { - syntaxStyle: transcriptSyntaxStyle(), - fg: gutter.fg, - width, - flexShrink: 0, - tableOptions: TRANSCRIPT_TABLE_OPTIONS, - } as const; -} - -/** - * A markdown row's body. Most rows have no settled heading yet (no heading at - * all, or the only one is still the open tail), and paint through a single - * renderer, same as before this fix existed. Once a heading closes, the body - * becomes a settled `frozen` renderer — everything through that heading, - * never streaming, never handed new content while the tail keeps growing, so - * it is never asked to re-highlight once written — stacked above the still - * `live` one, which carries the row's own streaming flag. Both halves use the - * library's default block mode, so paragraphs, lists and tables inside either - * one lay out exactly as a single unsplit body would. - */ -function createMarkdownBody( - ctx: CliRenderer, - row: StreamRow, - gutter: PaintedStreamLine, - layout: RowLayout, -): MarkdownRenderable | BoxRenderable { - const width = markdownBodyColumns(gutter, layout); - const content = markdownContent(row); - const split = splitAtSettledHeading(content); - if (split === null) { - return new MarkdownRenderable(ctx, { - ...markdownBodyOptions(gutter, width), - content, - // Native incremental block stability: only the trailing block is unstable. - streaming: row.streaming === true, - }); - } - const column = new BoxRenderable(ctx, { flexDirection: "column", width }); - column.add( - new MarkdownRenderable(ctx, { - ...markdownBodyOptions(gutter, width), - content: split.frozen, - streaming: false, - }), - ); - column.add( - new MarkdownRenderable(ctx, { - ...markdownBodyOptions(gutter, width), - content: split.live, - streaming: row.streaming === true, - marginTop: split.gapRows, - }), - ); - return column; -} - -/** - * Build the paint node for one transcript row, including its writer label - * when this row opens a new block (see `blockLabel`). The label is one text - * child stacked above the row's own node in a column wrapper — never a - * separate transcript child — so the 1:1 log-index-to-child mapping holds. - */ -export function createStreamRowRenderable( - shell: AppShell, - row: StreamRow, - marginTop = 0, - label: string | null = null, - index?: number, -): TextRenderable | BoxRenderable { - const ctx = shell.renderer as CliRenderer; - const layout = transcriptRowLayout(shell); - // `index` is absolute (see `streamLogBase`), so it stays the row's index - // for as long as its node lives even if the retention cap trims the array - // out from underneath it later. `toggleRowExpandedAt` converts it back to - // a local array position at click time, not here. - const onToggle = - index === undefined || !isCollapsibleRow(row) - ? undefined - : () => { - toggleRowExpandedAt(shell, index); - }; - const node = buildRowNode(ctx, row, layout, onToggle); - - if (label === null) { - node.marginTop = marginTop; - return node; - } - - const wrapper = new BoxRenderable(ctx, { flexDirection: "column", width: "100%", marginTop }); - wrapper.add(new TextRenderable(ctx, { content: label, fg: UI.textDim })); - wrapper.add(node); - return wrapper; -} - -/** Map one styled body line's segments to native text chunks. */ -function diffLineChunks(line: StyledBodyLine): TextChunk[] { - return line.map((segment) => { - const chunk = fgChunk(segment.fg)(segment.text); - return segment.bold === true ? boldChunk(chunk) : chunk; - }); -} - -/** - * Gutter + one text line per body row, for bodies that arrive already coloured - * and already laid out (a diff, an expanded tool call's structured arguments). - * Each line paints inside the body column, so a wrapped line lands under the - * body rather than in the shell's gutter. - */ -function createStyledLinesRowRenderable( - ctx: CliRenderer, - row: StreamRow, - layout: RowLayout, - lines: readonly StyledBodyLine[], - onToggle?: () => void, -): BoxRenderable { - const gutter = streamRowGutter(row, layout); - const wrapper = new BoxRenderable(ctx, { - flexDirection: "row", - width: "100%", - }); - wrapper.add(gutterNode(ctx, gutter)); - const body = new BoxRenderable(ctx, { - flexDirection: "column", - flexGrow: 1, - }); - for (const line of lines) { - body.add(bodyLineNode(ctx, line, onToggle)); - } - wrapper.add(body); - return wrapper; -} - -/** - * One painted body line. A line ending in an expand arrow is split so the - * arrow is its own renderable and can answer a click; every other line is a - * single text node, as before. - */ -function bodyLineNode( - ctx: CliRenderer, - line: StyledBodyLine, - onToggle?: () => void, -): TextRenderable | BoxRenderable { - const split = onToggle === undefined ? null : splitTrailingArrow(line); - if (split === null || onToggle === undefined) { - return new TextRenderable(ctx, { content: new StyledText(diffLineChunks(line)) }); - } - const wrapper = new BoxRenderable(ctx, { flexDirection: "row", flexGrow: 1 }); - wrapper.add( - new TextRenderable(ctx, { - content: new StyledText(diffLineChunks(split.body)), - flexShrink: 0, - }), - ); - wrapper.add( - new TextRenderable(ctx, { - content: new StyledText(diffLineChunks([split.arrow])), - flexShrink: 0, - width: stringWidth(split.arrow.text), - onMouseDown: (event) => { - // The transcript scroll box drags on the same press; a toggle is not a - // scroll gesture, so the arrow keeps the event. - event.stopPropagation(); - onToggle(); - }, - }), - ); - return wrapper; -} - -/** - * Gutter + native table body for a structured (MCP result) row, under the head - * lines the row collapses to. The head and the table share one body column so - * the table stays inside the shell's gutter. - */ -function createStructuredRowRenderable( - ctx: CliRenderer, - row: StreamRow, - layout: RowLayout, - view: McpStructuredView, - head: readonly StyledBodyLine[] = [], - onToggle?: () => void, -): BoxRenderable { - const gutter = streamRowGutter(row, layout); - const wrapper = new BoxRenderable(ctx, { - flexDirection: "row", - width: "100%", - }); - wrapper.add(gutterNode(ctx, gutter)); - const body = new BoxRenderable(ctx, { flexDirection: "column", flexGrow: 1 }); - for (const line of head) { - body.add(bodyLineNode(ctx, line, onToggle)); - } - body.add( - new TextTableRenderable(ctx, { - content: viewToTableContent(view), - columnWidthMode: "content", - columnGap: 2, - showBorders: false, - wrapMode: "none", - flexGrow: 1, - }), - ); - wrapper.add(body); - return wrapper; -} - -export function setHeader(shell: AppShell, text: string): void { - shell.baseTitle = text; - paintChrome(shell); -} - -export function setPendingQueue(shell: AppShell, count: number): void { - let s = shell.session; - const target = Math.max(0, Math.floor(count)); - while (badgeCount(s) > target) { - s = { ...s, items: s.items.slice(0, -1) }; - } - while (badgeCount(s) < target) { - s = enqueue(s, `pad-${badgeCount(s) + 1}`); - } - shell.session = s; - paintChrome(shell); -} - -export function setShellRunState(shell: AppShell, run: RunState): void { - shell.session = setRunState(shell.session, run); - paintChrome(shell); -} - -/** - * Submit the prompt. Product chords (CL-6290): - * - "steer": mid-run Enter — soft steer at the next tool.boundary. - * - "queue": mid-run Alt+Enter — follow-up; deliver only when the run goes - * idle. Idle Alt+Enter is a no-op at the key handler (never reaches here - * with kind "queue" while idle from the product chord). - * - "reinject": hard-stop and restart from this message. No product chord - * wires this anymore; kept for tests / direct API callers. No-op when the - * run isn't busy, or the prompt is empty. - * - Idle Enter (either queue or steer kind) goes straight through; "kind" - * only matters while a run is in flight. - */ -export function submitPrompt( - shell: AppShell, - kind: "queue" | "steer" | "reinject" = "queue", -): void { - const text = shell.prompt.value; - const t = text.trim(); - const attachments = shell.pendingAttachments; - if (t.length === 0 && attachments.length === 0) { - // Empty Enter still reaches the exclusive host so multi-turn /feedback - // can cancel; non-exclusive shells have nothing to do with a blank line. - const hooks = getShellBridgeHooks(shell); - if (hooks?.exclusive) { - hooks.onSubmit(text, "immediate", attachments); - } - return; - } - // Reinject is unwired from product chords; still guard idle for API callers. - if (kind === "reinject" && shell.session.run !== "busy") return; - // Follow-up idle no-op lives on the Alt+Enter key handler (kind "queue" is - // also the default for submitPrompt and must still send when idle). - - // Shell/REPL muscle memory: a bare `exit` or `quit` quits rather than being - // sent to the model. Attachments mean the operator meant it as a message. - if (attachments.length === 0 && isExitCommand(t)) { - const onExit = shellExitHandlers.get(shell); - if (onExit !== undefined) { - shell.prompt.value = ""; - onExit(); - return; - } - } - - if (t.length > 0) recordSentMessage(shell, t); - const hooks = getShellBridgeHooks(shell); - if (hooks?.exclusive) { - shell.prompt.value = ""; - clearPendingAttachments(shell); - const resolved: "queue" | "steer" | "immediate" | "reinject" = - kind === "reinject" ? "reinject" : shell.session.run === "idle" ? "immediate" : kind; - hooks.onSubmit(text, resolved, attachments); - return; - } - - if (kind === "reinject") { - // Unwired from product chords (CL-6290); kept for tests / direct callers. - shell.session = interrupt(shell.session); - shell.prompt.value = ""; - clearPendingAttachments(shell); - appendStreamRow(shell, { - role: "system", - text: "stop — restarting from your message", - meta: "stop", - }); - appendStreamRow(shell, { - role: "user", - text: userRowText(t, attachments), - meta: "reinject", - }); - paintChrome(shell); - return; - } - - if (shell.session.run === "idle") { - appendStreamRow(shell, { role: "user", text: t }); - shell.prompt.value = ""; - clearPendingAttachments(shell); - return; - } - - shell.session = - kind === "steer" - ? enqueueSteer(shell.session, t, undefined, attachments) - : enqueue(shell.session, t, "queue", undefined, attachments); - const queued = shell.session.items[shell.session.items.length - 1]; - shell.prompt.value = ""; - clearPendingAttachments(shell); - // Show the message itself, not the internal transition ("queue +1 → - // pending N") — the notice row already carries the depth once, in plain - // language, so this row's job is making the pending item identifiable. - appendStreamRow(shell, { - role: "user", - text: userRowText(t, attachments), - meta: kind === "steer" ? "steer" : "queue", - ...(queued !== undefined ? { queueItemId: queued.id } : {}), - }); - paintChrome(shell); -} - -/** - * Find the transcript row a still-pending queue/steer item echoed, so a - * cancel can retract it instead of leaving a message tagged "queue" that will - * never dispatch. Absolute index, matching `replaceStreamRowAt`. - */ -function findQueueRowIndex(shell: AppShell, queueItemId: string): number | undefined { - for (let local = shell.streamLog.length - 1; local >= 0; local--) { - if (shell.streamLog[local]?.queueItemId === queueItemId) { - return shell.streamLogBase + local; - } - } - return undefined; -} - -/** - * Cancel the most recently queued or steered message (last-only: see - * `cancelLast`'s doc comment for why picking an earlier item is out of - * scope). Retracts it from the queue and rewrites its transcript row so the - * readout never shows a message tagged "queue"/"steer" that will not send. - */ -export function applyShellCancelLast(shell: AppShell): void { - const { state, item } = cancelLast(shell.session); - if (item === null) return; - shell.session = state; - const index = findQueueRowIndex(shell, item.id); - if (index !== undefined) { - const row = streamRowAt(shell, index); - if (row !== undefined) { - // `cancelled` stays a flag, not a `text` rewrite — `paintStreamRow` - // owns turning it into the "[cancelled]" prefix, so `row.text` still - // holds what the operator actually typed for anything else that reads - // it (copy mode, a resumed transcript). - replaceStreamRowAt(shell, index, { ...row, meta: "cancelled", cancelled: true }); - } - } - paintChrome(shell); -} - -/** Local interrupt mutation (no bridge re-entry). */ -export function applyShellInterrupt(shell: AppShell): void { - const had = badgeCount(shell.session); - shell.session = interrupt(shell.session); - shell.prompt.value = ""; - appendStreamRow(shell, { - role: "system", - text: had > 0 ? `${had} pending kept` : "stopped", - meta: "stop", - }); - paintChrome(shell); -} - -/** Ctrl+C interrupt path: keep pending, flash, idle. */ -export function interruptShell(shell: AppShell): void { - const hooks = getShellBridgeHooks(shell); - if (hooks?.exclusive) { - hooks.onInterrupt(); - return; - } - applyShellInterrupt(shell); -} - -export function clearShellInterruptFlash(shell: AppShell): void { - shell.session = clearInterruptFlash(shell.session); - paintChrome(shell); -} - -const OVERLAY_FRAME_ID = "inset-demo"; - -/** Re-shape and store the open overlay's body rows for the current width. */ -function applyOverlayBodyText( - shell: AppShell, - text: string, - maxLines: number, - terminalHeight = shell.renderer.height, -): void { - const width = overlayRowWidth(shell.layout.contentWidth); - const bag = internals.get(shell); - // Scoped to decision overlays: a palette stacked over an open approval - // calls this too, with its own (usually empty) body text. Caching that - // would overwrite the approval's cached raw text with the palette's, and - // popping the palette restores the approval's `overlayBodyLines` but not - // this cache (`PriorOverlaySnapshot` never carried it) — so a resize right - // after would re-shape the approval's body from the palette's stale empty - // string instead of its own, blanking it. The palette itself never reads - // this cache (not a decision overlay), so it never needs to be cached. - if (bag && isDecisionOverlay(shell.overlayKind)) bag.overlayRawBodyText = text; - if (text.length === 0) { - shell.overlayBodyLines = []; - shell.overlayBodyFgs = []; - return; - } - if (isDecisionOverlay(shell.overlayKind)) { - const rows = composeDecisionBody( - text, - width, - decisionContextBudget({ - terminalHeight, - overlayRowsPerItem: overlayRowsPerItem( - shell.overlayKind, - shell.overlayItems, - shell.layout.contentWidth, - ), - overlayTitleRows: overlayTitleRows(shell.overlayKind), - overlayHostBorderRows: OVERLAY_HOST_BORDER_ROWS, - overlayMaxFraction: OVERLAY_MAX_FRACTION, - promptBaseRows: PROMPT_BASE_ROWS, - }), - ); - shell.overlayBodyLines = rows.map((r) => r.text); - shell.overlayBodyFgs = rows.map((r) => r.fg); - return; - } - const lines = wrapOverlayText(text, width, maxLines); - shell.overlayBodyLines = lines; - shell.overlayBodyFgs = lines.map(() => UI.text); -} - -export interface OpenListOverlayOpts { - readonly kind?: PrimaryOverlayKind; - readonly title?: string; - readonly items?: readonly string[]; - /** Optional stable ids aligned with `items` (permission scope ids, model ids). */ - readonly itemIds?: readonly string[]; - /** - * Optional plain chosen-value aligned with `items`, for rows whose display - * label carries more than the value itself (a cycled field's name, padding, - * and `‹ ›` markers around the active option). The accept echo reads this - * instead of recovering the value by parsing the label back apart. - */ - readonly itemValues?: readonly (string | undefined)[]; - readonly body?: string; - readonly activeIndex?: number; - readonly frameId?: string; - /** - * Per-open accept callback. Takes precedence over shell-level overlay hooks - * for this open. Not invoked on Esc / closeInsetOverlay. - */ - readonly onAccept?: (selection: OverlaySelection) => void; - /** - * Per-open expand/collapse hook. When set, the modal overlay claims a bare - * key for it (see OVERLAY_EXPAND_KEY) — no global binding is needed because - * the overlay owns the keyboard while it is open. - */ - readonly onToggleExpand?: () => void; - /** - * Per-open ← → cycle hook. When set, the overlay claims Left/Right for it - * instead of leaving them unbound — settings-style inline value cycling. - * Scoped to this open only, the way `onToggleExpand` and `typeToFilter` are. - */ - readonly onCycle?: (itemId: string, direction: -1 | 1) => void; - /** - * Per-open Esc/dismiss hook for promise-backed overlays (permissions, - * operator). Invoked by closeInsetOverlay before the accept path is - * cleared, so the caller's awaited promise resolves instead of hanging. - */ - readonly onCancel?: () => void; - /** - * Per-open cleanup for replace and dismiss. closeReplaceableOverlay - * invokes this and skips `onCancel`, which is Esc/dismiss only. - */ - readonly onDispose?: () => void; - /** - * True when this open is a permission/operator decision gate. Command - * surfaces call `closeReplaceableOverlay` to free the host; that no-ops - * while this is set so a live gate is not torn down. - */ - readonly isGate?: boolean; - /** - * Invoked only after this open actually takes the host (including a - * deferred flush). Busy no-ops and deferred stashes do not run it. - */ - readonly onOpened?: () => void; - /** - * Description-zone source. Called with the focused item's id on every move - * (falling back to its label when no `itemIds` were supplied). Returning - * null renders the zone blank, not collapsed — the fixed two-line zone is - * charged to the row budget whenever this is set, whether or not the current - * item has anything to say. - */ - readonly describe?: (itemId: string) => ItemDescription | null; - /** - * Per-open bare-key claim, checked before list navigation. Returning false - * leaves the key available to the ordinary j/k and arrow handlers. Scoped to - * this open only, so it cannot shadow prompt typing. - */ - readonly onAction?: (itemId: string, key: KeyEvent) => boolean; - /** Per-open bracketed-paste target for synthetic text panes. */ - readonly onPaste?: (text: string) => void; - /** - * Per-open free-text answer. When set the overlay paints an answer field the - * operator can Tab into and type into, and submitting it closes the overlay - * through this callback instead of the selection path. - */ - readonly onTextAnswer?: (text: string) => void; - /** - * Open with the answer field already taking keystrokes. Used when there is - * nothing to choose, so the overlay is never a chooser with an empty list. - */ - readonly textAnswerActive?: boolean; - /** - * Suppress the `chose (kind): label` transcript echo for this open. - * - * The echo exists so a choice with no other visible result still leaves a - * trace. A surface that reports the outcome itself does not need it, and the - * echo is worse than silent there: it quotes the row's label from *before* - * the action, so authorizing a server leaves a permanent line saying that - * server needs authorization. - */ - readonly echoChoice?: boolean; - /** - * Claim printable keys for a `>` filter row so the list narrows as you type. - * Opt-in per open (model picker, palette, resume). Overlays without it keep j/k - * navigation; with it, j/k type into the filter and arrows still navigate. - */ - readonly typeToFilter?: boolean; - /** - * Advertise Alt+A and /connect in the footer and yield composed Option+A - * (å/Å) from type-to-filter. Set only when the caller actually wired an - * add-provider handler via `onAction`, so the hint never names a dead chord. - */ - readonly addProviderHint?: boolean; - /** - * Advertise the Alt+D set-default hint in the footer for this open. Set - * only when the caller actually wired an Alt+D handler via `onAction`. - */ - readonly setDefaultHint?: boolean; - /** - * Advertise Alt+D disable / Alt+R remove in the `/mcp` footer. Confirm - * overlays leave this unset so they fall back to DEFAULT_OVERLAY_HINTS. - */ - readonly mcpManageHint?: boolean; - /** - * Advertise Alt+A add in the `/mcp` footer. False while local settings - * shadow global MCP (add is hidden and Alt+A is a dead chord). - */ - readonly mcpAddHint?: boolean; - /** - * When the host is already showing a non-palette overlay, stash this open - * in the one deferred slot and print a system line. Off by default: a - * busy open is a silent no-op (demo, mentions, same-kind re-open of - * surfaces that do not call `closeReplaceableOverlay` first). - */ - readonly deferIfBusy?: boolean; -} - -/** - * Open an inset list overlay on the shared host (permissions / operator / picker / palette). - * Measures body + list into geometry — no guessed absolute paint. - * - * Single host: a non-palette open while anything is showing is a silent no-op - * unless `deferIfBusy` is set, in which case it waits in one deferred slot - * with a system line. Callers that replace a non-gate list close it first. - * Palette may stack over a prior primary. - */ -export function openListOverlay(shell: AppShell, opts?: OpenListOverlayOpts): void { - const kind = opts?.kind ?? "demo"; - const isPalette = kind === "palette"; - - // Single host: non-palette open is a silent no-op while anything is open, - // unless the caller opted into the one deferred command-surface slot. - // Command surfaces that should replace a non-gate list call - // closeReplaceableOverlay first. Palette may stack over a prior primary. - if (shell.overlayList) { - if (!isPalette) { - if (opts?.deferIfBusy === true) deferBusyCommandOpen(shell, opts); - return; - } - if (shell.overlayKind !== "palette") { - const bag = internals.get(shell); - if (bag) { - bag.priorOverlay = { - kind: shell.overlayKind, - items: shell.overlayItems, - bodyLines: shell.overlayBodyLines, - bodyFgs: shell.overlayBodyFgs, - list: shell.overlayList, - title: String(shell.overlayTitle.content), - paletteCommands: shell.paletteCommands, - primaryBindings: { ...bag.primaryBindings }, - answer: bag.overlayAnswer, - titleText: bag.overlayTitleText, - }; - } - // Leave prior overlay focus frame; palette will stack above it. - } else { - // Already palette — pop palette frame only so we re-push cleanly. - let guard = 4; - while (guard-- > 0 && focusOwner(shell.focus) === "palette") { - shell.focus = popFocus(shell.focus); - } - } - } - - const labels = opts?.items ?? shell.overlayItems; - shell.overlayItems = labels; - shell.overlayKind = kind; - if (!isPalette) shell.paletteCommands = []; - - const bag = internals.get(shell); - if (bag) { - bag.overlayGeneration += 1; - // A stacked palette borrows the primary bindings until restoration. - if (!isPalette || !bag.priorOverlay) { - bag.primaryBindings = { - itemIds: opts?.itemIds ? [...opts.itemIds] : [], - itemValues: opts?.itemValues ? [...opts.itemValues] : [], - onAccept: opts?.onAccept ?? null, - onToggleExpand: opts?.onToggleExpand ?? null, - onCycle: opts?.onCycle ?? null, - describe: opts?.describe ?? null, - onAction: opts?.onAction ?? null, - onPaste: opts?.onPaste ?? null, - onCancel: opts?.onCancel ?? null, - onDispose: opts?.onDispose ?? null, - isGate: opts?.isGate === true, - addProviderHint: opts?.addProviderHint ?? false, - setDefaultHint: opts?.setDefaultHint ?? false, - mcpManageHint: opts?.mcpManageHint ?? false, - mcpAddHint: opts?.mcpAddHint ?? false, - }; - bag.overlayEchoChoice = opts?.echoChoice ?? true; - // Capture the full unfiltered set so typing can re-narrow in place. - bag.listFilter = - !isPalette && opts?.typeToFilter === true - ? { - query: "", - allItems: [...labels], - allItemIds: opts?.itemIds ? [...opts.itemIds] : [], - allItemValues: opts?.itemValues ? [...opts.itemValues] : [], - } - : null; - } - if (!isPalette) { - bag.overlayAnswer = - opts?.onTextAnswer === undefined - ? null - : { - text: "", - // With nothing to choose, typing is the only way to answer, so - // the field takes the keys immediately. - active: opts.textAnswerActive ?? labels.length === 0, - onSubmit: opts.onTextAnswer, - }; - } - } - - // Type-to-filter list overlays paint a `>` query row; everything else uses - // the caller's body text (or empty). - const bodyText = - !isPalette && opts?.typeToFilter === true - ? `> ${bag?.listFilter?.query ?? ""}` - : (opts?.body ?? ""); - // Operator question and permission approval context get body lines; other - // list-only overlays keep the body empty. - applyOverlayBodyText(shell, bodyText, 0); - - // Ask for exactly what the content needs. The resolver caps the request - // against OVERLAY_MAX_FRACTION and the transcript floor, and applyLayout - // shrinks the viewport to whatever survived — so a longer list scrolls - // instead of growing, and a short one leaves no dead rows below it. - // An empty list charges no rows: a chooser with nothing to choose must not - // reserve a blank band the operator can neither read nor act on. - const listItems = labels.length; - - shell.overlayList = createListViewport({ - count: labels.length, - height: Math.max(1, listItems), - activeIndex: opts?.activeIndex ?? 0, - }); - - if (bag) bag.overlayTitleText = opts?.title ?? "permission"; - refreshOverlayTitle(shell); - - const frameId = opts?.frameId ?? OVERLAY_FRAME_ID; - const focusTarget = isPalette ? "palette" : "overlay"; - shell.focus = openOverlay(shell.focus, frameId, { - target: focusTarget, - scrollOwner: isPalette ? "palette" : "overlay", - }); - relayoutOverlayHost(shell, listItems); - applyFocus(shell); - paintOverlayList(shell); - opts?.onOpened?.(); -} - -/** Open inset permission/palette stub; focus stack owns keys; Esc closes. */ -export function openInsetOverlay(shell: AppShell, items?: readonly string[]): void { - openListOverlay(shell, { - kind: "demo", - title: "permission", - items: items ?? shell.overlayItems, - frameId: OVERLAY_FRAME_ID, - }); -} - -/** Resolve the shell's registry-backed command catalog (host-injected). */ -export function resolvePaletteCatalog(shell: AppShell): readonly PaletteCommand[] { - const bag = internals.get(shell); - const raw = bag?.paletteCatalog; - if (raw === null || raw === undefined) return []; - return typeof raw === "function" ? raw() : raw; -} - -/** - * Replace the shell's `/` command catalog (host rebinds after registry load). - * Pass null to clear it. - */ -export function setPaletteCatalog( - shell: AppShell, - catalog: readonly PaletteCommand[] | (() => readonly PaletteCommand[]) | null, -): void { - const bag = internals.get(shell); - if (bag) bag.paletteCatalog = catalog; -} - -/** - * Open the `/` command list overlay. Catalog: opts.catalog when given, else - * the shell's registry-backed default (see `resolvePaletteCatalog`). - */ -export function openPalette( - shell: AppShell, - opts?: { - readonly query?: string; - readonly catalog?: readonly PaletteCommand[]; - readonly title?: string; - /** Claim printable keys for the `>` filter row. Off for the `/` popup. */ - readonly typeToFilter?: boolean; - }, -): void { - const title = opts?.title ?? "command palette"; - const bag = internals.get(shell); - if (bag) { - bag.paletteFilter = { - query: opts?.query ?? "", - title, - // `/` passes a pre-narrowed catalog; omitting it re-resolves the shell - // default so a registry loaded later is picked up. - catalog: opts?.catalog ?? null, - // The `/` popup keeps its query in the prompt and drives its own reopen. - typeToFilter: opts?.typeToFilter ?? false, - }; - } - repaintPalette(shell); -} - -/** Palette open state that survives a re-filter. */ -interface PaletteFilterState { - query: string; - readonly title: string; - readonly catalog: readonly PaletteCommand[] | null; - readonly typeToFilter: boolean; -} - -/** - * Live type-to-filter state for a non-palette list overlay (model picker). - * Holds the full unfiltered row set so each keystroke can re-narrow in place - * without reopening the overlay (a busy open is a silent no-op unless - * `deferIfBusy` is set). - */ -interface ListFilterState { - query: string; - readonly allItems: readonly string[]; - readonly allItemIds: readonly string[]; - readonly allItemValues: readonly (string | undefined)[]; -} - -/** Re-open the palette against the current filter state (used on every keystroke). */ -function repaintPalette(shell: AppShell): void { - const state = internals.get(shell)?.paletteFilter; - if (!state) return; - const catalog = state.catalog ?? resolvePaletteCatalog(shell); - const commands = filterPaletteCommands(state.query, catalog); - const labels = commands.length > 0 ? paletteLabels(commands) : ["(no matches)"]; - shell.paletteCommands = commands; - openListOverlay(shell, { - kind: "palette", - title: state.title, - items: labels, - itemIds: commands.map((c) => c.id), - describe: (id) => { - const cmd = commands.find((c) => c.id === id); - const what = cmd?.description?.trim(); - return what ? { what } : null; - }, - // Typed filter row only when the overlay owns keystrokes. The `/` popup - // keeps its query in the prompt, so a body of `>` would be orphan chrome. - ...(state.typeToFilter ? { body: `> ${state.query}` } : {}), - frameId: "command-palette", - }); - // No title rule row: the box is only ever the palette, and when a filter - // row is present it already shows what's typed. - shell.overlayTitle.visible = false; - shell.overlayTitle.content = ""; - paintOverlayList(shell); -} - -/** - * Keys a type-to-filter list claims while it is open, so the `>` row filters - * as you type. - * - * Opt-in per open (`typeToFilter`): palette, the flat model picker, and the - * resume picker give up j/k navigation so printable keys feed the filter. - * Overlays without type-to-filter (permissions, workers, copy, …) keep j/k. Arrow and - * page keys are never claimed here, so they keep working in every overlay - * including type-to-filter ones. - */ -export function handlePaletteFilterKey(shell: AppShell, key: KeyEvent): boolean { - const state = internals.get(shell)?.paletteFilter; - if (!state?.typeToFilter) return false; - if (shell.overlayKind !== "palette" || shell.overlayList === null) return false; - if (key.ctrl || key.meta || key.option) return false; - - if (key.name === "backspace") { - if (state.query.length === 0) return true; - state.query = state.query.slice(0, -1); - repaintPalette(shell); - return true; - } - - const seq = typeof key.sequence === "string" ? key.sequence : ""; - if (seq.length !== 1 || seq < " ") return false; - - state.query += seq; - repaintPalette(shell); - return true; -} - -/** - * Glyphs some terminals emit for Option+A without setting meta/option. - */ -const OPTION_A_COMPOSED_CHARS = new Set(["å", "Å"]); - -/** - * True when a key event is the model-picker Alt+A add-provider chord. - * Terminals may deliver Option+A as å/Å without meta/option. - */ -export function isAddProviderShortcutKey(key: KeyEvent): boolean { - if (key.ctrl) return false; - const name = typeof key.name === "string" ? key.name : ""; - const seq = typeof key.sequence === "string" ? key.sequence : ""; - if ((key.meta || key.option) && name.toLowerCase() === "a") return true; - if (OPTION_A_COMPOSED_CHARS.has(name) || OPTION_A_COMPOSED_CHARS.has(seq)) return true; - return false; -} - -/** - * Keys a type-to-filter list overlay claims while open, so the `>` row - * narrows as you type. Mirrors the palette filter, but updates the open - * list in place via setOverlayItems (a busy openListOverlay is a silent - * no-op unless `deferIfBusy` is set). - */ -export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean { - const bag = internals.get(shell); - const state = bag?.listFilter; - if (!state || shell.overlayList === null) return false; - if (shell.overlayKind === "palette") return false; - if (key.ctrl || key.meta || key.option) return false; - - // addProviderHint also gates this filter-bypass so composed Option+A - // (å/Å) reaches runOverlayAction instead of type-to-filter. - if ( - bag?.primaryBindings.addProviderHint === true && - shell.overlayKind === "model_picker" && - isAddProviderShortcutKey(key) - ) { - return false; - } - - if (key.name === "backspace") { - if (state.query.length === 0) return true; - state.query = state.query.slice(0, -1); - repaintListFilter(shell); - return true; - } - - const seq = typeof key.sequence === "string" ? key.sequence : ""; - if (seq.length !== 1 || seq < " ") return false; - - state.query += seq; - repaintListFilter(shell); - return true; -} - -function repaintListFilter(shell: AppShell): void { - const bag = internals.get(shell); - const state = bag?.listFilter; - if (!state) return; - const q = state.query.trim().toLowerCase(); - const matched: { label: string; id: string; value: string | undefined }[] = []; - for (let i = 0; i < state.allItems.length; i++) { - const label = state.allItems[i] ?? ""; - const id = state.allItemIds[i] ?? label; - if (q.length > 0) { - const hay = `${label} ${id}`.toLowerCase(); - if (!hay.includes(q)) continue; - } - matched.push({ - label, - id, - value: state.allItemValues[i], - }); - } - const labels = matched.length > 0 ? matched.map((m) => m.label) : ["(no matches)"]; - const ids = matched.length > 0 ? matched.map((m) => m.id) : [""]; - const values = - state.allItemValues.length > 0 - ? matched.length > 0 - ? matched.map((m) => m.value) - : [undefined] - : undefined; - setOverlayItems(shell, labels, ids, values); - setOverlayBody(shell, `> ${state.query}`); -} - -/** - * Move the open overlay's free-text field in or out of taking keystrokes. - * Returns false when the overlay offers no such field. - */ -export function setOverlayAnswerActive(shell: AppShell, active: boolean): boolean { - const answer = overlayAnswerState(shell); - if (answer === null || shell.overlayList === null) return false; - if (answer.active === active) return false; - answer.active = active; - refreshOverlayTitle(shell); - paintOverlayList(shell); - return true; -} - -/** - * Esc inside a live answer field means "back to the choices", not "abandon the - * question" — but only when there are choices to go back to. - */ -export function exitOverlayAnswerMode(shell: AppShell): boolean { - const answer = overlayAnswerState(shell); - if (answer === null || !answer.active) return false; - if (shell.overlayItems.length === 0) return false; - return setOverlayAnswerActive(shell, false); -} - -/** - * Keys the free-text answer field claims while it is taking input. Printable - * characters and backspace edit the answer; Enter submits it and closes the - * overlay through the per-open `onTextAnswer` callback. - */ -export function handleOverlayAnswerKey(shell: AppShell, key: KeyEvent): boolean { - const answer = overlayAnswerState(shell); - if (answer === null || shell.overlayList === null) return false; - - if (key.name === "tab" && !key.shift && !key.ctrl && !key.meta && !key.option && !answer.active) { - return setOverlayAnswerActive(shell, true); - } - if (!answer.active) return false; - if (key.ctrl || key.meta || key.option) return false; - - if (key.name === "return" || key.name === "enter") { - if (answer.text.length === 0) return true; - const text = answer.text; - const submit = answer.onSubmit; - const bag = internals.get(shell); - if (bag?.overlayEchoChoice !== false) { - appendStreamRow(shell, { - role: "system", - text: `answered: ${text}`, - meta: overlayKindWord(shell.overlayKind ?? "operator"), - }); - } - // Deliberate submit, not a dismiss — closeInsetOverlay must not also fire - // the Esc/cancel path. - if (bag) bag.primaryBindings.onCancel = null; - closeInsetOverlay(shell); - submit(text); - return true; - } - if (key.name === "backspace") { - if (answer.text.length > 0) { - answer.text = answer.text.slice(0, -1); - paintOverlayList(shell); - } - return true; - } - - const seq = typeof key.sequence === "string" ? key.sequence : ""; - if (seq.length !== 1 || seq < " ") return false; - answer.text += seq; - paintOverlayList(shell); - return true; -} - -/** - * Which open surface a chord toggles shut, or null when the chord is not a - * toggling opener. - * - * Only pickers appear here. An opener that performs an action (Ctrl+P attaches - * an image, Ctrl+C interrupts, the expand key expands a row) has nothing to - * toggle, and a decision surface — a permission or operator question — is - * deliberately absent: re-pressing whatever chord happened to be underneath it - * must not count as an answer. Those leave via a choice or Esc. - * - * `@` and `/` are openers too, but they are also characters being typed, so - * pressing them again inserts them rather than closing the popup. - */ -function toggledSurfaceFor(key: KeyEvent): PrimaryOverlayKind | null { - if ((key.meta || key.option) && !key.ctrl && (key.name === "c" || key.name === "C")) { - return "copy"; - } - return null; -} - -/** - * Re-pressing the chord that opened a picker closes it, through the same path - * Esc uses so key claims and focus are unwound identically. - */ -function toggleCloseOpenSurface(shell: AppShell, key: KeyEvent): boolean { - if (shell.overlayList === null) return false; - const kind = toggledSurfaceFor(key); - if (kind === null || kind !== shell.overlayKind) return false; - // The `/` popup borrows the palette overlay; there the chord is still a - // character the operator may be typing into the filter. - if (kind === "palette" && isSlashPopupOpen(shell)) return false; - closeInsetOverlay(shell); - return true; -} - -/** Close overlay/palette if open; restore prior focus (or prior overlay under palette). */ -export function closeInsetOverlay(shell: AppShell): void { - if (!shell.overlayList) return; - // Esc (or any other dismiss) must also drop the `/` and `@` popups' key claim. - slashPopups.delete(shell); - if (mentionPopups.has(shell)) clearMentionAccept(shell); - mentionPopups.delete(shell); - - const wasPalette = shell.overlayKind === "palette"; - if (wasPalette) { - const filterBag = internals.get(shell); - if (filterBag) filterBag.paletteFilter = null; - } - const bag = internals.get(shell); - if (bag) bag.listFilter = null; - const prior = wasPalette ? (bag?.priorOverlay ?? null) : null; - // A primary overlay that registers onCancel owns cleanup for every dismiss - // path. A palette stacked over another overlay restores that prior frame - // instead, so its callback must remain untouched. - const onCancel = !prior ? (bag?.primaryBindings.onCancel ?? null) : null; - const onDispose = !prior ? (bag?.primaryBindings.onDispose ?? null) : null; - - shell.overlayList = null; - shell.overlayKind = null; - shell.overlayBodyLines = []; - shell.overlayBodyFgs = []; - shell.paletteCommands = []; - shell.copyTargets = null; - shell.overlayView.clearBody(); - // Esc / dismiss: drop accept path without invoking it (onCancel above is - // captured before this clears, and is invoked separately once state settles). - if (bag && !prior) { - bag.primaryBindings = { ...EMPTY_PRIMARY_BINDINGS }; - bag.overlayAnswer = null; - } - - // Pop exactly one frame (palette or overlay). - if (focusOwner(shell.focus) === "overlay" || focusOwner(shell.focus) === "palette") { - shell.focus = popFocus(shell.focus); - } - - if (prior && bag) { - bag.priorOverlay = null; - // Restore prior primary overlay paint; focus should already be overlay. - shell.overlayItems = prior.items; - shell.overlayKind = prior.kind; - shell.overlayBodyLines = prior.bodyLines; - shell.overlayBodyFgs = prior.bodyFgs; - shell.overlayList = prior.list; - shell.paletteCommands = prior.paletteCommands; - shell.overlayTitle.visible = true; - shell.overlayTitle.content = prior.title; - bag.primaryBindings = { ...prior.primaryBindings }; - bag.overlayAnswer = prior.answer; - bag.overlayTitleText = prior.titleText; - // If focus was not stacked (edge case), re-open overlay frame. - if (focusOwner(shell.focus) !== "overlay") { - shell.focus = openOverlay(shell.focus, OVERLAY_FRAME_ID, { - target: "overlay", - scrollOwner: "overlay", - }); - } - relayoutOverlayHost(shell, prior.list.count); - applyFocus(shell); - paintOverlayList(shell); - return; - } - - // Ensure no leftover overlay/palette frames. - let guard = 4; - while ( - guard-- > 0 && - (focusOwner(shell.focus) === "overlay" || focusOwner(shell.focus) === "palette") - ) { - shell.focus = popFocus(shell.focus); - } - - relayout(shell, { overlayMode: "closed" }); - applyFocus(shell); - if (bag) bag.overlayGeneration += 1; - if (isOverlayHostIdle(shell)) notifyOverlayClosed(shell); - try { - onDispose?.(); - onCancel?.(); - } finally { - scheduleDeferredCommandFlush(shell); - } -} - -/** - * Close the current overlay only when dismissing it does not settle a - * decision gate (`isGate`). Command surfaces that need a fresh host - * (settings cycle, plugins, mcp) call this instead of `closeInsetOverlay` - * so a live gate is left in place and `openListOverlay` can defer. - * Overlays that bind `onDispose` for cleanup (mcp unsubscribe) still - * run that hook; `onCancel` is Esc/dismiss only and is skipped here. - */ -export function closeReplaceableOverlay(shell: AppShell): void { - const bag = internals.get(shell); - if (bag?.primaryBindings.isGate === true) return; - if (bag) bag.primaryBindings.onCancel = null; - closeInsetOverlay(shell); -} - -/** - * Subscribe to "the overlay host is idle". Idle means no live list, no - * deferred command surface, and no host reservations. Callers that must not - * lose an open (gate wiring) queue on this instead of racing a busy host. - */ -export function onOverlayClosed(shell: AppShell, listener: () => void): () => void { - const bag = internals.get(shell); - if (!bag) return () => undefined; - bag.overlayClosedListeners.add(listener); - return () => { - bag.overlayClosedListeners.delete(listener); - }; -} - -/** - * True when the shared overlay host can accept a new primary open: the shell - * is live, no list is showing, no deferred command is waiting, and nothing - * holds a reservation. - */ -export function isOverlayHostIdle(shell: AppShell): boolean { - if (shell.disposed) return false; - const bag = internals.get(shell); - return ( - shell.overlayList === null && - (bag?.deferredCommandOverlay ?? null) === null && - (bag?.overlayHostReservations ?? 0) === 0 - ); -} - -function notifyOverlayClosed(shell: AppShell): void { - if (!isOverlayHostIdle(shell)) return; - const bag = internals.get(shell); - if (!bag) return; - // Copied: a listener may re-open an overlay and unsubscribe mid-iteration. - for (const listener of [...bag.overlayClosedListeners]) listener(); -} - -/** - * Hold the overlay host idle-notify while an async command surface is still - * claiming it (permissions.list() before settings/permissions paint). Release - * clears the hold, flushes a deferred surface if one is waiting, and notifies - * if the host is actually idle. - */ -export function reserveOverlayHost(shell: AppShell): () => void { - const bag = internals.get(shell); - if (!bag) return () => undefined; - bag.overlayHostReservations += 1; - const epoch = bag.overlayReservationEpoch; - let released = false; - return () => { - if (released) return; - released = true; - const current = internals.get(shell); - if (!current || current.overlayReservationEpoch !== epoch) return; - if (current.overlayHostReservations > 0) current.overlayHostReservations -= 1; - scheduleDeferredCommandFlush(shell); - notifyOverlayClosed(shell); - }; -} - -/** Drop in-flight host holds. Stale `release()` callbacks become no-ops. */ -function abortOverlayHostReservations(shell: AppShell): void { - const bag = internals.get(shell); - if (!bag || bag.overlayHostReservations === 0) return; - bag.overlayReservationEpoch += 1; - bag.overlayHostReservations = 0; - bag.overlayGeneration += 1; - scheduleDeferredCommandFlush(shell); -} - -/** One deferred command-surface slot while the host is busy. */ -function deferBusyCommandOpen(shell: AppShell, opts: OpenListOverlayOpts): void { - const bag = internals.get(shell); - if (!bag) return; - bag.deferredCommandOverlay = opts.kind === undefined ? { ...opts, kind: "demo" } : opts; - const kind = overlayKindWord(opts.kind ?? "demo"); - appendStreamRow(shell, { - role: "system", - text: `${kind} will open when the current list closes.`, - }); - scheduleDeferredCommandFlush(shell); -} - -function scheduleDeferredCommandFlush(shell: AppShell): void { - const bag = internals.get(shell); - if (!bag || bag.deferredCommandOverlay === null || bag.deferredFlushScheduled) return; - bag.deferredFlushScheduled = true; - queueMicrotask(() => { - bag.deferredFlushScheduled = false; - if (shell.disposed) { - bag.deferredCommandOverlay = null; - return; - } - flushDeferredCommandOverlay(shell); - }); -} - -function flushDeferredCommandOverlay(shell: AppShell): void { - const bag = internals.get(shell); - if (!bag) return; - // Live list still occupies the host — keep the slot. - if (shell.overlayList !== null) return; - const opts = bag.deferredCommandOverlay; - if (opts === null) { - notifyOverlayClosed(shell); - return; - } - bag.deferredCommandOverlay = null; - // Reservations/disposed still occupy the host; restore the slot. - if (!isOverlayHostIdle(shell)) { - bag.deferredCommandOverlay = opts; - return; - } - openListOverlay(shell, opts); -} - -function dropDeferredCommandOverlay(shell: AppShell): void { - const bag = internals.get(shell); - if (!bag) return; - bag.deferredCommandOverlay = null; - bag.deferredFlushScheduled = false; -} - -/** - * Bare key the modal overlay claims for its expand/collapse hook. Deliberately - * not in SHELL_SHORTCUTS: it is live only while an overlay that supplied - * `onToggleExpand` is open, so it never shadows a prompt binding. - */ -export const OVERLAY_EXPAND_KEY = EXPAND_KEY; - -/** - * Expand or collapse every transcript row that hides a body behind a summary: - * loaded skills, summarised tool calls, settled reasoning. Same key as the - * overlay's collapsed payloads, so the product has one expand idiom. - * - * All-or-nothing rather than one row at a time: with several collapsed rows on - * screen, expanding the newest and leaving the rest reads as the key having - * missed. Any row still collapsed means the whole set opens; only once nothing - * is left to open does the key close them again. - * - * False when no row on the log can expand at all. - */ -/** - * Expand or collapse exactly one transcript row — what a click on its arrow - * means. The key stays bulk (see `toggleCollapsedRow`): a pointer says *this - * one*, a key with nothing under it can only mean all of them. - * - * False when that row hides nothing. - */ -/** `index` is absolute (see `streamLogBase`), matching the index closures built off `createStreamRowRenderable` carry. */ -export function toggleRowExpandedAt(shell: AppShell, index: number): boolean { - const row = shell.streamLog[index - shell.streamLogBase]; - if (row === undefined || !isCollapsibleRow(row)) return false; - replaceStreamRowAt(shell, index, { ...row, expanded: row.expanded !== true }); - return true; -} - -export function toggleCollapsedRow(shell: AppShell): boolean { - const collapsible = shell.streamLog.flatMap((row, local) => - row !== undefined && isCollapsibleRow(row) ? [{ row, index: shell.streamLogBase + local }] : [], - ); - if (collapsible.length === 0) return false; - const expand = collapsible.some(({ row }) => row.expanded !== true); - for (const { row, index } of collapsible) { - if ((row.expanded === true) === expand) continue; - replaceStreamRowAt(shell, index, { ...row, expanded: expand }); - } - return true; -} - -/** Replace the open overlay's body text in place (re-wrap + relayout). */ -export function setOverlayBody(shell: AppShell, text: string, maxLines = 8): void { - if (!shell.overlayList) return; - applyOverlayBodyText(shell, text, maxLines); - // Ask for the whole list again, not the height it currently has: a body that - // shrank should hand its rows back to the choices rather than leave the - // viewport stuck at the size an earlier, taller body forced it to. - const perItem = overlayRowsPerItem( - shell.overlayKind, - shell.overlayItems, - shell.layout.contentWidth, - ); - const chrome = overlayChromeRows( - shell.overlayKind, - shell.overlayBodyLines.length, - !!internals.get(shell)?.primaryBindings.describe, - overlayAnswerState(shell) !== null, - ); - const hostRows = chrome + Math.max(1, shell.overlayItems.length) * perItem; - const minHostRows = overlayMinHostRows(chrome, perItem, shell.overlayItems.length > 0); - relayout(shell, { - overlayMode: "inset", - overlayBodyRows: hostRows, - overlayMinBodyRows: minHostRows, - }); - paintOverlayList(shell); -} - -export interface OverlayContinuationToken { - readonly generation: number; -} - -/** Capture overlay generation for an async continuation. Stale after a newer open, a full close, or Esc abort. */ -export function captureOverlayContinuation(shell: AppShell): OverlayContinuationToken { - return { generation: internals.get(shell)?.overlayGeneration ?? -1 }; -} - -/** True only while no newer overlay has taken ownership of the shared host. */ -export function isOverlayContinuationCurrent( - shell: AppShell, - token: OverlayContinuationToken, -): boolean { - return isOverlayGenerationCurrent(shell, token) && shell.overlayList === null; -} - -/** True while the shell is live and generation has not advanced. */ -export function isOverlayGenerationCurrent( - shell: AppShell, - token: OverlayContinuationToken, -): boolean { - return !shell.disposed && internals.get(shell)?.overlayGeneration === token.generation; -} - -/** - * Refresh an overlay owned by either the foreground or the frame beneath a - * stacked palette. Returns false once that overlay no longer owns either slot. - */ -export function setOwnedOverlayItems( - shell: AppShell, - kind: PrimaryOverlayKind, - items: readonly string[], - itemIds: readonly string[], -): boolean { - const bag = internals.get(shell); - if (!bag) return false; - - if (shell.overlayKind === kind && shell.overlayList !== null) { - const previousCount = shell.overlayItems.length; - const activeId = bag.primaryBindings.itemIds[shell.overlayList.activeIndex]; - const filter = bag.listFilter; - if (filter) { - bag.listFilter = { - query: filter.query, - allItems: [...items], - allItemIds: [...itemIds], - allItemValues: filter.allItemValues, - }; - repaintListFilter(shell); - } else { - setOverlayItems(shell, items, itemIds); - } - const displayedCount = shell.overlayItems.length; - const activeIndex = activeId === undefined ? -1 : bag.primaryBindings.itemIds.indexOf(activeId); - if (activeIndex >= 0 && shell.overlayList.activeIndex !== activeIndex) { - shell.overlayList = createListViewport({ - count: displayedCount, - height: shell.overlayList.height, - activeIndex, - }); - paintOverlayList(shell); - } - if (displayedCount !== previousCount) { - if (shell.overlayList !== null) { - shell.overlayList = setListHeight(shell.overlayList, Math.max(1, displayedCount)); - } - relayoutOverlayHost(shell, displayedCount); - paintOverlayList(shell); - } - return true; - } - - const prior = bag.priorOverlay; - if (prior?.kind !== kind) return false; - const activeId = prior.primaryBindings.itemIds[prior.list.activeIndex]; - const activeIndex = activeId === undefined ? -1 : itemIds.indexOf(activeId); - bag.priorOverlay = { - ...prior, - items: [...items], - primaryBindings: { ...prior.primaryBindings, itemIds: [...itemIds] }, - list: createListViewport({ - count: items.length, - height: prior.list.height, - activeIndex: activeIndex >= 0 ? activeIndex : prior.list.activeIndex, - }), - }; - return true; -} - -/** - * Replace the open overlay's item labels (and optionally ids) in place, - * keeping the active row's position. Cycling a value redraws the row it - * changed rather than closing and reopening the overlay, which would lose - * the cursor and retrigger the open animation for a one-key edit. - */ -export function setOverlayItems( - shell: AppShell, - items: readonly string[], - itemIds?: readonly string[], - itemValues?: readonly (string | undefined)[], - opts?: { readonly resetActive?: boolean }, -): void { - if (!shell.overlayList) return; - shell.overlayItems = items; - const bag = internals.get(shell); - if (bag && itemIds) bag.primaryBindings.itemIds = [...itemIds]; - if (bag && itemValues) bag.primaryBindings.itemValues = [...itemValues]; - // Most callers (mention/model-picker filtering) keep the operator's current - // selection as the list narrows. The `/` popup instead resets to the top - // row on every keystroke, matching pre-refresh behavior where each filter - // reopened the overlay fresh. - shell.overlayList = opts?.resetActive - ? createListViewport({ - count: items.length, - height: shell.overlayList.height, - activeIndex: 0, - }) - : setListCount(shell.overlayList, items.length); - paintOverlayList(shell); -} - -/** Run the open overlay's expand/collapse hook; true when one was bound. */ -export function toggleOverlayExpand(shell: AppShell): boolean { - if (!shell.overlayList) return false; - const hook = internals.get(shell)?.primaryBindings.onToggleExpand ?? null; - if (!hook) return false; - hook(); - return true; -} - -/** Move overlay selection (j/k / arrows). */ -export function moveOverlaySelection(shell: AppShell, delta: number): void { - if (!shell.overlayList) return; - shell.overlayList = moveActive(shell.overlayList, delta); - paintOverlayList(shell); -} - -/** - * Cycle the focused row's value in place, for overlays that opted in via - * `onCycle` (settings inline cycling). No-op when the open overlay did not - * supply a cycle hook, so Left/Right stay unclaimed everywhere else. - */ -export function cycleOverlaySelection(shell: AppShell, direction: -1 | 1): boolean { - const list = shell.overlayList; - if (!list) return false; - const onCycle = internals.get(shell)?.primaryBindings.onCycle; - if (!onCycle) return false; - onCycle(activeOverlayItemId(shell, list), direction); - return true; -} - -/** - * Run the open overlay's bare-key claim, for overlays that opted in via - * `onAction`. No-op when the open overlay did not supply one, so the key - * falls through unclaimed everywhere else. - */ -export function runOverlayAction(shell: AppShell, key: KeyEvent): boolean { - const list = shell.overlayList; - if (!list) return false; - const onAction = internals.get(shell)?.primaryBindings.onAction; - if (!onAction) return false; - return onAction(activeOverlayItemId(shell, list), key); -} - -/** Page overlay selection (PgUp/PgDn). */ -export function pageOverlaySelection(shell: AppShell, dir: -1 | 1): void { - if (!shell.overlayList) return; - shell.overlayList = pageList(shell.overlayList, dir); - paintOverlayList(shell); -} - -/** Accept active overlay item → callback + system line + close (palette dispatches action). - * Mention Enter that is not live (stale generation or cursor off that `@`) dismisses. */ -export function acceptOverlaySelection(shell: AppShell): void { - if (!shell.overlayList) return; - - if (shell.overlayKind === "copy") { - confirmCopySelection(shell); - return; - } - // Nothing to choose: Enter must not synthesize a phantom row and resolve the - // gate with it. The answer field (when offered) already claimed Enter. - if (shell.overlayItems.length === 0) return; - - const idx = shell.overlayList.activeIndex; - const label = shell.overlayItems[idx] ?? `item ${idx}`; - const kind = shell.overlayKind ?? "demo"; - const bag = internals.get(shell); - - if (kind === "palette") { - const cmd = shell.paletteCommands[idx]; - if (!cmd) { - // Type-to-filter plants a "(no matches)" row with no command. Stay open. - // Slash popup (`typeToFilter: false`) still closes — intentional dismiss. - if (bag?.paletteFilter?.typeToFilter === true && !isSlashPopupOpen(shell)) return; - closeInsetOverlay(shell); - return; - } - const release = reserveOverlayHost(shell); - closeInsetOverlay(shell); - try { - dispatchPaletteSelection(shell, cmd); - } finally { - release(); - } - return; - } - - if (kind === "mentions" && mentionPopups.has(shell) && liveMentionAccept(shell) === null) { - // Stale generation or cursor off the @token: operator dismiss, not accept. - closeInsetOverlay(shell); - return; - } - - const id = bag?.primaryBindings.itemIds[idx]; - // Type-to-filter plants "(no matches)" with an empty-id sentinel. Stay open. - if (id === "") return; - const value = bag?.primaryBindings.itemValues[idx]; - const selection: OverlaySelection = { - kind, - index: idx, - label, - ...(id !== undefined ? { id } : {}), - ...(value !== undefined ? { value } : {}), - }; - // Capture before close clears per-open state. - const perOpen = bag?.primaryBindings.onAccept ?? null; - // This is a deliberate accept, not a dismiss — closeInsetOverlay must not - // also fire the Esc/cancel path below. - if (bag) bag.primaryBindings.onCancel = null; - - if (bag?.overlayEchoChoice !== false) { - appendStreamRow(shell, { - role: "system", - text: overlayChoiceText(label, id, value), - meta: overlayKindWord(kind), - }); - } - // Accept is not operator dismiss: keep mention accept state for onAccept - // after this close (closeInsetOverlay would otherwise bump the generation). - if (kind === "mentions") mentionPopups.delete(shell); - const release = reserveOverlayHost(shell); - closeInsetOverlay(shell); - try { - dispatchOverlayAccept(shell, selection, perOpen); - } finally { - release(); - } -} - -/** - * Dispatch a selected `/` command list item after the popup has closed. - * Every entry is registry-backed — the host's `onCommand(name)` runs it. - */ -export function dispatchPaletteSelection(shell: AppShell, cmd: PaletteCommand): void { - const onCommand = getPaletteOnCommand(shell); - if (onCommand) { - onCommand(cmd.id); - return; - } - appendStreamRow(shell, { - role: "system", - text: `palette: /${cmd.id} (no onCommand handler)`, - }); -} - -/** Bracket marker per task status; a trailer row (status null) gets none. */ -function taskStatusMarker(status: TaskPanelRow["status"]): string { - switch (status) { - case "todo": - return "[ ] "; - case "doing": - return "[~] "; - case "done": - return "[x] "; - case "cancelled": - return "[-] "; - case null: - return ""; - } -} - -/** - * Fit a row's label + tail into `maxWidth` terminal columns, ellipsizing the - * label (agentId + description — free-form, model-authored, routinely long, - * and not guaranteed narrow: CJK and emoji run two columns per code point) - * before ever touching the tail (elapsed/tool/stalled). The tail carries - * the fact an operator glances at the panel to see, so it is preserved - * whole or not shown at all. Measured and sliced in columns via - * `stringWidth`/`sliceToWidth` (`src/tui/view/height.ts`) rather than UTF-16 - * code units — `.length` undercounts wide glyphs, which is exactly the class - * of bug that would make a row overflow its zone and wrap. - */ -function fitAgentRow(row: AgentPanelRow, maxWidth: number): string { - const full = ` ${row.label}${row.tail}`; - if (stringWidth(full) <= maxWidth) { - // Push every lane's tail to the right edge so the clocks line up as a - // column. A lane that has been silent far longer than its neighbours then - // stands out of that column by its shape, before any of it is read — which - // is the one thing the board has to get right at a glance. - if (row.kind === "lane") { - const pad = maxWidth - stringWidth(full); - return ` ${row.label}${" ".repeat(Math.max(0, pad))}${row.tail}`; - } - return full; - } - - const leadingSpace = 1; - const ellipsis = 1; - const budget = maxWidth - leadingSpace - stringWidth(row.tail) - ellipsis; - if (budget <= 0) { - // Not even the tail fits at full width — keep as much of the tail's - // trailing end (where the "stalled" marker lives) as there is room for, - // rather than an unreadable sliver of the label. - return ` ${sliceTailToWidth(row.tail, maxWidth - leadingSpace)}`; - } - return ` ${sliceToWidth(row.label, budget)}…${row.tail}`; -} - -/** - * Fit a task row's status marker + label into `maxWidth` columns, same - * ellipsis discipline as `fitAgentRow`: the marker (what says done vs. - * pending) is preserved whole, the free-form title is what gives way. - */ -function fitTaskRow(row: TaskPanelRow, maxWidth: number): string { - const marker = taskStatusMarker(row.status); - const full = ` ${marker}${row.label}`; - if (stringWidth(full) <= maxWidth) return full; - - const leadingSpace = 1; - const ellipsis = 1; - const budget = maxWidth - leadingSpace - stringWidth(marker) - ellipsis; - if (budget <= 0) return ` ${sliceToWidth(marker, maxWidth - leadingSpace)}`; - return ` ${marker}${sliceToWidth(row.label, budget)}…`; -} - -/** Rebuild taskBox's row children to match the requested rows exactly. */ -function renderTasksRows(shell: AppShell, rows: readonly TaskPanelRow[], maxWidth: number): void { - for (const child of [...shell.taskBox.getChildren()]) { - shell.taskBox.remove(child); - destroySubtree(child); - } - for (const row of rows) { - const text = new TextRenderable(shell.renderer as CliRenderer, { - content: fitTaskRow(row, maxWidth), - fg: row.status === "done" ? UI.done : row.status === "doing" ? UI.text : UI.textDim, - }); - shell.taskBox.add(text); - } -} - -/** Paint tone for one agents-strip row (cream live / orange trouble / green done). */ -function agentRowFg(row: AgentPanelRow): string { - if (row.kind === "more" || row.kind === "header") return UI.textDim; - if (row.stalled || row.status === "failed") return UI.action; - if (row.status === "done") return UI.done; - if (row.status === "cancelled" || row.status === "interrupted") return UI.textDim; - return UI.text; -} - -/** Rebuild agentsBox's row children to match the requested rows exactly. */ -function renderAgentsRows(shell: AppShell, rows: readonly AgentPanelRow[], maxWidth: number): void { - for (const child of [...shell.agentsBox.getChildren()]) { - shell.agentsBox.remove(child); - destroySubtree(child); - } - for (const row of rows) { - // Live lanes use primary cream (`UI.text`) — the Amp/Codex strip is body - // text, not bronze in-flight chrome. Stalled / failed keep the decision - // orange; done linger is green; cancelled / "+N more" sit back in dim. - const text = new TextRenderable(shell.renderer as CliRenderer, { - content: fitAgentRow(row, maxWidth), - fg: agentRowFg(row), - }); - shell.agentsBox.add(text); - } -} - -/** - * Set agents/task chrome zone content (null/empty = hide zone). - * Heights come from geometry resolve — never guessed. - */ -function taskRowsEqual(a: readonly TaskPanelRow[], b: readonly TaskPanelRow[]): boolean { - return ( - a.length === b.length && - a.every((row, i) => { - const other = b[i]; - return other !== undefined && row.label === other.label && row.status === other.status; - }) - ); -} - -export function setChromeZones(shell: AppShell, content: ChromeZoneContent): void { - const bag = internals.get(shell); - if (!bag) return; - - let taskChanged = false; - if (content.task !== undefined) { - bag.chrome.tasksRaw = content.task ?? []; - const rendered = bag.tasksPanelHidden ? [] : bag.chrome.tasksRaw; - taskChanged = !taskRowsEqual(rendered, bag.chrome.task); - bag.chrome.task = rendered; - } - let agentsChanged = false; - if (content.agents !== undefined) { - const next = content.agents ?? []; - agentsChanged = - next.length !== bag.chrome.agents.length || - next.some((row, i) => { - const prev = bag.chrome.agents[i]; - return ( - prev === undefined || - row.label !== prev.label || - row.tail !== prev.tail || - row.stalled !== prev.stalled || - row.status !== prev.status || - row.kind !== prev.kind - ); - }); - bag.chrome.agents = next; - } - - const taskRowCount = bag.chrome.task.length; - const agentsRowCount = bag.chrome.agents.length; - - // Rebuilding N TextRenderable children is real node churn; skip it unless - // the panel's actual lines changed (not every push carries new data). - if (taskChanged) { - renderTasksRows(shell, bag.chrome.task, shell.layout.contentWidth); - } - // Only a zone appearing/disappearing or its row count changing alters the - // row budget; retitling a zone whose row count is unchanged must not - // re-resolve and re-apply the whole layout. - const budgetUnchanged = - taskRowCount === bag.visibility.task && agentsRowCount === bag.visibility.agents; - if (!budgetUnchanged) { - relayout(shell, { - visibility: { - ...bag.visibility, - task: taskRowCount, - agents: agentsRowCount, - }, - overlayMode: bag.overlayMode, - ...(bag.overlayBodyRows !== undefined ? { overlayBodyRows: bag.overlayBodyRows } : {}), - }); - } - - // Painted after the resolver has spoken, and only ever as many rows as it - // granted: a board that paints past its box lands on top of the transcript - // and tears down the renderables underneath it. Full content width (stack). - if (agentsChanged || !budgetUnchanged) { - renderAgentsRows( - shell, - clampBoardRows(bag.chrome.agents, shell.layout.heights.agents), - shell.layout.contentWidth, - ); - } - if (budgetUnchanged) paintChrome(shell); -} - -/** How long a panel-visibility flash holds the notice row. */ -const PANEL_TOGGLE_FLASH_MS = 3000; - -/** - * Toggle the task-list panel visible/hidden without touching the live task - * data underneath it — un-hiding shows whatever manage_tasks last wrote, - * not a stale snapshot from before the hide. The flag lives on the shell's - * internals in memory for the shell's lifetime; nothing is written to - * storage, so it does not survive a restart. - */ -export function toggleTasksPanel(shell: AppShell): void { - const bag = internals.get(shell); - if (!bag) return; - bag.tasksPanelHidden = !bag.tasksPanelHidden; - const hiding = bag.tasksPanelHidden; - setChromeZones(shell, { task: bag.chrome.tasksRaw }); - // A flash, not a transcript row: which panels are showing is a property of - // the current screen, not something that happened in the conversation. - setStatusFlash(shell, hiding ? "task list hidden · alt+t to show" : "task list shown", { - ttlMs: PANEL_TOGGLE_FLASH_MS, - }); -} - -/** - * Enter copy mode (Alt+C / palette copy_active): freeze targets from the - * active streamLog, open inset overlay with the last target selected. - * Empty log → status flash only; no stream mutation. - */ -export function enterCopyMode(shell: AppShell): boolean { - // Single host: do not stack copy over another primary overlay. - if (shell.overlayList) return false; - - const targets = buildCopyTargets(shell.streamLog); - if (targets.length === 0) { - setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); - return false; - } - - shell.copyTargets = targets; - const labels = targets.map((t) => `${t.label}: ${t.preview}`); - openListOverlay(shell, { - kind: "copy", - title: "copy · Enter copies the selected item", - items: labels, - activeIndex: targets.length - 1, - frameId: "copy-mode", - }); - return true; -} - -/** Write the frozen target at the active list index; status flash only. */ -export function confirmCopySelection(shell: AppShell): boolean { - const targets = shell.copyTargets; - if (!targets || targets.length === 0 || !shell.overlayList) { - setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); - closeInsetOverlay(shell); - return false; - } - const idx = Math.max(0, Math.min(targets.length - 1, shell.overlayList.activeIndex)); - const target = targets[idx]; - if (!target) { - setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); - closeInsetOverlay(shell); - return false; - } - const preview = - target.text.length > 48 ? `${target.text.slice(0, 45).replace(/\s+/g, " ")}…` : target.text; - writeClipboard(shell.clipboard, target.text, { - onSuccess: () => { - setStatusFlash(shell, `Copied ${target.label} (${target.text.length} chars): ${preview}`, { - ttlMs: RUNTIME_FLASH_MS, - }); - }, - onFailure: () => { - setStatusFlash(shell, "Copy failed", { ttlMs: RUNTIME_FLASH_MS }); - }, - }); - closeInsetOverlay(shell); - return true; -} - -/** Copy all frozen targets as markdown; status flash only. */ -export function copyAllTargets(shell: AppShell): boolean { - const targets = shell.copyTargets; - if (!targets || targets.length === 0) { - setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); - if (shell.overlayKind === "copy") closeInsetOverlay(shell); - return false; - } - const text = streamLogMarkdown(targets); - writeClipboard(shell.clipboard, text, { - onSuccess: () => { - setStatusFlash(shell, `Copied all (${targets.length} items, ${text.length} chars)`, { - ttlMs: RUNTIME_FLASH_MS, - }); - }, - onFailure: () => { - setStatusFlash(shell, "Copy failed", { ttlMs: RUNTIME_FLASH_MS }); - }, - }); - closeInsetOverlay(shell); - return true; -} - -/** - * Alt+M: take DEC mouse reporting, or hand it back to the terminal. - * Reporting is on by default so wheel scroll and click-to-expand work; - * releasing it restores the terminal's own drag-select and copy. - * Returns the new enabled state, or null when the host exposes no control. - */ -export function toggleMouseCapture(shell: AppShell): boolean | null { - const port = shell.mouseCapture; - if (!port) { - setStatusFlash(shell, "mouse reporting is not controllable here", { - ttlMs: RUNTIME_FLASH_MS, - }); - return null; - } - const next = !port.get(); - port.set(next); - setStatusFlash( - shell, - next - ? "Mouse captured · drag text to copy · click to expand · Alt+M for native select" - : "Mouse released · drag to select and copy as usual · Alt+M to click rows", - { ttlMs: RUNTIME_FLASH_MS }, - ); - return next; -} - -/** - * Enter a child subagent session view. - * Host passes live rows + agent label (`ObserveSession`); fixture via - * `makeObserveFixture()` is only for demo/tests. Esc restores parent lease. - */ -export function enterSubagentObserve(shell: AppShell, session: ObserveSession): void { - if (shell.observe) { - leaveSubagentObserve(shell); - } - - const seedLines = session.lines.slice(); - shell.parentStreamLog = shell.streamLog.slice(); - shell.parentStreamLogBase = shell.streamLogBase; - shell.observe = { - sessionId: session.sessionId, - agentId: session.agentId, - description: session.description, - lines: seedLines.slice(), - }; - - // A fresh log for the child view; its own indices start at zero regardless - // of how far the parent's retention cap has already trimmed. - shell.streamLog = seedLines; - shell.streamLogBase = 0; - shell.lineCount = shell.streamLog.length; - repaintTranscriptWindow(shell); - - shell.focus = openObserve(shell.focus, `observe-${session.sessionId}`); - setChromeZones(shell, { - agents: [ - { - label: `observe: ${session.agentId} — ${session.description}`, - tail: "", - stalled: false, - }, - ], - }); - // Child chrome toast — must not route to parent snapshot. - appendObserveStreamRow(shell, { - role: "system", - text: `Viewing ${session.agentId}: ${session.description}`, - meta: "observe", - }); - applyFocus(shell); -} - -/** Leave observe; restore parent stream + focus lease. */ -export function leaveSubagentObserve(shell: AppShell): void { - if (!shell.observe) return; - - const agentId = shell.observe.agentId; - shell.observe = null; - - if (shell.parentStreamLog) { - shell.streamLog = shell.parentStreamLog; - shell.streamLogBase = shell.parentStreamLogBase ?? 0; - shell.parentStreamLog = null; - shell.parentStreamLogBase = null; - } - shell.lineCount = shell.streamLog.length; - repaintTranscriptWindow(shell); - - let guard = 4; - while (guard-- > 0 && focusOwner(shell.focus) === "observe") { - shell.focus = popFocus(shell.focus); - } - // Drop any observe frames that weren't top. - const frames = shell.focus.frames.filter((f) => f.target !== "observe"); - if (frames.length > 0) shell.focus = { frames }; - - setChromeZones(shell, { agents: null }); - appendStreamRow(shell, { - role: "system", - text: `left observe (${agentId})`, - meta: "observe", - }); - applyFocus(shell); -} - -/** - * Alt+O: observe a live subagent (its only entry point now that the palette - * is gone — the palette's "observe" action used to call this same - * `onObserveRequest` host hook). An honest "nothing to observe" flash rather - * than doing nothing when there is no live session, so the chord is - * discoverable as working even when it currently has nothing to show. - */ -export function observeActiveSubagent(shell: AppShell): void { - const onObserveRequest = getPaletteOnObserveRequest(shell); - const session = onObserveRequest ? onObserveRequest() : null; - if (session) { - enterSubagentObserve(shell, session); - return; - } - appendStreamRow(shell, { - role: "system", - text: "no subagent session to observe", - meta: "observe", - }); -} - -/** - * Host-injected residual list open. `items` is owned by the caller — there is - * no fallback, so a missing dependency must produce an honest empty state or - * a surfaced error upstream rather than reach this with nothing to show. - * Per-open `onAccept` wins over shell-level residual hooks for that open. - */ -export interface OpenResidualListOpts { - readonly items: readonly string[]; - /** Stable ids aligned with `items` (setting keys, session ids, paths). */ - readonly itemIds?: readonly string[]; - /** Plain chosen value aligned with `items`, for the accept echo (see `OpenListOverlayOpts.itemValues`). */ - readonly itemValues?: readonly (string | undefined)[]; - readonly activeIndex?: number; - /** Per-open accept; host binds toggle / resume / mention insert. */ - readonly onAccept?: (selection: OverlaySelection) => void; - /** Per-open ← → cycle hook (settings inline value cycling). */ - readonly onCycle?: (itemId: string, direction: -1 | 1) => void; - /** Per-open description-zone source. */ - readonly describe?: (itemId: string) => ItemDescription | null; -} - -export function openSettingsOverlay(shell: AppShell, opts: OpenResidualListOpts): void { - openListOverlay(shell, { - kind: "settings", - title: "settings", - items: opts.items, - activeIndex: opts.activeIndex ?? 0, - frameId: "overlay-settings", - deferIfBusy: true, - ...(opts.itemIds !== undefined ? { itemIds: opts.itemIds } : {}), - ...(opts.itemValues !== undefined ? { itemValues: opts.itemValues } : {}), - ...(opts.onAccept !== undefined ? { onAccept: opts.onAccept } : {}), - ...(opts.onCycle !== undefined ? { onCycle: opts.onCycle } : {}), - ...(opts.describe !== undefined ? { describe: opts.describe } : {}), - }); -} - -export function openHelpOverlay(shell: AppShell): void { - const release = reserveOverlayHost(shell); - try { - closeReplaceableOverlay(shell); - openListOverlay(shell, { - kind: "help", - title: "help · keymap", - items: helpItems(), - activeIndex: 0, - frameId: "overlay-help", - deferIfBusy: true, - }); - } finally { - release(); - } -} - -export function openMentionsOverlay(shell: AppShell, opts: OpenResidualListOpts): void { - openListOverlay(shell, { - kind: "mentions", - title: "mentions", - items: opts.items, - activeIndex: opts.activeIndex ?? 0, - frameId: "overlay-mentions", - ...(opts.itemIds !== undefined ? { itemIds: opts.itemIds } : {}), - ...(opts.onAccept !== undefined ? { onAccept: opts.onAccept } : {}), - }); -} - -/** Keys that only move the caret — they must not cancel history browsing. */ -const MOTION_KEYS: ReadonlySet = new Set([ - "up", - "down", - "left", - "right", - "home", - "end", - "pageup", - "pagedown", - "tab", - "escape", -]); - -const defaultMentionSource: MentionSuggestionSource = (prefix) => - listPathSuggestions(prefix, process.cwd()); - -interface MentionAcceptState { - readonly suggestions: readonly string[]; - readonly generation: number; - readonly atStart: number; -} - -/** - * Open path suggestions for the @token under the cursor and splice the - * accepted entry back into the prompt. Directory picks re-open one level - * down so the operator can drill in without typing the path. - * Returns false when the cursor is not inside an @token, nothing matched, - * a newer lookup superseded this one, or the overlay host was taken. - * - * Accept requires a current generation and a live `@` token under the cursor. - * A lookup that finishes after the cursor has left this token does not open. - */ -export async function openAtMentionSuggestions(shell: AppShell): Promise { - const at = parseAtState(shell.prompt.value, shell.prompt.cursorOffset); - if (at === null) { - closeMentionPopup(shell); - return false; - } - - // Every keystroke re-queries; a slower earlier query must not overwrite the - // list a later one already produced. - const generation = (mentionGenerations.get(shell) ?? 0) + 1; - mentionGenerations.set(shell, generation); - - const source = shellMentionSource.get(shell) ?? defaultMentionSource; - const token = splitMentionToken(at.prefix); - let suggestions = filterMentionSuggestions(await source(token.dir), token.fragment); - // Quitting mid-lookup tears down the renderer/TextBuffer this function - // writes into below; a resolved-but-stale lookup must not touch them. - if (shell.disposed) return false; - // The source caps how many entries it returns per directory, so a large - // directory can cap out before the interior match appears. Asking it to do - // its own prefix filter puts that cap after the narrowing instead of before. - if (suggestions.length === 0 && token.fragment.length > 0) { - suggestions = await source(at.prefix); - if (shell.disposed) return false; - } - if (mentionGenerations.get(shell) !== generation) return false; - - if (suggestions.length === 0) { - // Mirrors `/`'s no-match contract: close the popup and leave the typed - // text standing, with no empty-state message. - closeMentionPopup(shell); - return false; - } - - // The operator may have left this token while the lookup was in flight. - // Do not open, and do not arm accept, unless the cursor is still on this @ - // (same atStart). A different live @token is not this lookup. - const liveAt = parseAtState(shell.prompt.value, shell.prompt.cursorOffset); - if (liveAt === null || liveAt.atStart !== at.atStart) { - closeMentionPopup(shell); - return false; - } - - // The onAccept closure reads mentionAcceptState rather than closing over - // `suggestions` directly, so a same-session refresh can update what accept - // splices without re-binding the callback. atStart is the @ this lookup - // started on; the splice end is the live cursor. - const acceptState: MentionAcceptState = { - suggestions, - generation, - atStart: at.atStart, - }; - - // Every keystroke lands here while the popup is already open. Closing and - // reopening the overlay released the host between the two calls — long - // enough for a queued permission/operator gate to open on it — and left the - // gate's overlay on screen while `mentionPopups` still claimed ownership. - // Refreshing the open list in place never releases the host, so a queued - // gate has nothing to drain into. - if (isMentionPopupOpen(shell)) { - mentionAcceptState.set(shell, acceptState); - setOverlayItems(shell, [...suggestions]); - return true; - } - - closeMentionPopup(shell); - openMentionsOverlay(shell, { - items: [...suggestions], - onAccept: (selection) => { - const ready = liveMentionAccept(shell); - if (ready === null) return; - const completion = ready.state.suggestions[selection.index]; - if (completion === undefined) return; - const spliced = spliceMentionCompletion( - shell.prompt.value, - ready.live.atStart, - shell.prompt.cursorOffset, - completion, - ); - editPromptAt(shell, spliced.value, spliced.cursor); - if (completion.endsWith("/")) void openAtMentionSuggestions(shell); - }, - }); - if (shell.overlayKind !== "mentions") return false; - mentionAcceptState.set(shell, acceptState); - mentionPopups.add(shell); - return true; -} - -const mentionPopups = new WeakSet(); -const mentionGenerations = new WeakMap(); -const mentionAcceptState = new WeakMap(); - -/** Drop accept state and invalidate in-flight lookups on operator dismiss. */ -function clearMentionAccept(shell: AppShell): void { - mentionAcceptState.delete(shell); - mentionGenerations.set(shell, (mentionGenerations.get(shell) ?? 0) + 1); -} - -/** Live accept snapshot, or null when there is no state, generation is stale, or the cursor left this @. */ -function liveMentionAccept(shell: AppShell): { state: MentionAcceptState; live: AtState } | null { - const state = mentionAcceptState.get(shell); - if (state === undefined) return null; - if (mentionGenerations.get(shell) !== state.generation) return null; - const live = parseAtState(shell.prompt.value, shell.prompt.cursorOffset); - if (live === null || live.atStart !== state.atStart) return null; - return { state, live }; -} - -/** True while the `@` path popup owns typed characters. */ -export function isMentionPopupOpen(shell: AppShell): boolean { - return mentionPopups.has(shell) && shell.overlayKind === "mentions"; -} - -export function closeMentionPopup(shell: AppShell): void { - if (!mentionPopups.has(shell)) return; - clearMentionAccept(shell); - mentionPopups.delete(shell); - if (shell.overlayList) closeInsetOverlay(shell); -} - -function editPromptAt(shell: AppShell, value: string, cursor: number): void { - shell.prompt.value = value; - shell.prompt.cursorOffset = cursor; - shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); -} - -/** - * Keys the `@` popup claims while open — the same contract as the `/` popup: - * printable characters narrow the list, Backspace widens it, and a query that - * matches nothing closes the popup with the typed text left in place. - * - * The prompt does not hold focus while the overlay is open, so this inserts and - * deletes the characters itself rather than letting the InputRenderable do it. - */ -export function handleMentionPopupKey(shell: AppShell, key: KeyEvent): boolean { - if (!isMentionPopupOpen(shell) || shell.overlayList === null) return false; - if (key.ctrl || key.meta || key.option) return false; - - const value = shell.prompt.value; - const cursor = shell.prompt.cursorOffset; - - if (key.name === "backspace") { - if (cursor === 0) { - closeMentionPopup(shell); - return true; - } - editPromptAt(shell, value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1); - // Deleting the `@` itself ends the mention; there is nothing left to filter. - if (value[cursor - 1] === "@") closeMentionPopup(shell); - else void openAtMentionSuggestions(shell); - return true; - } - - const seq = typeof key.sequence === "string" ? key.sequence : ""; - if (seq.length !== 1 || seq < " ") return false; - - editPromptAt(shell, value.slice(0, cursor) + seq + value.slice(cursor), cursor + 1); - // Whitespace terminates the @token, so the popup has nothing left to narrow. - if (/\s/.test(seq)) closeMentionPopup(shell); - else void openAtMentionSuggestions(shell); - return true; -} - -const slashPopups = new WeakSet(); - -/** True while the `/` command popup owns typed characters. */ -export function isSlashPopupOpen(shell: AppShell): boolean { - return slashPopups.has(shell) && shell.overlayList !== null; -} - -/** - * Popup query = prompt text after the leading `/`. Null once the operator has - * typed whitespace: at that point the name is settled and the rest is arguments. - */ -function slashPopupQuery(shell: AppShell): string | null { - const value = shell.prompt.value; - if (!value.startsWith("/")) return null; - const head = value.slice(1); - return /\s/.test(head) ? null : head; -} - -export function closeSlashPopup(shell: AppShell): void { - if (!slashPopups.has(shell)) return; - slashPopups.delete(shell); - if (shell.overlayList) closeInsetOverlay(shell); -} - -/** - * Open (or refresh) the `/` command popup for the name being typed. Reuses the - * palette overlay so accept dispatches through the same registry path as a - * typed `/name`. Returns false when nothing matches — the typed text stays. - */ -export function openSlashCommands(shell: AppShell): boolean { - const query = slashPopupQuery(shell); - if (query === null) { - closeSlashPopup(shell); - return false; - } - // Name-prefix, not the palette's fuzzy label match: at the prompt the - // operator is typing the command they already mean. - const q = query.toLowerCase(); - const matches = resolvePaletteCatalog(shell).filter((cmd) => cmd.id.toLowerCase().startsWith(q)); - - // Every keystroke lands here while the popup is already open. Closing and - // reopening released the overlay host between the two calls (closeSlashPopup - // routes through closeInsetOverlay, which idle-notifies) — long enough for a - // queued permission/operator gate to drain onto it. Refreshing the open - // palette in place never releases the host, so a queued gate has nothing to - // drain into. priorOverlay stacking is untouched here (it is only ever - // written by openListOverlay's stack-on-open path), so a palette stacked - // over a prior overlay keeps that snapshot across the refresh. - // - // A typo that zeroes the matches must not fall through to closeSlashPopup - // while the popup is already open — that closes through the same idle-notify - // path and drains a queued gate mid-filter. Instead this refreshes in place - // to a "(no matches)" row, same as the general palette does, and holds the - // host until a real dismiss (deleting the `/`, Esc, accept) or a backspace - // that restores matches. - if (isSlashPopupOpen(shell) && shell.overlayKind === "palette") { - refreshSlashPopupInPlace(shell, matches); - return true; - } - - if (matches.length === 0) { - closeSlashPopup(shell); - return false; - } - - closeSlashPopup(shell); - openPalette(shell, { catalog: matches, title: "commands · /" }); - slashPopups.add(shell); - return true; -} - -/** Refresh the already-open `/` popup's rows in place for the given matches. */ -function refreshSlashPopupInPlace(shell: AppShell, matches: readonly PaletteCommand[]): void { - const labels = matches.length > 0 ? paletteLabels(matches) : ["(no matches)"]; - shell.paletteCommands = matches; - const bag = internals.get(shell); - if (bag) { - bag.paletteFilter = { - query: bag.paletteFilter?.query ?? "", - title: "commands · /", - catalog: matches, - typeToFilter: false, - }; - bag.primaryBindings.describe = (id) => { - const cmd = matches.find((c) => c.id === id); - const what = cmd?.description?.trim(); - return what ? { what } : null; - }; - } - setOverlayItems( - shell, - labels, - matches.map((c) => c.id), - undefined, - { - resetActive: true, - }, - ); - relayoutOverlayHost(shell, labels.length); -} - -function setPromptText(shell: AppShell, value: string): void { - shell.prompt.value = value; - shell.prompt.cursorOffset = value.length; - shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); -} - -/** - * Keys the `/` popup claims while open. Returns true when handled. - * - * Enter runs the highlighted command with no arguments; Tab instead completes - * the name and leaves the popup so arguments can be typed — a command that - * needs arguments should not fire bare just because its name matched. - */ -export function handleSlashPopupKey(shell: AppShell, key: KeyEvent): boolean { - if (!isSlashPopupOpen(shell) || shell.overlayList === null) return false; - - if (key.name === "backspace" && !key.ctrl && !key.meta && !key.option) { - setPromptText(shell, shell.prompt.value.slice(0, -1)); - openSlashCommands(shell); - return true; - } - - const active = shell.paletteCommands[shell.overlayList.activeIndex]; - - if (key.name === "tab" && !key.shift && !key.ctrl && !key.meta && !key.option) { - if (active) setPromptText(shell, `/${active.id} `); - closeSlashPopup(shell); - return true; - } - - if ((key.name === "return" || key.name === "enter") && !key.ctrl && !key.meta && !key.option) { - // Genuine dismiss (zero matches) still notifies immediately so a queued - // gate can drain. Accept-with-match keeps the host until dispatch settles. - if (!active) { - closeSlashPopup(shell); - return true; - } - setPromptText(shell, ""); - slashPopups.delete(shell); - const release = reserveOverlayHost(shell); - closeInsetOverlay(shell); - try { - dispatchPaletteSelection(shell, active); - } finally { - release(); - } - return true; - } - - const seq = typeof key.sequence === "string" ? key.sequence : ""; - const printable = - seq.length === 1 && seq >= " " && seq !== "" && !key.ctrl && !key.meta && !key.option; - if (!printable) return false; - - setPromptText(shell, shell.prompt.value + seq); - // Whitespace ends the name; keep the popup out of the way while args are typed. - if (/\s/.test(seq)) closeSlashPopup(shell); - else openSlashCommands(shell); - return true; -} - -/** Window in which a second Ctrl+C is read as "yes, quit". */ -export const CTRL_C_EXIT_WINDOW_MS = 2000; - -const ctrlCArmedAt = new WeakMap(); - -/** - * Ctrl+C: interrupt / clear, and quit on a second press inside the window. - * The double press replaces the old Ink y/n exit confirm — same intent (an - * explicit second confirmation), no modal. Quitting routes through the - * registered exit handler so host finalize still runs. - */ -export function handleCtrlC(shell: AppShell, now = Date.now(), options?: FlashOptions): void { - const armedAt = ctrlCArmedAt.get(shell); - if (armedAt !== undefined && now - armedAt <= CTRL_C_EXIT_WINDOW_MS) { - ctrlCArmedAt.delete(shell); - const onExit = shellExitHandlers.get(shell); - if (onExit !== undefined) { - // Host teardown usually disposes; unlink here too so a stub/delayed - // onExit cannot leave Corbits-created clipboard files behind. - clearPendingAttachments(shell); - onExit(); - return; - } - } - - const idle = shell.session.run !== "busy" && badgeCount(shell.session) === 0; - const hasPromptText = shell.prompt.value.length > 0; - const hasAttachments = shell.pendingAttachments.length > 0; - if (idle && (hasPromptText || hasAttachments)) { - shell.prompt.value = ""; - clearPendingAttachments(shell); - if (!hasPromptText) return; - } - - ctrlCArmedAt.set(shell, now); - - if (shell.session.run === "busy" || badgeCount(shell.session) > 0) { - interruptShell(shell); - } - // The notice is exactly as true as the arming window is open, so it expires - // with it rather than waiting for some later flash to overwrite it. - setStatusFlash(shell, "press ctrl+c again to exit", { - ttlMs: CTRL_C_EXIT_WINDOW_MS, - ...(options?.schedule !== undefined ? { schedule: options.schedule } : {}), - }); -} - -/** - * Wheel/trackpad scroll landing on the prompt scrolls the chat instead. - * - * The prompt textarea is an editable buffer with its own `scrollY`, so - * OpenTUI's default routing — whichever renderable the wheel event hits, or - * the focused renderable when the hit misses — happily scrolls the prompt's - * own (usually one-screen, nothing-to-scroll) content. The prompt also holds - * keyboard focus for the whole session, so it is the fallback target for any - * wheel event that lands off the transcript's hit-tested rows. Overriding the - * scroll case here — rather than teaching the transcript's own scroll lease - * about wheel events — keeps the fix to exactly where wheel input actually - * arrives, without touching transcript viewport internals. - */ -function routePromptWheelToTranscript( - prompt: BaseRenderable, - transcript: ScrollBoxRenderable, -): void { - (prompt as unknown as { onMouseEvent: (event: MouseEvent) => void }).onMouseEvent = ( - event: MouseEvent, - ) => { - if (event.type !== "scroll") return; - (transcript as unknown as { onMouseEvent: (event: MouseEvent) => void }).onMouseEvent(event); - }; -} - -/** - * Build the app shell frame on an OpenTUI renderer. - * Mounts sticky transcript / overlay host / transient notice / prompt box. - */ -export function createAppShell(renderer: ShellRenderer, options?: AppShellOptions): AppShell { - const title = options?.title ?? DEFAULT_TITLE; - const visibility = defaultVisibility(options?.visibility); - const promptContentRows = options?.promptContentRows ?? PROMPT_IDLE_ROWS; - const wireKeys = options?.wireKeys !== false; - const mount = options?.mount !== false; - // A freshly mounted shell has nothing in flight; the runner sets busy when a - // turn starts. Defaulting to busy made the landing screen offer "^C stop". - const run = options?.run ?? "idle"; - const overlayItems = options?.overlayItems ?? [...DEFAULT_OVERLAY_ITEMS]; - const paletteCatalogOpt = options?.paletteCatalog ?? null; - const onCommandOpt = options?.onCommand; - const onObserveRequestOpt = options?.onObserveRequest; - const reducedMotion = options?.reducedMotion === true; - - const terminal = terminalOf(renderer, options?.terminal); - const layout = resolveGeometry({ - terminal: terminalForGeometry(terminal), - visibility, - overlay: { mode: "closed" }, - promptContentRows, - }); - - const ctx = renderer as CliRenderer; - - const root = new BoxRenderable(ctx, { - id: "app-shell", - width: "100%", - height: "100%", - flexDirection: "column", - backgroundColor: UI.ground, - paddingLeft: layout.sideMargin, - paddingRight: layout.sideMargin, - }); - - // One optical gutter for the whole shell: every zone is a child of the padded - // root, so nothing can drift out of alignment with the rest. - const topPad = new BoxRenderable(ctx, { - id: "shell-top-pad", - width: "100%", - height: 1, - flexShrink: 0, - backgroundColor: UI.ground, - }); - - // Same gutter, other end: keeps the prompt box off the terminal's last row. - const bottomPad = new BoxRenderable(ctx, { - id: "shell-bottom-pad", - width: "100%", - height: 1, - flexShrink: 0, - backgroundColor: UI.ground, - }); - - // Persistent chrome, not part of the landing composition (`landing.ts` - // never renders it, unlike the old in-hero version line): its own row at - // the very foot of root's column, after everything else, right-aligned. - // Every other zone here already toggles a reserved row on/off by terminal - // size (taskBox, agentsBox, bottomPad) rather than floating over content, - // so this follows the same pattern — the row only exists (and can only - // move the prompt box up by exactly one line) at the size threshold where - // `versionBadgeVisible` already says the badge itself should degrade away, - // well before anything else in the shell would need to. - const versionRow = new BoxRenderable(ctx, { - id: "shell-version-row", - width: "100%", - height: 1, - flexShrink: 0, - flexDirection: "row", - justifyContent: "flex-end", - backgroundColor: UI.ground, - visible: versionBadgeVisible(terminal.columns, terminal.rows), - }); - const versionBadge = new TextRenderable(ctx, { - id: "shell-version-badge", - content: LANDING_VERSION, - fg: UI.textFaint, - }); - versionRow.add(versionBadge); - - // Optional chrome zones (off by default; setChromeZones turns them on). - const taskBox = new BoxRenderable(ctx, { - id: "shell-task", - width: "100%", - height: 1, - flexShrink: 0, - flexDirection: "column", - backgroundColor: UI.ground, - visible: false, - }); - - const agentsBox = new BoxRenderable(ctx, { - id: "shell-agents", - width: "100%", - height: 1, - flexShrink: 0, - flexDirection: "column", - backgroundColor: UI.ground, - visible: false, - }); - - const transcript = new ScrollBoxRenderable(ctx, { - id: "shell-transcript", - width: "100%", - height: Math.max(1, layout.heights.transcript), - flexShrink: 0, - stickyScroll: true, - stickyStart: "bottom", - scrollY: true, - focusable: true, - rootOptions: { backgroundColor: UI.ground }, - contentOptions: { backgroundColor: UI.ground }, - viewportOptions: { backgroundColor: UI.ground }, - }); - // The transcript scrolls with the keyboard, and the bar spent a column on - // every row to say so. Position is legible from the content itself. - transcript.verticalScrollBar.visible = false; - transcript.horizontalScrollBar.visible = false; - - // Leading filler that bottom-anchors a short transcript; see - // `syncTranscriptSpacer`. Zero height until the first sync call. - const transcriptSpacer = new BoxRenderable(ctx, { - id: "shell-transcript-spacer", - width: "100%", - height: 0, - flexShrink: 0, - backgroundColor: UI.ground, - }); - transcript.add(transcriptSpacer); - - const landingAbove = createLandingAbove(ctx, reducedMotion); - const landingBelowState = landingBelowContent({ - rows: splitLandingRows(layout.heights.transcript).below, - columns: layout.contentWidth, - telemetryNotice: options?.telemetryNotice, - }); - const landingBelow = createLandingBelow(ctx, landingBelowState); - - const overlayView = createOverlayView(ctx); - const { host: overlayHost, title: overlayTitle, body: overlayBody } = overlayView; - - // Transient only: the resolver gives it a row when paintChrome asks for one. - const notice = new TextRenderable(ctx, { - id: "shell-notice", - height: Math.max(1, layout.heights.notice), - content: "", - fg: UI.textDim, - visible: layout.heights.notice > 0, - }); - - const promptBox = new BoxRenderable(ctx, { - id: "shell-prompt-region", - width: "100%", - height: Math.max(1, layout.heights.prompt), - flexShrink: 0, - flexDirection: "column", - backgroundColor: UI.ground, - }); - // The box is drawn in three pieces rather than as one bordered Box because - // both horizontal rules carry content the frame's own border cannot: a - // right-aligned label that the rule breaks around, and an animated lockup - // whose cells are individually coloured. - const promptTopRule = new TextRenderable(ctx, { - id: "shell-prompt-top-rule", - height: 1, - content: "", - fg: UI.textFaint, - }); - const promptBottomRule = new TextRenderable(ctx, { - id: "shell-prompt-bottom-rule", - height: 1, - content: "", - fg: UI.textFaint, - }); - const promptField = new BoxRenderable(ctx, { - id: "shell-prompt-frame", - width: "100%", - height: Math.max(1, layout.heights.prompt - 2), - flexShrink: 0, - border: ["left", "right"], - borderStyle: "rounded", - borderColor: UI.textFaint, - focusedBorderColor: UI.textDim, - backgroundColor: UI.ground, - paddingLeft: 1, - paddingRight: 1, - }); - const prompt = createPromptInput(ctx, { - id: "shell-prompt", - width: "100%", - height: Math.max(1, layout.heights.prompt - 2), - placeholder: "message…", - backgroundColor: UI.ground, - focusedBackgroundColor: UI.ground, - textColor: UI.text, - cursorColor: UI.text, - placeholderColor: UI.textFaint, - }); - routePromptWheelToTranscript(prompt, transcript); - promptField.add(prompt); - promptBox.add(promptTopRule); - promptBox.add(promptField); - promptBox.add(promptBottomRule); - - root.add(topPad); - root.add(transcript); - root.add(overlayHost); - root.add(agentsBox); - root.add(taskBox); - root.add(notice); - root.add(promptBox); - root.add(landingBelow); - root.add(bottomPad); - root.add(versionRow); - - if (mount) { - renderer.root.add(root); - } - - let disposed = false; - let session = createSessionQueue(run); - const seedPending = Math.max(0, Math.floor(options?.pendingQueue ?? 0)); - for (let i = 0; i < seedPending; i++) { - session = enqueue(session, `seed-${i + 1}`); - } - - // A real bracketed-paste event proves this terminal negotiates DEC 2004: - // every paste from here on arrives as one `paste` event, never as raw - // keystrokes, so the CRLF-submit fallback below has nothing left to guard - // against and turns itself off for the rest of the session. Terminals that - // never send one keep the guard, since they've never shown they can do - // better. Un-bracketed-paste bookkeeping only this key handler reads, so it - // lives in this closure rather than on the shared AppShell. - let sawBracketedPaste = false; - let lastKeyAt = 0; - let lastKeyWasPrintable = false; - let suppressNextLinefeed = false; - const onPaste = (event: { bytes: Uint8Array; preventDefault: () => void }): void => { - if (disposed) return; - const bag = internals.get(shell); - if (bag?.inputSuspended === true) return; - sawBracketedPaste = true; - if (shell.overlayList !== null && bag?.primaryBindings.onPaste) { - event.preventDefault(); - bag.primaryBindings.onPaste(new TextDecoder().decode(event.bytes)); - } - }; - - const onKey = (key: KeyEvent): void => { - if (disposed) return; - if (internals.get(shell)?.inputSuspended === true) return; - - if (key.name === "escape") { - if (exitOverlayAnswerMode(shell)) { - key.preventDefault(); - return; - } - if (shell.overlayList) { - key.preventDefault(); - abortOverlayHostReservations(shell); - closeInsetOverlay(shell); - return; - } - if (internals.get(shell)?.overlayHostReservations) { - abortOverlayHostReservations(shell); - key.preventDefault(); - // Next tick so the same Esc cannot also dismiss a gate this abort drains. - queueMicrotask(() => notifyOverlayClosed(shell)); - return; - } - if (shell.observe) { - key.preventDefault(); - leaveSubagentObserve(shell); - return; - } - // Transcript browse (entered with Tab) is the remaining poppable frame: - // Esc hands typing back to the prompt. - if (canPopFocus(shell.focus)) { - key.preventDefault(); - shell.focus = popFocus(shell.focus); - applyFocus(shell); - return; - } - } - - // Landing starters. Only while the prompt is untouched, so the digit goes - // back to being a digit the moment the operator starts typing. - if ( - shell.overlayList === null && - !key.ctrl && - !key.meta && - !key.option && - typeof key.name === "string" && - applyLandingSuggestion(shell, key.name) - ) { - key.preventDefault(); - return; - } - - if (shell.overlayList) { - // Checked ahead of the filter handlers: an opener chord pressed again is - // a request to close, not a character to narrow the list with. - if (toggleCloseOpenSurface(shell, key)) { - key.preventDefault(); - return; - } - // The `/` popup filters as you type, so it claims printable keys before - // the overlay's j/k navigation can swallow them. - if (handleSlashPopupKey(shell, key)) { - key.preventDefault(); - return; - } - // Same reason as the `/` popup: the `@` popup narrows as you type, so it - // claims printable keys ahead of the overlay's j/k navigation. - if (handleMentionPopupKey(shell, key)) { - key.preventDefault(); - return; - } - // A live answer field owns every printable key, so an operator typing a - // free-form answer is not navigating the choice list instead. - if (handleOverlayAnswerKey(shell, key)) { - key.preventDefault(); - return; - } - // Type-to-filter overlays (palette, model picker) claim printables — - // including j/k that non-filter overlays still use to navigate. - if (handlePaletteFilterKey(shell, key)) { - key.preventDefault(); - return; - } - // Same opt-in for list overlays (model picker): type-to-filter claims - // printables so a long flat catalog narrows without a nested pane. - if (handleListFilterKey(shell, key)) { - key.preventDefault(); - return; - } - // Per-overlay bare-key owners (including text panes) get first refusal. - // Ordinary lists return false here, preserving j/k navigation below. - if (runOverlayAction(shell, key)) { - key.preventDefault(); - return; - } - if (key.name === "up" || key.name === "k") { - key.preventDefault(); - moveOverlaySelection(shell, -1); - return; - } - if (key.name === "down" || key.name === "j") { - key.preventDefault(); - moveOverlaySelection(shell, 1); - return; - } - // Left/Right only mean something to an overlay that opted into cycling - // (settings). Everywhere else they fall through unclaimed. - if ( - (key.name === "left" || key.name === "right") && - !key.ctrl && - !key.meta && - !key.option && - cycleOverlaySelection(shell, key.name === "left" ? -1 : 1) - ) { - key.preventDefault(); - return; - } - if (key.name === "pageup") { - key.preventDefault(); - pageOverlaySelection(shell, -1); - return; - } - if (key.name === "pagedown") { - key.preventDefault(); - pageOverlaySelection(shell, 1); - return; - } - if ( - key.name === OVERLAY_EXPAND_KEY && - !key.ctrl && - !key.meta && - !key.option && - toggleOverlayExpand(shell) - ) { - key.preventDefault(); - return; - } - if (shell.overlayKind === "copy") { - if (key.name === "y" && !key.ctrl && !key.meta && !key.option) { - key.preventDefault(); - confirmCopySelection(shell); - return; - } - if (key.name === "a" && !key.ctrl && !key.meta && !key.option) { - key.preventDefault(); - copyAllTargets(shell); - return; - } - } - if (key.name === "return" || key.name === "enter") { - if (!key.meta && !key.option && !key.ctrl) { - key.preventDefault(); - acceptOverlaySelection(shell); - return; - } - } - return; - } - - // Emacs-style prompt editing: Ctrl+B/F/D, arrow motion, and Alt+B/F word - // motion are already native InputRenderable bindings (see - // defaultTextareaKeyBindings in @opentui/core). What's missing is the - // kill ring — Ctrl+K/U/W and Alt+D delete natively but discard the text; - // Ctrl+Y/Alt+Y need somewhere to yank it back from. - const keyName = typeof key.name === "string" ? key.name.toLowerCase() : ""; - - // Everything below this line is the un-bracketed-paste fallback, and a - // terminal that has ever fired a real `paste` event has proven it never - // needs it: every future paste arrives as one `paste` event, not raw - // keystrokes, so re-running these checks on it would only risk a false - // positive for no benefit. - if (!sawBracketedPaste) { - // The LF half of a CRLF pair the block below just turned into a - // newline: without this, "line one\r\nline two" would insert two - // newlines, one for the converted CR and one for the LF right behind it. - const suppressLinefeed = suppressNextLinefeed; - suppressNextLinefeed = false; - if (suppressLinefeed && keyName === "linefeed" && !key.ctrl && !key.meta && !key.option) { - key.preventDefault(); - return; - } - - // A bare CR is the same "return" that submits. Left alone, pasting - // three lines here sends three separate messages instead of composing - // one. Detecting it needs two signals, not one: a lone fast Enter can - // happen (key rollover, a scripted "send keys"), and a lone printable - // character right before Enter is just typing. What never happens from - // a human is a printable character landing, then Enter, both inside a - // keystroke burst -- that shape is unique to a paste being replayed - // byte-for-byte. Gating on both keeps a deliberate Ctrl+J-then-Enter - // (newline, then send) safe, since Ctrl+J is not "a printable - // character," while still catching "...line oneline two...". - const now = Date.now(); - const sincePreviousKey = now - lastKeyAt; - const previousKeyWasPrintable = lastKeyWasPrintable; - lastKeyAt = now; - lastKeyWasPrintable = isPrintableInsertKey(key); - const isBareReturn = - !key.ctrl && !key.meta && !key.option && (keyName === "return" || keyName === "kpenter"); - if (isBareReturn && previousKeyWasPrintable && sincePreviousKey < PASTE_BURST_MS) { - key.preventDefault(); - shell.prompt.insertText("\n"); - suppressNextLinefeed = true; - return; - } - } - - const isCtrlKillYank = - key.ctrl && - !key.meta && - !key.option && - (keyName === "k" || keyName === "u" || keyName === "w" || keyName === "y"); - const isAltKillYank = - (key.meta || key.option) && !key.ctrl && (keyName === "d" || keyName === "y"); - if (!isCtrlKillYank && !isAltKillYank) { - shell.promptKillRing = breakKillSequence(shell.promptKillRing); - } - - if (key.ctrl && !key.meta && !key.option && keyName === "k") { - key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; - shell.prompt.deleteToLineEnd(); - const killed = killedTextForward(before, beforeCursor, shell.prompt.value); - shell.promptKillRing = recordKill(shell.promptKillRing, killed, "forward"); - return; - } - - if (key.ctrl && !key.meta && !key.option && keyName === "u") { - key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; - shell.prompt.deleteToLineStart(); - const killed = killedTextBackward(before, beforeCursor, shell.prompt.cursorOffset); - shell.promptKillRing = recordKill(shell.promptKillRing, killed, "backward"); - return; - } - - if (key.ctrl && !key.meta && !key.option && keyName === "w") { - key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; - shell.prompt.deleteWordBackward(); - const killed = killedTextBackward(before, beforeCursor, shell.prompt.cursorOffset); - shell.promptKillRing = recordKill(shell.promptKillRing, killed, "backward"); - return; - } - - if ((key.meta || key.option) && !key.ctrl && keyName === "d") { - key.preventDefault(); - const before = shell.prompt.value; - const beforeCursor = shell.prompt.cursorOffset; - shell.prompt.deleteWordForward(); - const killed = killedTextForward(before, beforeCursor, shell.prompt.value); - shell.promptKillRing = recordKill(shell.promptKillRing, killed, "forward"); - return; - } - - if (key.ctrl && !key.meta && !key.option && keyName === "y") { - key.preventDefault(); - const yank = beginYank(shell.promptKillRing, shell.prompt.cursorOffset); - if (yank !== null) { - shell.promptKillRing = yank.ring; - shell.prompt.insertText(yank.text); - } - return; - } - - if ((key.meta || key.option) && !key.ctrl && keyName === "y") { - key.preventDefault(); - const rotated = rotateYank(shell.promptKillRing); - if (rotated !== null && rotated.span.end <= shell.prompt.value.length) { - shell.promptKillRing = rotated.ring; - shell.prompt.setSelection(rotated.span.start, rotated.span.end); - shell.prompt.deleteSelection(); - shell.prompt.cursorOffset = rotated.span.start; - shell.prompt.insertText(rotated.text); - } - return; - } - - // Ctrl+V is a real keypress (0x16), not the system paste: the terminal - // turns CMD+V into bracketed paste, which OpenTUI delivers as its own - // `paste` event and the InputRenderable inserts as text. Binding Ctrl+V - // here therefore cannot swallow an ordinary text paste. - if (key.ctrl && !key.meta && !key.option && (keyName === "p" || keyName === "v")) { - key.preventDefault(); - void attachClipboardImage(shell); - return; - } - - // Typing @ at a token boundary opens path suggestions. The overlay owns - // focus while open, so the @ is inserted here rather than left to the - // InputRenderable, which would race the focus change. - if ( - !key.ctrl && - !key.meta && - !key.option && - key.sequence === "@" && - focusOwner(shell.focus) === "prompt" - ) { - const before = shell.prompt.value.slice(0, shell.prompt.cursorOffset); - if (before.length === 0 || /\s$/.test(before)) { - key.preventDefault(); - shell.prompt.insertText("@"); - void openAtMentionSuggestions(shell); - return; - } - } - - // A slash command is only valid as the whole prompt, so `/` pops the - // command list at the start of an empty prompt and nowhere else — mid-line - // it is just a path separator. - if ( - !key.ctrl && - !key.meta && - !key.option && - key.sequence === "/" && - focusOwner(shell.focus) === "prompt" && - shell.prompt.cursorOffset === 0 && - shell.prompt.value.trim().length === 0 - ) { - key.preventDefault(); - setPromptText(shell, "/"); - openSlashCommands(shell); - return; - } - - if ( - !key.ctrl && - !key.meta && - !key.option && - (key.name === "up" || key.name === "down") && - focusOwner(shell.focus) === "prompt" - ) { - // Multi-row prompt: Up/Down are caret motion first. Recall only fires at - // the buffer's edges, which is where a shell history is conventionally - // reachable and where the caret has nowhere left to go. - const stepped = - key.name === "up" - ? promptCaretAtFirstRow(shell.prompt) - ? stepSentHistoryUp(shell.sentHistory, shell.prompt.value) - : null - : promptCaretAtLastRow(shell.prompt) - ? stepSentHistoryDown(shell.sentHistory, shell.prompt.value, shell.prompt.value.length) - : null; - if (stepped !== null) { - key.preventDefault(); - shell.sentHistory = stepped.browse; - shell.prompt.value = stepped.value; - shell.prompt.cursorOffset = stepped.cursor; - return; - } - } else if (!MOTION_KEYS.has(keyName)) { - shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); - } - - if ( - ((key.name === "tab" && key.shift) || key.name === "backtab") && - !key.ctrl && - !key.meta && - !key.option - ) { - key.preventDefault(); - effortCycleHandlers.get(shell)?.(); - return; - } - - if (key.name === "tab" && !key.ctrl && !key.meta && !key.option && !key.shift) { - key.preventDefault(); - toggleShellFocus(shell); - return; - } - - // Alt+E, never bare: the prompt almost always holds focus, and a bare - // `e` would just type a letter into it instead of expanding a row. - if ((key.meta || key.option) && !key.ctrl && key.name === EXPAND_KEY) { - if (toggleCollapsedRow(shell)) { - key.preventDefault(); - return; - } - } - - if ((key.meta || key.option) && (key.name === "c" || key.name === "C") && !key.ctrl) { - // Alt+C: keyboard copy path (no mouse drag-select). - key.preventDefault(); - enterCopyMode(shell); - return; - } - - if ((key.meta || key.option) && (key.name === "m" || key.name === "M") && !key.ctrl) { - // Alt+M: release mouse reporting so the terminal can drag-select. - key.preventDefault(); - toggleMouseCapture(shell); - return; - } - - if ((key.meta || key.option) && (key.name === "t" || key.name === "T") && !key.ctrl) { - // Alt+T: the task panel's only entry point now that the palette is gone. - // Losing the palette must not lose the toggle with it. - key.preventDefault(); - toggleTasksPanel(shell); - return; - } - - if ((key.meta || key.option) && (key.name === "o" || key.name === "O") && !key.ctrl) { - // Alt+O: observe a live subagent, same rationale as Alt+T — this was - // the palette's "observe" action and needs a real chord now the - // palette is gone, not a silently orphaned feature. - key.preventDefault(); - observeActiveSubagent(shell); - return; - } - - if (key.ctrl && key.name === "c") { - key.preventDefault(); - handleCtrlC(shell); - return; - } - - if (key.ctrl && key.name === "g") { - // Readline/Emacs "abort" chord — unclaimed by both the textarea's - // default bindings and this shell's other chords, and already means - // "cancel the pending thing" to muscle memory, unlike Ctrl+X (cut). - key.preventDefault(); - applyShellCancelLast(shell); - return; - } - - if ((key.name === "return" || key.name === "enter") && (key.meta || key.option) && !key.ctrl) { - // Alt+Enter: follow-up — enqueue kind "queue"; deliver only when the - // run goes idle. Does not interrupt or reinject. Idle / empty: no-op - // (nothing to wait for). Soft steer is plain Enter below; reinject is - // not wired to any product chord. - key.preventDefault(); - if (shell.session.run !== "busy") return; - submitPrompt(shell, "queue"); - return; - } - }; - - const onEnter = (): void => { - if (disposed || shell.overlayList) return; - if (internals.get(shell)?.inputSuspended === true) return; - // Mid-run Enter soft-steers (deliver at next tool.boundary); the bridge - // upgrades it to an immediate new turn while the parent is idle with a - // live fleet (idle-with-fleet, CL-7057). Alt+Enter is follow-up (quiet - // wait until idle). Idle sends ignore "kind". - submitPrompt(shell, "steer"); - }; - - // Per frame rather than per keystroke: the editor view's wrapped-line table is - // rebuilt during layout, so on the content-changed callback it still describes - // the text before the edit and the box would size itself one keystroke behind. - const onFrame = (): void => { - if (disposed) return; - syncPromptRows(shell); - syncPromptHighlights(shell); - // Applied after a natural render, not at mutation time: a row's own box - // needs a layout pass to size itself, and claiming the padding first - // starves that pass of room to lay the row out in. - syncTranscriptSpacer(shell); - syncNoticeAfterLayout(shell); - }; - - const onResize = (width: number, height: number): void => { - if (disposed) return; - const bag = internals.get(shell); - // A decision overlay's body was shaped against the old height's context - // budget; a shorter terminal can no longer afford as much of it without - // crowding out the choices, so it is re-shaped before asking for rows. - if (shell.overlayList && isDecisionOverlay(shell.overlayKind) && bag) { - applyOverlayBodyText(shell, bag.overlayRawBodyText, 0, height); - relayoutOverlayHost(shell, shell.overlayItems.length); - } - relayout(shell, { - columns: width, - rows: height, - overlayMode: bag?.overlayMode ?? "closed", - ...(bag?.overlayBodyRows !== undefined ? { overlayBodyRows: bag.overlayBodyRows } : {}), - }); - }; - - if (wireKeys) { - renderer.keyInput.on("keypress", onKey); - renderer.keyInput.on("paste", onPaste); - prompt.onSubmit = onEnter; - } - renderer.on(CliRenderEvents.FRAME, onFrame); - renderer.on(CliRenderEvents.RESIZE, onResize); - - // Declared before shell so dispose can off() the same function reference; - // body closes over shell after createAppShell finishes assigning it. - const onSelection = (selection: Selection): void => { - if (disposed) return; - copyFinishedSelection( - { - clipboard: shell.clipboard, - flash: (text) => setStatusFlash(shell, text, { ttlMs: RUNTIME_FLASH_MS }), - clearSelection: () => { - renderer.clearSelection(); - }, - }, - selection, - ); - }; - renderer.on(CliRenderEvents.SELECTION, onSelection); - - const shell: AppShell = { - renderer, - root, - topPad, - bottomPad, - versionRow, - taskBox, - agentsBox, - transcript, - overlayView, - overlayHost, - overlayTitle, - overlayBody, - prompt, - promptBox, - promptField, - promptTopRule, - promptBottomRule, - notice, - layout, - focus: createFocusState(), - session, - pendingQueue: badgeCount(session), - lineCount: 0, - streamLog: [], - streamLogBase: 0, - agentVoices: new Set(), - baseTitle: title, - modelLabel: null, - workspace: { cwd: options?.cwd ?? process.cwd(), branch: null }, - overlayList: null, - overlayItems, - overlayKind: null, - overlayBodyLines: [], - overlayBodyFgs: [], - paletteCommands: [], - clipboard: options?.clipboard ?? createRecordingClipboard(), - mouseCapture: options?.mouseCapture ?? null, - copyTargets: null, - statusFlash: null, - mcpNeedsAuth: [], - pluginNeedsAttention: false, - lockupNowMs: 0, - inFlightTool: null, - lockupAnimating: false, - lockupPhase: null, - lockupChangedMs: 0, - lockupRampPhase: null, - lockupStalledForMs: null, - costContext: null, - observe: null, - parentStreamLog: null, - parentStreamLogBase: null, - promptKillRing: emptyKillRing, - pendingAttachments: [], - sentHistory: createSentHistoryBrowse([]), - disposed: false, - dispose: () => { - if (disposed) return; - dropDeferredCommandOverlay(shell); - // Unwind a stacked palette first, then let the primary overlay's owner - // release subscriptions or settle awaited cancellation exactly once. - let overlayGuard = 4; - while (shell.overlayList !== null && overlayGuard-- > 0) closeInsetOverlay(shell); - abortOverlayHostReservations(shell); - disposed = true; - shell.disposed = true; - // Quit paths that skip idle Ctrl+C still drop Corbits-created files. - clearPendingAttachments(shell); - if (wireKeys) { - renderer.keyInput.off("keypress", onKey); - renderer.keyInput.off("paste", onPaste); - prompt.onSubmit = undefined; - } - renderer.off(CliRenderEvents.FRAME, onFrame); - renderer.off(CliRenderEvents.RESIZE, onResize); - renderer.off(CliRenderEvents.SELECTION, onSelection); - internals.get(shell)?.landingIdleTimerCancel?.(); - flashTimers.get(shell)?.(); - flashTimers.delete(shell); - try { - renderer.root.remove(root); - } catch { - // Root may already be torn down in tests. - } - destroySubtree(root); - }, - }; - - if (options?.flashSchedule) { - shellFlashSchedules.set(shell, options.flashSchedule); - } - - internals.set(shell, { - visibility, - promptContentRows, - overlayMode: "closed", - overlayBodyRows: undefined, - overlayMinBodyRows: undefined, - overlayRawBodyText: "", - priorOverlay: null, - overlayGeneration: 0, - primaryBindings: { ...EMPTY_PRIMARY_BINDINGS }, - overlayEchoChoice: true, - inputSuspended: false, - overlayAnswer: null, - overlayTitleText: "", - overlayClosedListeners: new Set(), - deferredCommandOverlay: null, - deferredFlushScheduled: false, - overlayHostReservations: 0, - overlayReservationEpoch: 0, - paletteCatalog: paletteCatalogOpt, - paletteFilter: null, - listFilter: null, - landing: { above: landingAbove, below: landingBelow }, - landingNotice: options?.telemetryNotice ?? null, - landingDeferredRows: [], - landingBelow: landingBelowState, - landingSuggestionsVisible: true, - landingAnimating: false, - landingNowMs: 0, - reducedMotion, - landingIdleTimerCancel: null, - chrome: { task: [], tasksRaw: [], agents: [] }, - // CL-5847: the manage_tasks checklist panel is hidden by default. The - // panel owns too much of the screen for the operator to want it forced - // into view on a fresh shell; Alt+T (toggleTasksPanel) opts in for the - // shell's lifetime. Live task data still lands in tasksRaw while hidden, - // so the first toggle shows current data rather than a stale snapshot. - tasksPanelHidden: true, - }); - // The landing's snow needs a frame source that keeps running while the - // turn monitor is deliberately quiet (idle, no session yet). A plain timer - // armed at mount is that source: it does not depend on the renderer - // scheduling further frames, so it cannot stall the way riding the - // renderer's FRAME event does: FRAME follows dirty rows, not a clock. - // - // Only repaints while idle (`landingAnimating` false): while a turn is - // processing, `paintPhaseAt` in runtime-bridge.ts drives the mountain's - // own draw/fill/fade loop off the turn monitor's clock, and this timer - // must not stomp that with an unrelated real-clock value. - // - // Cleared on whichever teardown happens first: the landing going away - // (`clearLandingMark`, first transcript row) or the whole shell disposing - // (`dispose` below, e.g. tests that never grow a transcript). - // - // Also self-cancels on `renderer.isDestroyed`: a real terminal session - // always disposes the shell, but headless test harnesses commonly destroy - // the renderer directly (`withTestRenderer`'s cleanup) without ever - // calling `shell.dispose()`. Without this check the timer would keep - // firing against renderables the harness already tore down. - // - // Reduced motion never starts the timer: there is no snow to advance - // and the mountain stays on its filled frame. - if (!reducedMotion) { - const landingIdleHandle = setInterval(() => { - if (renderer.isDestroyed) { - clearInterval(landingIdleHandle); - return; - } - const bag = internals.get(shell); - if (bag?.landing == null || bag.landingAnimating) return; - paintLanding(shell, Date.now(), false); - }, LANDING_IDLE_REPAINT_INTERVAL_MS); - landingIdleHandle.unref?.(); - { - const bag = internals.get(shell); - if (bag !== undefined) { - bag.landingIdleTimerCancel = () => clearInterval(landingIdleHandle); - } else { - clearInterval(landingIdleHandle); - } - } - } - transcriptSpacers.set(shell, transcriptSpacer); - if (onCommandOpt) setPaletteOnCommand(shell, onCommandOpt); - if (onObserveRequestOpt) { - setPaletteOnObserveRequest(shell, onObserveRequestOpt); - } - applyLayout(shell, layout); - // Added after the first layout pass so the scroll box sizes it against the - // resolved transcript height rather than the pre-layout placeholder. - transcript.add(landingAbove.box); - applyFocus(shell); - return shell; -} diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts new file mode 100644 index 000000000..c33d3d9df --- /dev/null +++ b/src/tui/shell/chrome.ts @@ -0,0 +1,1469 @@ +/** + * Persistent chrome: paint, notice row, lockup frame, landing paint, panels, flash status, focus application. + */ +import { homedir } from "node:os"; +import { + clampBoardRows, + type AgentPanelRow, + type ChromeZoneContent, + type TaskPanelRow, +} from "../chrome-state.js"; +import { + BoxRenderable, + TextRenderable, + StyledText, + fg as fgChunk, + type CliRenderer, + type TextChunk, +} from "@opentui/core"; +import { sliceTailToWidth, sliceToWidth, stringWidth } from "../view/height.js"; +import { promptRowCount } from "../prompt-input.js"; +import { promptBoxRows } from "../prompt-rows.js"; +import { composeNoticeLine, resolveWaitingOn } from "../notice-line.js"; +import { lockupCells, lockupText, lockupWidth, type LockupInput } from "../lockup.js"; +import type { RampPhase, StallAge } from "../ramp.js"; +import type { ActivityState } from "../session-chrome.js"; +import { + BORDER, + composeAttentionLabel, + composeRule, + composeWorkspaceLabel, + costContextText, + type RulePart, +} from "../prompt-border.js"; +import { focusOwner, focusPrompt, focusTranscript, popFocus } from "../focus/index.js"; +import { + resolveBottomMarginRows, + resolveGeometry, + resolveTopPadRows, + type GeometryLayout, +} from "../geometry/index.js"; +import { + fitLandingMark, + landingSuggestionFor, + paintLandingBelow, + paintLandingMark, + resolveMarkGrid, + versionBadgeVisible, +} from "../landing.js"; +import { evictedRowsNotice, trimRetainedLog } from "../long-log.js"; +import { destroySubtree } from "../teardown.js"; +import { + badgeCount, + enqueue, + queueCount, + setRunState, + steerCount, + type RunState, +} from "../session-queue.js"; +import { agentVoicesIn, isCollapsibleRow, type StreamRow } from "../stream.js"; +import { UI } from "../theme.js"; +import { isDecisionOverlay, overlayRowsPerItem, overlayChromeRows } from "../overlay-view.js"; +import { DECISION_CHOICE_ROWS } from "../overlay-body.js"; + +import { + type AppShell, + type FlashOptions, + type FlashSchedule, + flashTimers, + isLanding, + isTranscriptFollowing, + type OverlayAnswerState, + type OverlayList, + shellFlashSchedules, + shellInternals, + transcriptSpacers, +} from "./internals.js"; +import { + defaultVisibility, + fleetTranscriptFloor, + landingSplitFor, + type RelayoutOpts, + terminalForGeometry, + terminalOf, +} from "./layout.js"; +import { + buildRowNode, + evictionMarkers, + gapBefore, + labelBefore, + noteAgentVoice, + retextStreamRow, + transcriptMarker, + transcriptRowChildren, + transcriptRowLayout, + transcriptRowOffset, +} from "./transcript.js"; + +function syncPending(shell: AppShell): void { + shell.pendingQueue = badgeCount(shell.session); +} + +/** The transient row's text for the current state ("" when it has nothing to say). */ +export function noticeText(shell: AppShell): string { + return composeNoticeLine({ + steer: steerCount(shell.session), + followUp: queueCount(shell.session), + waitingOn: resolveWaitingOn(steerCount(shell.session), shell.inFlightTool, shell.lockupNowMs), + interrupt: shell.session.interruptFlash, + pinned: !isTranscriptFollowing(shell), + flash: shell.statusFlash, + attachments: shell.pendingAttachments.length, + }); +} + +/** Which MCP servers are waiting on authorization. Repaints on change. */ +export function setMcpNeedsAuth(shell: AppShell, names: readonly string[]): void { + const next = [...names]; + if ( + shell.mcpNeedsAuth.length === next.length && + next.every((name) => shell.mcpNeedsAuth.includes(name)) + ) { + return; + } + shell.mcpNeedsAuth = next; + paintChrome(shell); +} + +/** Whether plugin load warnings still need attention. Repaints on change. */ +export function setPluginNeedsAttention(shell: AppShell, needs: boolean): void { + if (shell.pluginNeedsAttention === needs) return; + shell.pluginNeedsAttention = needs; + paintChrome(shell); +} + +/** + * Every input the chrome compose paths read, as one comparable key. A missed + * input here means stale chrome, so this list is exhaustive: + * + * - notice row: the composed `noticeText` output (folds in steer/follow-up + * queue counts, the in-flight tool and its start time, `lockupNowMs` as the + * waiting-on clock, the interrupt flash, transcript pin state, the status + * flash, and pending attachment count) + * - border geometry: `layout.contentWidth` + * - top rule: MCP-needs-auth presence, plugin-needs-attention, `modelLabel` + * - bottom rule: workspace cwd and branch, `homedir()` (label compression) + * - both rules' lockup slot: `lockupNowMs`, `lockupAnimating`, `lockupPhase`, + * `lockupChangedMs`, `lockupRampPhase`, `lockupStalledForMs` + * - cost meter: band, percent label, cost label (or absence) + * - landing suggestions: whether the prompt has text + * + * Landing and zone paints read their own state and do not pass through here. + */ +function chromeComposeKey(shell: AppShell, notice: string): string { + const meter = shell.costContext; + return [ + notice, + shell.layout.contentWidth, + shell.mcpNeedsAuth.length > 0 ? "1" : "0", + shell.pluginNeedsAttention ? "1" : "0", + shell.modelLabel ?? "", + shell.lockupNowMs, + shell.lockupAnimating ? "1" : "0", + shell.lockupPhase ?? "", + shell.lockupChangedMs, + shell.lockupRampPhase ?? "", + String(shell.lockupStalledForMs), + shell.workspace.cwd, + shell.workspace.branch, + homedir(), + meter === null ? "" : `${meter.band}\u0001${meter.percentLabel}\u0001${meter.costLabel ?? ""}`, + shell.prompt.value.length, + ].join("\u0000"); +} + +const paintedChromeKey = new WeakMap(); +const chromeComposeCounts = new WeakMap(); + +/** Compose passes run since mount. Test seam for the repaint gate. */ +export function chromeComposeCount(shell: AppShell): number { + return chromeComposeCounts.get(shell) ?? 0; +} + +/** + * Repaint the prompt borders and the transient notice row from live state. + * + * Recomposes only when a composed input actually changed; passes with an + * unchanged key cost one key build and a string compare. `force` bypasses the + * gate for paths that must repaint regardless (layout application, where the + * column budget and the render tree may have moved under identical text). + */ +export function paintChrome(shell: AppShell, opts?: { readonly force?: boolean }): void { + if (shell.disposed) return; + // Headless tests often destroy the renderer without dispose + // (`withTestRenderer` cleanup). A TTL flash armed before that teardown + // must not write a TextBuffer the harness already freed. + if (shell.renderer.isDestroyed || shell.notice.isDestroyed) return; + syncPending(shell); + const notice = noticeText(shell); + const key = chromeComposeKey(shell, notice); + if (!opts?.force && paintedChromeKey.get(shell) === key) return; + paintedChromeKey.set(shell, key); + chromeComposeCounts.set(shell, chromeComposeCount(shell) + 1); + shell.notice.content = new StyledText([ + fgChunk(UI.textDim)(notice.length > 0 ? ` ${notice}` : ""), + ]); + paintPromptBorder(shell); + syncLandingSuggestions(shell); + syncNoticeRow(shell, notice); +} + +/** + * Give the notice row a row only while it has something to say, and take it + * back the moment it does not. The relayout re-enters paintChrome, which then + * finds the visibility already correct and stops. + */ +function syncNoticeRow(shell: AppShell, notice: string): void { + paintedNotice.set(shell, notice); + const bag = shellInternals(shell); + if (bag === undefined) return; + const wanted = notice.length > 0; + if ((bag.visibility.notice ?? false) === wanted) return; + relayout(shell, { visibility: { ...bag.visibility, notice: wanted } }); +} + +/** + * Re-read the notice once the layout pass has run. + * + * `pinned` is derived from the scroll box's own numbers, and those describe the + * *last completed* layout: chrome painted at row-mutation time can read a + * transcript that is following its tail as pinned, for the one frame between a + * row landing and sticky-scroll re-applying. Repaints only when the wording + * actually changed, so a settled frame costs a string compare. + */ +export function syncNoticeAfterLayout(shell: AppShell): void { + if (noticeText(shell) !== paintedNotice.get(shell)) paintChrome(shell); +} + +/** Notice wording currently on the row, for the post-layout re-read. */ +const paintedNotice = new WeakMap(); + +/** Withdraw or restore the landing starters as the prompt fills and empties. */ +function syncLandingSuggestions(shell: AppShell): void { + const bag = shellInternals(shell); + if (!bag) return; + const landing = bag.landing; + const content = bag.landingBelow; + if (landing === null || content === null) return; + const visible = shell.prompt.value.length === 0; + if (visible === bag.landingSuggestionsVisible) return; + bag.landingSuggestionsVisible = visible; + paintLandingBelow(landing.below, content, visible); +} + +/** + * Advance the status slot's clock and publish what it says. Callers own the + * tick; the shell only repaints when the frame it would draw can actually + * differ. + * + * A change of phase stamps the fade's origin, so the crossfade runs off the + * frames the monitor is already scheduling for the live turn. Settling snaps + * straight to the idle slot rather than fading into it: the tick stops on the + * frame the turn ends, and a transition with no frames left to draw is worse + * than none. + */ +export interface LockupFrame { + readonly nowMs: number; + readonly animating: boolean; + /** + * Live activity state, or null for the idle wordmark. Typed to the closed + * set so the caller cannot hand this a raw tool identifier. + */ + readonly phase: ActivityState | null; + /** The turn's ramp phase, or null when idle. */ + readonly rampPhase: RampPhase | null; + /** How long the turn has been stalled, or null when it is not stalled. */ + readonly stalledForMs: StallAge; +} + +export function setLockupFrame(shell: AppShell, frame: LockupFrame): void { + const settled = !frame.animating && !shell.lockupAnimating; + shell.lockupNowMs = frame.nowMs; + const phaseChanged = frame.phase !== shell.lockupPhase; + if (phaseChanged) { + shell.lockupPhase = frame.phase; + shell.lockupChangedMs = frame.nowMs; + } + const changed = + phaseChanged || + frame.rampPhase !== shell.lockupRampPhase || + frame.stalledForMs !== shell.lockupStalledForMs; + shell.lockupRampPhase = frame.rampPhase; + shell.lockupStalledForMs = frame.stalledForMs; + if (settled && !changed && shell.lockupAnimating === frame.animating) return; + shell.lockupAnimating = frame.animating; + paintChrome(shell); +} + +const defaultFlashSchedule: FlashSchedule = (fn, ms) => { + const timer = setTimeout(fn, ms); + // A pending flash must never be the reason the process stays alive. + (timer as { unref?: () => void }).unref?.(); + return () => { + clearTimeout(timer); + }; +}; + +/** + * Set a non-destructive flash and repaint (does not touch streamLog). + * + * A flash with a `ttlMs` clears itself when its window lapses. Anything whose + * wording is only true for a moment ("press ctrl+c again to exit") must say so + * for exactly that moment: left on screen it becomes a claim about a keypress + * the operator never made, and it holds a transcript row hostage for it. + * Omit `ttlMs` for live conditions that stay true until something replaces them + * (stall notice, landing hold). + */ +export function setStatusFlash( + shell: AppShell, + message: string | null, + options?: FlashOptions, +): void { + flashTimers.get(shell)?.(); + flashTimers.delete(shell); + shell.statusFlash = message; + paintChrome(shell); + const ttlMs = options?.ttlMs; + if (message === null || ttlMs === undefined || ttlMs <= 0) return; + if (shell.disposed || shell.renderer.isDestroyed) return; + const schedule = options?.schedule ?? shellFlashSchedules.get(shell) ?? defaultFlashSchedule; + flashTimers.set( + shell, + schedule(() => { + flashTimers.delete(shell); + // Only this flash expires: a later one has its own window, and the row + // it is holding is not this one's to take back. + if (shell.statusFlash !== message) return; + shell.statusFlash = null; + paintChrome(shell); + }, ttlMs), + ); +} + +/** Apply focus state to OpenTUI focusables. */ +export function applyFocus(shell: AppShell): void { + const owner = focusOwner(shell.focus); + // Observe is a read-only child view: the parent prompt must not swallow the + // keystrokes, so it is blurred exactly as an overlay blurs it. + if (owner === "overlay" || owner === "palette" || owner === "observe") { + if (typeof shell.prompt.blur === "function") { + shell.prompt.blur(); + } + } else if (owner === "transcript") { + shell.transcript.focus(); + } else { + shell.prompt.focus(); + } + paintChrome(shell); +} + +export function shellFocusPrompt(shell: AppShell): void { + shell.focus = focusPrompt(shell.focus); + applyFocus(shell); +} + +export function shellFocusTranscript(shell: AppShell): void { + shell.focus = focusTranscript(shell.focus); + applyFocus(shell); +} + +export function toggleShellFocus(shell: AppShell): void { + const owner = focusOwner(shell.focus); + if (owner === "overlay" || owner === "palette") return; + if (owner === "transcript") { + shellFocusPrompt(shell); + } else { + shellFocusTranscript(shell); + } +} + +export function overlayAnswerState(shell: AppShell): OverlayAnswerState | null { + return shellInternals(shell)?.overlayAnswer ?? null; +} + +/** + * Stacking order for the floated overlay host. Only the landing composition + * sits under it, and that has no z-index of its own, so one step is enough. + */ +const OVERLAY_FLOAT_Z = 10; + +/** + * Lift the overlay host out of the root's column, or drop it back in. + * + * On the landing the host is a modal: the mark and the disclosure are the + * screen, and shoving them around to open a command list would make every + * overlay feel like a navigation. Absolute positioning takes the host out of + * flow so the composition beneath is untouched, anchored above the chrome the + * host used to sit on top of. With a transcript on screen the opposite is + * true — rows there are content the operator is reading, and covering them is + * worse than pushing them — so the host goes back into the column. + */ +function floatOverlayHost(shell: AppShell, floating: boolean, top: number): void { + const host = shell.overlayHost; + if (!floating) { + host.position = "relative"; + host.zIndex = 0; + // A previous landing float left absolute insets behind. Under relative + // positioning those same values act as offsets from the in-flow slot, so + // a stale top pushes the band that many rows below the prompt — clear + // them so the band sits where the flow put it. + host.top = 0; + host.left = 0; + host.width = "100%"; + return; + } + host.position = "absolute"; + // Absolute positioning escapes root's padding, so the same sideMargin the + // prompt box gets for free in normal flow has to be given back explicitly. + // width is set to the same contentWidth the prompt box resolves to via + // "100%" of root's padded box — one source, not a second computed here — + // rather than left+right insets, since those combine with the existing + // width:"100%" to overshoot the right edge. + host.left = shell.layout.sideMargin; + host.width = shell.layout.contentWidth; + host.top = top; + host.zIndex = OVERLAY_FLOAT_Z; +} + +/** Stable id for the focused row: `itemIds[index]` when supplied, else its label. */ +export function activeOverlayItemId(shell: AppShell, list: OverlayList): string { + const bag = shellInternals(shell); + return ( + bag?.primaryBindings.itemIds[list.activeIndex] ?? + shell.overlayItems[list.activeIndex] ?? + String(list.activeIndex) + ); +} + +export function paintOverlayList(shell: AppShell): void { + const list = shell.overlayList; + if (!list) return; + shell.overlayView.paintList( + { + kind: shell.overlayKind, + items: shell.overlayItems, + paletteCommands: shell.paletteCommands, + list, + bodyLines: shell.overlayBodyLines, + bodyFgs: shell.overlayBodyFgs, + answer: overlayAnswerState(shell), + describe: () => { + const describe = shellInternals(shell)?.primaryBindings.describe; + return describe ? describe(activeOverlayItemId(shell, list)) : undefined; + }, + }, + shell.layout.contentWidth, + ); +} + +/** + * Colour a composed rule. The frame stays faint so the labels it carries read + * as the brighter thing on the row; the brand run is swapped for the lockup's + * own cells, which is the only part of the border that animates. + */ +function ruleChunks(shell: AppShell, parts: readonly RulePart[]): TextChunk[] { + const chunks: TextChunk[] = []; + for (const part of parts) { + if (part.role === "brand") { + const cells = lockupCells(lockupFrameInput(shell)); + chunks.push(fgChunk(UI.textFaint)(" ")); + for (const cell of cells) chunks.push(fgChunk(cell.fg)(cell.char)); + chunks.push(fgChunk(UI.textFaint)(" ")); + continue; + } + if (part.role === "meter") { + chunks.push(...meterChunks(shell, part.text)); + continue; + } + if (part.role === "attention") { + chunks.push(fgChunk(UI.warning)(part.text)); + continue; + } + chunks.push(fgChunk(part.role === "label" ? UI.textDim : UI.textFaint)(part.text)); + } + return chunks; +} + +/** + * Color a meter cell: the percent takes the band color (quiet `textDim`, + * warning sand, danger red) and the optional cost suffix stays dim chrome. + */ +function meterChunks(shell: AppShell, cell: string): TextChunk[] { + const meter = shell.costContext; + const percentFg = + meter?.band === "danger" ? UI.error : meter?.band === "warning" ? UI.warning : UI.textDim; + if (meter === null) return [fgChunk(percentFg)(cell)]; + const percent = meter.percentLabel; + const idx = cell.indexOf(percent); + if (idx === -1) return [fgChunk(percentFg)(cell)]; + const before = cell.slice(0, idx); + const after = cell.slice(idx + percent.length); + const chunks: TextChunk[] = []; + if (before.length > 0) chunks.push(fgChunk(UI.textFaint)(before)); + chunks.push(fgChunk(percentFg)(percent)); + if (after.length > 0) chunks.push(fgChunk(UI.textDim)(after)); + return chunks; +} + +/** The status slot's state, as the lockup renderer wants it. */ +function lockupFrameInput(shell: AppShell): LockupInput { + return { + nowMs: shell.lockupNowMs, + still: !shell.lockupAnimating, + phase: shell.lockupPhase, + changedMs: shell.lockupChangedMs, + rampPhase: shell.lockupRampPhase, + stalledForMs: shell.lockupStalledForMs, + }; +} + +/** + * Repaint both border rules. Reached through `paintChrome`, which gates on the + * compose key: a resize changes the column budget without changing any label, + * and the lockup changes every animation frame without changing the geometry — + * both move the key (width, lockup fields) and so pass the gate. + */ +export function paintPromptBorder(shell: AppShell): void { + const width = shell.layout.contentWidth; + const attention = composeAttentionLabel({ + mcp: shell.mcpNeedsAuth.length > 0, + plugin: shell.pluginNeedsAttention, + }); + const top = composeRule({ + width, + corners: [BORDER.topLeft, BORDER.topRight], + ...(attention !== undefined ? { attention } : {}), + ...(shell.modelLabel !== null ? { label: shell.modelLabel } : {}), + }); + shell.promptTopRule.content = new StyledText(ruleChunks(shell, top)); + + // Corners, both rule margins, the gap and the spaces around each label are + // what the workspace has to fit inside — with the lockup if the rule can + // seat both, without it if it cannot. Where the row can only afford one, the + // information wins and the mark goes. + const withBrand = Math.max(0, width - 9 - lockupWidth(lockupFrameInput(shell))); + const alone = Math.max(0, width - 6); + const workspaceInput = { + cwd: shell.workspace.cwd, + branch: shell.workspace.branch, + home: homedir(), + }; + // A workspace that has lost its path is a branch floating with no context, + // which is worth less than the mark it displaced. So the mark yields not just + // when the label cannot fit at all, but when keeping it would starve the path. + const roomyRaw = composeWorkspaceLabel({ ...workspaceInput, maxWidth: withBrand }); + const roomy = roomyRaw.startsWith("(") ? "" : roomyRaw; + const workspace = + roomy.length > 0 ? roomy : composeWorkspaceLabel({ ...workspaceInput, maxWidth: alone }); + const brand = lockupText(lockupCells(lockupFrameInput(shell))); + const meter = shell.costContext; + const bottom = composeRule({ + width, + corners: [BORDER.bottomLeft, BORDER.bottomRight], + ...(roomy.length > 0 || workspace.length === 0 ? { brand } : {}), + ...(meter !== null + ? { meter: costContextText(meter, true), meterCompact: costContextText(meter, false) } + : {}), + ...(workspace.length > 0 ? { label: workspace } : {}), + }); + shell.promptBottomRule.content = new StyledText(ruleChunks(shell, bottom)); +} + +export function applyLayout(shell: AppShell, layout: GeometryLayout): void { + // Rows lay themselves out against the column budget (right-aligned bubbles, + // pre-wrapped reasoning blocks), so a width change invalidates every painted + // row rather than just reflowing it. + const widthChanged = + shell.layout.contentWidth !== layout.contentWidth || + shell.layout.chatWidth !== layout.chatWidth || + shell.layout.layoutMode !== layout.layoutMode; + shell.layout = layout; + const h = layout.heights; + + shell.root.paddingLeft = layout.sideMargin; + shell.root.paddingRight = layout.sideMargin; + + // Raw renderer size, not `layout.terminal` — that is already net of the row + // this badge itself reserves (see `terminalForGeometry`), which would make + // the threshold check its own effect. Landing-only: see `relayout`. + shell.versionRow.visible = + isLanding(shell) && versionBadgeVisible(shell.renderer.width, shell.renderer.height); + + const taskH = Math.max(0, h.task); + shell.taskBox.height = taskH > 0 ? taskH : 1; + shell.taskBox.visible = taskH > 0; + + const agentsH = Math.max(0, h.agents); + shell.agentsBox.visible = agentsH > 0; + + // Both pads are taken out of the transcript residual, never out of chrome, + // so the resolver's row budget still sums to the terminal height. + const transcriptH = Math.max(0, h.transcript); + const padH = resolveTopPadRows(transcriptH); + shell.topPad.height = padH > 0 ? padH : 1; + shell.topPad.visible = padH > 0; + + const bottomPadH = resolveBottomMarginRows(layout.terminal.rows); + shell.bottomPad.height = bottomPadH > 0 ? bottomPadH : 1; + shell.bottomPad.visible = bottomPadH > 0; + + const overlayH = Math.max(0, h.overlay_host); + + // The landing splits the transcript residual around the prompt box so the box + // sits on the terminal's middle row instead of at its foot. An open overlay + // floats over that composition rather than displacing it, so the rows the + // resolver took for the overlay host are handed back to the split. + const bag = shellInternals(shell); + const landing = bag?.landing ?? null; + const landingRows = transcriptH - padH - bottomPadH + (landing === null ? 0 : overlayH); + // The resolver already sized overlayH to the overlay's real content (list + // included) and capped it against the fraction/floor limits, so it is the + // correct minimum to ask the landing split to make room for — asking for + // less (e.g. just enough for one choice row) starves the list underneath + // the title down to nearly nothing once floatOverlayHost pins the host to it. + const split = landing === null ? null : landingSplitFor(landingRows, overlayH, padH); + if (bag !== undefined && landing !== null && split !== null) { + landing.above.box.height = Math.max(1, split.above); + // A new zone can seat a different tier, and a tier is a different grid, so + // the mark is redrawn rather than left showing the previous size's frame. + fitLandingMark(landing.above, resolveMarkGrid(split.above, layout.contentWidth)); + paintLandingMark(landing.above, bag.landingNowMs, !bag.landingAnimating, bag.reducedMotion); + landing.below.height = Math.max(0, split.below); + landing.below.visible = split.below > 0; + } + + const transcriptBody = + split === null ? transcriptH - padH - bottomPadH : Math.max(1, split.above); + shell.transcript.height = transcriptBody > 0 ? transcriptBody : 1; + shell.transcript.visible = transcriptBody > 0; + syncTranscriptSpacer(shell); + + // Agents strip: full-width flex stack under the transcript when present. + // Live chrome keeps the zone empty (spawn_agent transcript rows instead). + shell.agentsBox.position = "relative"; + shell.agentsBox.left = 0; + shell.agentsBox.top = 0; + shell.agentsBox.width = "100%"; + shell.agentsBox.height = agentsH > 0 ? agentsH : 1; + shell.agentsBox.zIndex = 0; + shell.transcript.width = "100%"; + + const noticeH = Math.max(0, h.notice); + shell.notice.height = noticeH > 0 ? noticeH : 1; + shell.notice.visible = noticeH > 0; + + const promptH = Math.max(1, h.prompt); + shell.promptBox.height = promptH; + shell.promptBox.visible = promptH > 0; + // The field takes whatever the box has left once both labelled rules are paid. + const promptInnerH = Math.max(1, promptH - 2); + shell.promptField.height = promptInnerH; + // Sized explicitly rather than left to grow with its content: past the cap the + // input has to scroll inside a fixed window instead of pushing the frame open. + shell.prompt.height = promptInnerH; + + // Sized last: the float is anchored against chrome sized earlier in this + // pass. Modal over the landing, an in-flow band once there is a transcript + // to push. + const floating = landing !== null && overlayH > 0; + // Rows the flow spends before the prompt box — where a floated host's bottom + // edge has to land, since the landing's box sits mid-screen rather than at + // the foot and covering it would hide the thing the operator types into. + // Stack: topPad, transcript, agents, task, then prompt (notice omitted — + // same as before; it is transient chrome between task and prompt). + const promptTop = padH + transcriptBody + agentsH + taskH; + const hostH = floating ? Math.min(overlayH, Math.max(1, promptTop)) : overlayH; + floatOverlayHost(shell, floating, Math.max(0, promptTop - hostH)); + shell.overlayHost.height = hostH > 0 ? hostH : 1; + shell.overlayHost.visible = hostH > 0; + if (hostH > 0 && shell.overlayList) { + const chrome = overlayChromeRows( + shell.overlayKind, + shell.overlayBodyLines.length, + !!bag?.primaryBindings.describe, + overlayAnswerState(shell) !== null, + ); + const bodyH = Math.max(1, hostH - chrome); + // The viewport counts items, not rows; a decision overlay spends several + // rows per item, so the row budget has to be divided back down. + const perItem = overlayRowsPerItem(shell.overlayKind); + shell.overlayList.setHeight( + Math.max(1, Math.floor(bodyH / perItem)), + isDecisionOverlay(shell.overlayKind) ? DECISION_CHOICE_ROWS : 1, + ); + paintOverlayList(shell); + } + + paintPromptBorder(shell); + + // The landing owns the transcript's children until the first row lands, so a + // resize there must not rebuild them out from under it. + if (widthChanged && shell.streamLog.length > 0 && !isLanding(shell)) { + repaintTranscriptWindow(shell); + } + + // Width change changes the column budget chrome rows fit to. Content may + // be unchanged, so setChromeZones would skip the rebuild — do it here. + if (widthChanged && bag !== undefined) { + if (bag.chrome.task.length > 0) { + renderTasksRows(shell, bag.chrome.task, layout.contentWidth); + } + if (bag.chrome.agents.length > 0) { + renderAgentsRows(shell, clampBoardRows(bag.chrome.agents, agentsH), layout.contentWidth); + } + } + + // Forced: a relayout can move the column budget and the render tree under + // identical composed text, so the trailing chrome pass always recomposes. + paintChrome(shell, { force: true }); +} + +/** + * Re-size the prompt box for what is now in it. Cheap enough to run on every + * content change: it re-resolves geometry only when the row count actually + * moves, which is once per wrapped line gained or lost. + */ +export function syncPromptRows(shell: AppShell): void { + const rows = promptBoxRows(promptRowCount(shell.prompt), shell.renderer.height); + if (rows === shell.layout.heights.prompt) return; + relayout(shell, { promptContentRows: rows }); +} + +/** + * Resize the transcript's leading filler to soak up leftover viewport space. + * Reads `scrollHeight` (content height, filler included) net of the filler's + * own last-applied height, so it stays correct regardless of wrapping, + * markdown, or windowed long-log rebuilds. + * + * Deliberately NOT called at row-mutation time: `scrollHeight` reflects the + * last completed layout, not the tree as it stands the instant a row lands — + * a row whose own box needs a layout pass to size itself (structured/tool/ + * collapsible rows) reads back as shorter than it really is for one frame. + * Growing the filler on that stale reading would claim room the row still + * needs and bury it. Called from the render-frame hook instead, once that + * pass has actually run. + */ +export function syncTranscriptSpacer(shell: AppShell): void { + const spacer = transcriptSpacers.get(shell); + if (spacer === undefined) return; + // The landing screen already bottom-anchors its own mark against the box + // via the above/below split; a filler competing for the same content box + // would double-count that space and squeeze the mark. + if (isLanding(shell)) { + if (spacer.height !== 0) spacer.height = 0; + return; + } + const rowsHeight = Math.max(0, shell.transcript.scrollHeight - spacer.height); + const nextHeight = Math.max(0, shell.transcript.height - rowsHeight); + if (spacer.height !== nextHeight) spacer.height = nextHeight; +} + +export function relayout(shell: AppShell, opts?: RelayoutOpts): GeometryLayout { + const bag = shellInternals(shell); + const visibility = opts?.visibility ?? bag?.visibility ?? defaultVisibility(); + const promptContentRows = opts?.promptContentRows ?? bag?.promptContentRows; + const overlayMode = opts?.overlayMode ?? bag?.overlayMode ?? "closed"; + const overlayBodyRows = opts?.overlayBodyRows ?? bag?.overlayBodyRows; + const overlayMinBodyRows = opts?.overlayMinBodyRows ?? bag?.overlayMinBodyRows; + if (bag) { + bag.visibility = visibility; + bag.promptContentRows = promptContentRows; + bag.overlayMode = overlayMode; + bag.overlayBodyRows = overlayBodyRows; + bag.overlayMinBodyRows = overlayMinBodyRows; + } + + const columns = opts?.columns ?? shell.renderer.width; + const rows = opts?.rows ?? shell.renderer.height; + const terminal = terminalOf(shell.renderer, { columns, rows }); + // Only the landing screen ever gives up a row for the version badge — once + // a session has real transcript content every row is that content's, and + // the badge simply stops showing (see `applyLayout`) rather than taking + // space back from it. + const versionReserved = isLanding(shell); + const layout = resolveGeometry({ + terminal: versionReserved ? terminalForGeometry(terminal) : terminal, + visibility, + overlay: + overlayMode === "closed" + ? { mode: "closed" } + : { + mode: overlayMode, + ...(overlayBodyRows !== undefined ? { bodyRows: overlayBodyRows } : {}), + ...(overlayMinBodyRows !== undefined ? { minBodyRows: overlayMinBodyRows } : {}), + }, + ...(promptContentRows !== undefined ? { promptContentRows } : {}), + // The landing owns the screen until the first transcript row lands, so + // holding rows back for a transcript that does not exist would only clip + // whatever the operator opened over it. An open overlay is the exception: + // it asks for exactly as many rows as it has content, and without the floor + // a long list would claim the whole screen instead of scrolling. + ...(isLanding(shell) && overlayMode === "closed" + ? { transcriptFloor: 0 } + : fleetTranscriptFloor(shell)), + }); + applyLayout(shell, layout); + return layout; +} + +/** + * Append a raw line to the sticky transcript ScrollBox. + * stickyScroll + stickyStart "bottom" auto-follow until the operator scrolls up. + */ +export function appendTranscript( + shell: AppShell, + line: string, + opts?: { readonly fg?: string }, +): void { + clearLandingMark(shell); + shell.lineCount += 1; + shell.transcript.add( + new TextRenderable(shell.renderer as CliRenderer, { + content: ` ${line}`, + fg: opts?.fg ?? UI.text, + }), + ); + paintChrome(shell); +} + +/** + * Append a role-styled stream row to the **parent** transcript. + * While subagent observe is active, rows go to the parent snapshot only + * (not painted); leave restores them with the parent lease. + */ +export function appendStreamRow(shell: AppShell, row: StreamRow): void { + if (shell.observe !== null && shell.parentStreamLog !== null) { + shell.parentStreamLog.push(row); + shell.parentStreamLogBase = trimRetainedLog( + shell.parentStreamLog, + shell.parentStreamLogBase ?? 0, + ); + return; + } + paintAppendStreamRow(shell, row); +} + +/** + * Append a child stream row while observing a subagent. + * Host-pushed live events (not only fixture seed lines). No-op when not observing. + * @returns true when the row was applied to the observe view + */ +export function appendObserveStreamRow(shell: AppShell, row: StreamRow): boolean { + if (shell.observe === null) return false; + shell.observe.lines.push(row); + paintAppendStreamRow(shell, row); + return true; +} + +/** + * Paint + push onto the visible streamLog (child while observing, parent + * otherwise). The paint tree stays 1:1 with the (retention-capped) log — + * CL-5551 already bounds `streamLog` to `MAX_RETAINED_STREAM_ROWS`, so there + * is no separate, smaller window to maintain on top of it: every retained + * row gets a node, which is also what makes all of it reachable by + * scrolling (CL-5553). A trim past the cap costs one node removal here, not + * a rebuild. + */ +function paintAppendStreamRow(shell: AppShell, row: StreamRow): void { + clearLandingMark(shell); + const gainedVoice = noteAgentVoice(shell, row); + shell.streamLog.push(row); + const baseBefore = shell.streamLogBase; + shell.streamLogBase = trimRetainedLog(shell.streamLog, shell.streamLogBase); + shell.lineCount = shell.streamLog.length; + + if (gainedVoice) { + repaintTranscriptWindow(shell); + paintChrome(shell); + return; + } + + const dropped = shell.streamLogBase - baseBefore; + if (dropped > 0) { + for (const evicted of transcriptRowChildren(shell).slice(0, dropped)) { + shell.transcript.remove(evicted); + destroySubtree(evicted); + } + const marker = transcriptMarker(shell); + if (marker instanceof TextRenderable) { + marker.content = evictedRowsNotice(shell.streamLogBase); + } else { + const node = new TextRenderable(shell.renderer as CliRenderer, { + content: evictedRowsNotice(shell.streamLogBase), + fg: UI.textDim, + }); + evictionMarkers.add(node); + shell.transcript.add(node, 1); + } + } + + const index = shell.streamLog.length - 1; + shell.transcript.add( + createStreamRowRenderable( + shell, + row, + gapBefore(shell, index), + labelBefore(shell, index), + shell.streamLogBase + index, + ), + ); + paintChrome(shell); +} + +/** + * Drop every row from absolute `length` onward on the log `appendStreamRow` + * targets. + * + * A committed inference attempt that fails is re-streamed from scratch, so the + * transcript has to retract what the failed attempt already painted instead of + * letting the replay pile up underneath it. A boundary the retention cap has + * already evicted has nothing left to retract, so this is a no-op rather than + * mis-truncating the tail that replaced it. + */ +export function truncateStreamRows(shell: AppShell, length: number): void { + const observing = shell.observe !== null && shell.parentStreamLog !== null; + const log = observing ? shell.parentStreamLog! : shell.streamLog; + const base = observing ? (shell.parentStreamLogBase ?? 0) : shell.streamLogBase; + const local = length - base; + if (local < 0 || local >= log.length) return; + log.length = local; + if (log !== shell.streamLog) return; + shell.lineCount = shell.streamLog.length; + repaintTranscriptWindow(shell); + paintChrome(shell); +} + +/** + * Empty the visible transcript for a fresh session (/clear, /new). + * + * Backend session rotation lives in the runner; this is only the on-screen wipe + * the OpenTUI host must own after the Ink App path went away. Observe mode is + * dropped first so a child view cannot keep painting into a cleared parent. + * Retention base resets so the screen matches a brand-new session, not a window + * over an empty retained log with a stale eviction marker. + */ +export function clearTranscript(shell: AppShell): void { + if (shell.observe !== null) { + // Drop observe without the "left observe" system row — the whole log is + // about to go and a farewell row would only flash then vanish. + shell.observe = null; + shell.parentStreamLog = null; + shell.parentStreamLogBase = null; + let guard = 4; + while (guard-- > 0 && focusOwner(shell.focus) === "observe") { + shell.focus = popFocus(shell.focus); + } + const frames = shell.focus.frames.filter((f) => f.target !== "observe"); + if (frames.length !== shell.focus.frames.length) { + shell.focus = { frames }; + } + setChromeZones(shell, { agents: null }); + applyFocus(shell); + } + shell.streamLog.length = 0; + shell.streamLogBase = 0; + shell.lineCount = 0; + shell.parentStreamLog = null; + shell.parentStreamLogBase = null; + repaintTranscriptWindow(shell); + paintChrome(shell); +} + +/** + * Rewrite an already-appended transcript row in place. + * + * Streaming assistant and thinking bodies grow token by token; the bridge keeps + * one open row and replaces it on every delta rather than appending a row per + * token. Repaints only the affected node while the log fits without windowing. + * + * `index` is absolute (see `streamLogBase`); a row the retention cap has + * already evicted is a no-op rather than corrupting an unrelated row at the + * same array slot. + */ +export function replaceStreamRowAt(shell: AppShell, index: number, row: StreamRow): void { + if (shell.observe !== null && shell.parentStreamLog !== null) { + const parentLocal = index - (shell.parentStreamLogBase ?? 0); + if (parentLocal >= 0 && parentLocal < shell.parentStreamLog.length) { + shell.parentStreamLog[parentLocal] = row; + } + return; + } + const local = index - shell.streamLogBase; + if (local < 0 || local >= shell.streamLog.length) return; + shell.streamLog[local] = row; + + const children = transcriptRowChildren(shell); + // A raw appendTranscript line breaks the 1:1 node↔row mapping; fall back to + // a full repaint, which derives every node from the log. + if (children.length !== shell.streamLog.length) { + repaintTranscriptWindow(shell); + paintChrome(shell); + return; + } + + const stale = children[local]; + if (stale && retextStreamRow(shell, stale, row, labelBefore(shell, local))) { + paintChrome(shell); + return; + } + if (stale) { + shell.transcript.remove(stale); + destroySubtree(stale); + } + // Raw child list is spacer (+ eviction notice, if any) then rows; see + // `transcriptRowOffset` (see `transcriptRowChildren` for why row 0 is not + // simply index 1). + shell.transcript.add( + createStreamRowRenderable( + shell, + row, + gapBefore(shell, local), + labelBefore(shell, local), + index, + ), + local + transcriptRowOffset(shell), + ); + paintChrome(shell); +} + +/** + * Rebuild the transcript paint tree from `streamLog` — every retained row, + * not a smaller window of it. `streamLog` is already capped at + * `MAX_RETAINED_STREAM_ROWS`, so this is O(cap), and painting all of it is + * what makes the full retained history reachable by scrolling. + */ +export function repaintTranscriptWindow(shell: AppShell): void { + clearLandingMark(shell); + shell.agentVoices = new Set(agentVoicesIn(shell.streamLog)); + // The bottom-anchor spacer (index 0) stays; the eviction notice (if any) + // and every row get torn down and rebuilt from the log. + for (const child of shell.transcript.getChildren().slice(1)) { + shell.transcript.remove(child); + destroySubtree(child); + } + + // Rows evicted by the retention cap are gone for good, not just scrolled + // past — say so, or the boundary reads as the true start of history. + if (shell.streamLogBase > 0) { + const marker = new TextRenderable(shell.renderer as CliRenderer, { + content: evictedRowsNotice(shell.streamLogBase), + fg: UI.textDim, + }); + evictionMarkers.add(marker); + shell.transcript.add(marker); + } + + shell.streamLog.forEach((row, local) => { + shell.transcript.add( + createStreamRowRenderable( + shell, + row, + gapBefore(shell, local), + labelBefore(shell, local), + shell.streamLogBase + local, + ), + ); + }); +} + +/** + * Tear the landing down on the first transcript row. + * + * The prompt box travels from the middle of the screen to the bottom, which is + * a jump; it happens on the same frame as the operator's own first row so it + * reads as the screen answering them rather than as the layout twitching. + * + * System/runtime notices deferred while the hero was up are flushed into the + * transcript here so they stay durable once the session has content, without + * ever having stolen the mountain on the way in. + */ +function clearLandingMark(shell: AppShell): void { + const bag = shellInternals(shell); + const landing = bag?.landing; + if (bag === undefined || landing === null || landing === undefined) return; + bag.landing = null; + bag.landingIdleTimerCancel?.(); + bag.landingIdleTimerCancel = null; + shell.transcript.remove(landing.above.box); + destroySubtree(landing.above.box); + shell.root.remove(landing.below); + destroySubtree(landing.below); + relayout(shell); + + const notice = bag.landingNotice; + if (notice !== null) { + bag.landingNotice = null; + appendStreamRow(shell, { role: "system", text: notice }); + } + + const deferred = bag.landingDeferredRows; + if (deferred.length > 0) { + bag.landingDeferredRows = []; + // The notice strip held the latest wording while the mark was up; the + // rows themselves are durable now, so drop the flash rather than double-paint. + setStatusFlash(shell, null); + for (const row of deferred) appendStreamRow(shell, row); + } +} + +/** + * Cadence of the mount-scoped idle repaint timer armed in `createAppShell`. + * The snow only needs to advance about half a row per second, so 8fps is + * comfortably enough to read as motion. + */ +export const LANDING_IDLE_REPAINT_INTERVAL_MS = 125; + +/** + * Repaint the landing mark for `nowMs`. `animating` runs the mountain's + * draw/fill/fade timeline; anything else holds its filled frame. No-op once + * the landing is gone, so the caller can drive it unconditionally. + * + * Always repaints while the landing is up, even when `animating` is false: + * the landing is idle by definition (no turn processing), and snow still + * needs to drift across a frozen mountain. Driven by the mount-scoped timer + * armed in `createAppShell` rather than a render event, so the repaint + * cadence is independent of however often the renderer happens to paint. + * + * Reduced motion is a mount-time flag on the shell, not a per-paint + * argument: it freezes the mountain and drops snow even when a caller + * asks for `animating`. + */ +export function paintLanding(shell: AppShell, nowMs: number, animating: boolean): void { + const bag = shellInternals(shell); + const landing = bag?.landing; + if (bag === undefined || landing === null || landing === undefined) return; + const motion = bag.reducedMotion ? false : animating; + bag.landingAnimating = motion; + bag.landingNowMs = nowMs; + paintLandingMark(landing.above, nowMs, !motion, bag.reducedMotion); +} + +/** + * Fill the prompt from a landing starter. Returns false when the key selects + * nothing, the landing is gone, or the operator has already typed. + */ +export function applyLandingSuggestion(shell: AppShell, key: string): boolean { + if (!isLanding(shell) || shell.prompt.value.length > 0) return false; + const suggestion = landingSuggestionFor(key); + if (suggestion === null) return false; + shell.prompt.value = suggestion.prompt; + return true; +} + +/** + * Build the paint node for one transcript row, including its writer label + * when this row opens a new block (see `blockLabel`). The label is one text + * child stacked above the row's own node in a column wrapper — never a + * separate transcript child — so the 1:1 log-index-to-child mapping holds. + */ +export function createStreamRowRenderable( + shell: AppShell, + row: StreamRow, + marginTop = 0, + label: string | null = null, + index?: number, +): TextRenderable | BoxRenderable { + const ctx = shell.renderer as CliRenderer; + const layout = transcriptRowLayout(shell); + // `index` is absolute (see `streamLogBase`), so it stays the row's index + // for as long as its node lives even if the retention cap trims the array + // out from underneath it later. `toggleRowExpandedAt` converts it back to + // a local array position at click time, not here. + const onToggle = + index === undefined || !isCollapsibleRow(row) + ? undefined + : () => { + toggleRowExpandedAt(shell, index); + }; + const node = buildRowNode(ctx, row, layout, onToggle); + + if (label === null) { + node.marginTop = marginTop; + return node; + } + + const wrapper = new BoxRenderable(ctx, { flexDirection: "column", width: "100%", marginTop }); + wrapper.add(new TextRenderable(ctx, { content: label, fg: UI.textDim })); + wrapper.add(node); + return wrapper; +} + +export function setHeader(shell: AppShell, text: string): void { + shell.baseTitle = text; + paintChrome(shell); +} + +export function setPendingQueue(shell: AppShell, count: number): void { + let s = shell.session; + const target = Math.max(0, Math.floor(count)); + while (badgeCount(s) > target) { + s = { ...s, items: s.items.slice(0, -1) }; + } + while (badgeCount(s) < target) { + s = enqueue(s, `pad-${badgeCount(s) + 1}`); + } + shell.session = s; + paintChrome(shell); +} + +export function setShellRunState(shell: AppShell, run: RunState): void { + shell.session = setRunState(shell.session, run); + paintChrome(shell); +} + +/** + * Expand or collapse every transcript row that hides a body behind a summary: + * loaded skills, summarised tool calls, settled reasoning. Same key as the + * overlay's collapsed payloads, so the product has one expand idiom. + * + * All-or-nothing rather than one row at a time: with several collapsed rows on + * screen, expanding the newest and leaving the rest reads as the key having + * missed. Any row still collapsed means the whole set opens; only once nothing + * is left to open does the key close them again. + * + * False when no row on the log can expand at all. + */ +/** + * Expand or collapse exactly one transcript row — what a click on its arrow + * means. The key stays bulk (see `toggleCollapsedRow`): a pointer says *this + * one*, a key with nothing under it can only mean all of them. + * + * False when that row hides nothing. + */ +/** `index` is absolute (see `streamLogBase`), matching the index closures built off `createStreamRowRenderable` carry. */ +export function toggleRowExpandedAt(shell: AppShell, index: number): boolean { + const row = shell.streamLog[index - shell.streamLogBase]; + if (row === undefined || !isCollapsibleRow(row)) return false; + replaceStreamRowAt(shell, index, { ...row, expanded: row.expanded !== true }); + return true; +} + +export function toggleCollapsedRow(shell: AppShell): boolean { + const collapsible = shell.streamLog.flatMap((row, local) => + row !== undefined && isCollapsibleRow(row) ? [{ row, index: shell.streamLogBase + local }] : [], + ); + if (collapsible.length === 0) return false; + const expand = collapsible.some(({ row }) => row.expanded !== true); + for (const { row, index } of collapsible) { + if ((row.expanded === true) === expand) continue; + replaceStreamRowAt(shell, index, { ...row, expanded: expand }); + } + return true; +} + +/** Bracket marker per task status; a trailer row (status null) gets none. */ +function taskStatusMarker(status: TaskPanelRow["status"]): string { + switch (status) { + case "todo": + return "[ ] "; + case "doing": + return "[~] "; + case "done": + return "[x] "; + case "cancelled": + return "[-] "; + case null: + return ""; + } +} + +/** + * Fit a row's label + tail into `maxWidth` terminal columns, ellipsizing the + * label (agentId + description — free-form, model-authored, routinely long, + * and not guaranteed narrow: CJK and emoji run two columns per code point) + * before ever touching the tail (elapsed/tool/stalled). The tail carries + * the fact an operator glances at the panel to see, so it is preserved + * whole or not shown at all. Measured and sliced in columns via + * `stringWidth`/`sliceToWidth` (`src/tui/view/height.ts`) rather than UTF-16 + * code units — `.length` undercounts wide glyphs, which is exactly the class + * of bug that would make a row overflow its zone and wrap. + */ +function fitAgentRow(row: AgentPanelRow, maxWidth: number): string { + const full = ` ${row.label}${row.tail}`; + if (stringWidth(full) <= maxWidth) { + // Push every lane's tail to the right edge so the clocks line up as a + // column. A lane that has been silent far longer than its neighbours then + // stands out of that column by its shape, before any of it is read — which + // is the one thing the board has to get right at a glance. + if (row.kind === "lane") { + const pad = maxWidth - stringWidth(full); + return ` ${row.label}${" ".repeat(Math.max(0, pad))}${row.tail}`; + } + return full; + } + + const leadingSpace = 1; + const ellipsis = 1; + const budget = maxWidth - leadingSpace - stringWidth(row.tail) - ellipsis; + if (budget <= 0) { + // Not even the tail fits at full width — keep as much of the tail's + // trailing end (where the "stalled" marker lives) as there is room for, + // rather than an unreadable sliver of the label. + return ` ${sliceTailToWidth(row.tail, maxWidth - leadingSpace)}`; + } + return ` ${sliceToWidth(row.label, budget)}…${row.tail}`; +} + +/** + * Fit a task row's status marker + label into `maxWidth` columns, same + * ellipsis discipline as `fitAgentRow`: the marker (what says done vs. + * pending) is preserved whole, the free-form title is what gives way. + */ +function fitTaskRow(row: TaskPanelRow, maxWidth: number): string { + const marker = taskStatusMarker(row.status); + const full = ` ${marker}${row.label}`; + if (stringWidth(full) <= maxWidth) return full; + + const leadingSpace = 1; + const ellipsis = 1; + const budget = maxWidth - leadingSpace - stringWidth(marker) - ellipsis; + if (budget <= 0) return ` ${sliceToWidth(marker, maxWidth - leadingSpace)}`; + return ` ${marker}${sliceToWidth(row.label, budget)}…`; +} + +/** Rebuild taskBox's row children to match the requested rows exactly. */ +function renderTasksRows(shell: AppShell, rows: readonly TaskPanelRow[], maxWidth: number): void { + for (const child of [...shell.taskBox.getChildren()]) { + shell.taskBox.remove(child); + destroySubtree(child); + } + for (const row of rows) { + const text = new TextRenderable(shell.renderer as CliRenderer, { + content: fitTaskRow(row, maxWidth), + fg: row.status === "done" ? UI.done : row.status === "doing" ? UI.text : UI.textDim, + }); + shell.taskBox.add(text); + } +} + +/** Paint tone for one agents-strip row (cream live / orange trouble / green done). */ +function agentRowFg(row: AgentPanelRow): string { + if (row.kind === "more" || row.kind === "header") return UI.textDim; + if (row.stalled || row.status === "failed") return UI.action; + if (row.status === "done") return UI.done; + if (row.status === "cancelled" || row.status === "interrupted") return UI.textDim; + return UI.text; +} + +/** Rebuild agentsBox's row children to match the requested rows exactly. */ +function renderAgentsRows(shell: AppShell, rows: readonly AgentPanelRow[], maxWidth: number): void { + for (const child of [...shell.agentsBox.getChildren()]) { + shell.agentsBox.remove(child); + destroySubtree(child); + } + for (const row of rows) { + // Live lanes use primary cream (`UI.text`) — the Amp/Codex strip is body + // text, not bronze in-flight chrome. Stalled / failed keep the decision + // orange; done linger is green; cancelled / "+N more" sit back in dim. + const text = new TextRenderable(shell.renderer as CliRenderer, { + content: fitAgentRow(row, maxWidth), + fg: agentRowFg(row), + }); + shell.agentsBox.add(text); + } +} + +/** + * Set agents/task chrome zone content (null/empty = hide zone). + * Heights come from geometry resolve — never guessed. + */ +function taskRowsEqual(a: readonly TaskPanelRow[], b: readonly TaskPanelRow[]): boolean { + return ( + a.length === b.length && + a.every((row, i) => { + const other = b[i]; + return other !== undefined && row.label === other.label && row.status === other.status; + }) + ); +} + +export function setChromeZones(shell: AppShell, content: ChromeZoneContent): void { + const bag = shellInternals(shell); + if (!bag) return; + + let taskChanged = false; + if (content.task !== undefined) { + bag.chrome.tasksRaw = content.task ?? []; + const rendered = bag.tasksPanelHidden ? [] : bag.chrome.tasksRaw; + taskChanged = !taskRowsEqual(rendered, bag.chrome.task); + bag.chrome.task = rendered; + } + let agentsChanged = false; + if (content.agents !== undefined) { + const next = content.agents ?? []; + agentsChanged = + next.length !== bag.chrome.agents.length || + next.some((row, i) => { + const prev = bag.chrome.agents[i]; + return ( + prev === undefined || + row.label !== prev.label || + row.tail !== prev.tail || + row.stalled !== prev.stalled || + row.status !== prev.status || + row.kind !== prev.kind + ); + }); + bag.chrome.agents = next; + } + + const taskRowCount = bag.chrome.task.length; + const agentsRowCount = bag.chrome.agents.length; + + // Rebuilding N TextRenderable children is real node churn; skip it unless + // the panel's actual lines changed (not every push carries new data). + if (taskChanged) { + renderTasksRows(shell, bag.chrome.task, shell.layout.contentWidth); + } + // Only a zone appearing/disappearing or its row count changing alters the + // row budget; retitling a zone whose row count is unchanged must not + // re-resolve and re-apply the whole layout. + const budgetUnchanged = + taskRowCount === bag.visibility.task && agentsRowCount === bag.visibility.agents; + if (!budgetUnchanged) { + relayout(shell, { + visibility: { + ...bag.visibility, + task: taskRowCount, + agents: agentsRowCount, + }, + overlayMode: bag.overlayMode, + ...(bag.overlayBodyRows !== undefined ? { overlayBodyRows: bag.overlayBodyRows } : {}), + }); + } + + // Painted after the resolver has spoken, and only ever as many rows as it + // granted: a board that paints past its box lands on top of the transcript + // and tears down the renderables underneath it. Full content width (stack). + if (agentsChanged || !budgetUnchanged) { + renderAgentsRows( + shell, + clampBoardRows(bag.chrome.agents, shell.layout.heights.agents), + shell.layout.contentWidth, + ); + } + if (budgetUnchanged) paintChrome(shell); +} + +/** How long a panel-visibility flash holds the notice row. */ +const PANEL_TOGGLE_FLASH_MS = 3000; + +/** + * Toggle the task-list panel visible/hidden without touching the live task + * data underneath it — un-hiding shows whatever manage_tasks last wrote, + * not a stale snapshot from before the hide. The flag lives on the shell's + * internals in memory for the shell's lifetime; nothing is written to + * storage, so it does not survive a restart. + */ +export function toggleTasksPanel(shell: AppShell): void { + const bag = shellInternals(shell); + if (!bag) return; + bag.tasksPanelHidden = !bag.tasksPanelHidden; + const hiding = bag.tasksPanelHidden; + setChromeZones(shell, { task: bag.chrome.tasksRaw }); + // A flash, not a transcript row: which panels are showing is a property of + // the current screen, not something that happened in the conversation. + setStatusFlash(shell, hiding ? "task list hidden · alt+t to show" : "task list shown", { + ttlMs: PANEL_TOGGLE_FLASH_MS, + }); +} diff --git a/src/tui/shell/copy.ts b/src/tui/shell/copy.ts new file mode 100644 index 000000000..5ebbf9f5f --- /dev/null +++ b/src/tui/shell/copy.ts @@ -0,0 +1,62 @@ +/** + * Copy mode, selection copy, mouse capture toggle. + */ +import { RUNTIME_FLASH_MS } from "../runtime-notices.js"; +import { buildCopyTargets } from "../copy-path.js"; + +import { type AppShell } from "./internals.js"; +import { openListOverlay } from "./overlay-host.js"; +import { setStatusFlash } from "./chrome.js"; + +/** + * Enter copy mode (Alt+C / palette copy_active): freeze targets from the + * active streamLog, open inset overlay with the last target selected. + * Empty log → status flash only; no stream mutation. + */ +export function enterCopyMode(shell: AppShell): boolean { + // Single host: do not stack copy over another primary overlay. + if (shell.overlayList) return false; + + const targets = buildCopyTargets(shell.streamLog); + if (targets.length === 0) { + setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); + return false; + } + + shell.copyTargets = targets; + const labels = targets.map((t) => `${t.label}: ${t.preview}`); + openListOverlay(shell, { + kind: "copy", + title: "copy · Enter copies the selected item", + items: labels, + activeIndex: targets.length - 1, + frameId: "copy-mode", + }); + return true; +} + +/** + * Alt+M: take DEC mouse reporting, or hand it back to the terminal. + * Reporting is on by default so wheel scroll and click-to-expand work; + * releasing it restores the terminal's own drag-select and copy. + * Returns the new enabled state, or null when the host exposes no control. + */ +export function toggleMouseCapture(shell: AppShell): boolean | null { + const port = shell.mouseCapture; + if (!port) { + setStatusFlash(shell, "mouse reporting is not controllable here", { + ttlMs: RUNTIME_FLASH_MS, + }); + return null; + } + const next = !port.get(); + port.set(next); + setStatusFlash( + shell, + next + ? "Mouse captured · drag text to copy · click to expand · Alt+M for native select" + : "Mouse released · drag to select and copy as usual · Alt+M to click rows", + { ttlMs: RUNTIME_FLASH_MS }, + ); + return next; +} diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts new file mode 100644 index 000000000..98d10ce16 --- /dev/null +++ b/src/tui/shell/index.ts @@ -0,0 +1,568 @@ +/** + * AppShell assembly: renderable tree construction, store wiring, event registration. + */ +import { + BoxRenderable, + CliRenderEvents, + ScrollBoxRenderable, + TextRenderable, + type CliRenderer, + type Selection, +} from "@opentui/core"; +import { createSentHistoryBrowse } from "../sent-message-history.js"; +import { createPromptInput } from "../prompt-input.js"; +import { RUNTIME_FLASH_MS } from "../runtime-notices.js"; +import { createFocusState } from "../focus/index.js"; +import { PROMPT_IDLE_ROWS, resolveGeometry } from "../geometry/index.js"; +import { + createLandingAbove, + createLandingBelow, + LANDING_VERSION, + landingBelowContent, + splitLandingRows, + versionBadgeVisible, +} from "../landing.js"; +import { destroySubtree } from "../teardown.js"; +import { createRecordingClipboard } from "../copy-path.js"; +import { copyFinishedSelection } from "../selection-copy.js"; +import { badgeCount, createSessionQueue, enqueue } from "../session-queue.js"; +import { UI } from "../theme.js"; +import { createOverlayView, isDecisionOverlay } from "../overlay-view.js"; +import { emptyKillRing } from "../prompt-kill-ring.js"; +import { flushStreamRowUpdates } from "../runtime-bridge.js"; + +import { + type AppShell, + type AppShellOptions, + EMPTY_PRIMARY_BINDINGS, + flashTimers, + initShellInternals, + setPaletteOnCommand, + setPaletteOnObserveRequest, + setTranscriptSpacer, + shellFlashSchedules, + shellInternals, + type ShellRenderer, +} from "./internals.js"; +import { defaultVisibility, terminalForGeometry, terminalOf } from "./layout.js"; +import { relayoutOverlayHost } from "./overlay-list.js"; +import { + abortOverlayHostReservations, + applyOverlayBodyText, + closeInsetOverlay, + dropDeferredCommandOverlay, +} from "./overlay-host.js"; +import { + applyFocus, + applyLayout, + LANDING_IDLE_REPAINT_INTERVAL_MS, + paintLanding, + relayout, + setStatusFlash, + syncNoticeAfterLayout, + syncPromptRows, + syncTranscriptSpacer, +} from "./chrome.js"; +import { clearPendingAttachments, submitPrompt, syncPromptHighlights } from "./prompt.js"; +import { createShellKeyHandlers, routePromptWheelToTranscript } from "./keys.js"; + +const DEFAULT_TITLE = "corbits"; + +const DEFAULT_OVERLAY_ITEMS = [ + "Allow bash: ls", + "Allow bash: cat README", + "Deny this tool", + "Always allow bash", +] as const; + +/** + * Build the app shell frame on an OpenTUI renderer. + * Mounts sticky transcript / overlay host / transient notice / prompt box. + */ +export function createAppShell(renderer: ShellRenderer, options?: AppShellOptions): AppShell { + const title = options?.title ?? DEFAULT_TITLE; + const visibility = defaultVisibility(options?.visibility); + const promptContentRows = options?.promptContentRows ?? PROMPT_IDLE_ROWS; + const wireKeys = options?.wireKeys !== false; + const mount = options?.mount !== false; + // A freshly mounted shell has nothing in flight; the runner sets busy when a + // turn starts. Defaulting to busy made the landing screen offer "^C stop". + const run = options?.run ?? "idle"; + const overlayItems = options?.overlayItems ?? [...DEFAULT_OVERLAY_ITEMS]; + const paletteCatalogOpt = options?.paletteCatalog ?? null; + const onCommandOpt = options?.onCommand; + const onObserveRequestOpt = options?.onObserveRequest; + const reducedMotion = options?.reducedMotion === true; + + const terminal = terminalOf(renderer, options?.terminal); + const layout = resolveGeometry({ + terminal: terminalForGeometry(terminal), + visibility, + overlay: { mode: "closed" }, + promptContentRows, + }); + + const ctx = renderer as CliRenderer; + + const root = new BoxRenderable(ctx, { + id: "app-shell", + width: "100%", + height: "100%", + flexDirection: "column", + backgroundColor: UI.ground, + paddingLeft: layout.sideMargin, + paddingRight: layout.sideMargin, + }); + + // One optical gutter for the whole shell: every zone is a child of the padded + // root, so nothing can drift out of alignment with the rest. + const topPad = new BoxRenderable(ctx, { + id: "shell-top-pad", + width: "100%", + height: 1, + flexShrink: 0, + backgroundColor: UI.ground, + }); + + // Same gutter, other end: keeps the prompt box off the terminal's last row. + const bottomPad = new BoxRenderable(ctx, { + id: "shell-bottom-pad", + width: "100%", + height: 1, + flexShrink: 0, + backgroundColor: UI.ground, + }); + + // Persistent chrome, not part of the landing composition (`landing.ts` + // never renders it, unlike the old in-hero version line): its own row at + // the very foot of root's column, after everything else, right-aligned. + // Every other zone here already toggles a reserved row on/off by terminal + // size (taskBox, agentsBox, bottomPad) rather than floating over content, + // so this follows the same pattern — the row only exists (and can only + // move the prompt box up by exactly one line) at the size threshold where + // `versionBadgeVisible` already says the badge itself should degrade away, + // well before anything else in the shell would need to. + const versionRow = new BoxRenderable(ctx, { + id: "shell-version-row", + width: "100%", + height: 1, + flexShrink: 0, + flexDirection: "row", + justifyContent: "flex-end", + backgroundColor: UI.ground, + visible: versionBadgeVisible(terminal.columns, terminal.rows), + }); + const versionBadge = new TextRenderable(ctx, { + id: "shell-version-badge", + content: LANDING_VERSION, + fg: UI.textFaint, + }); + versionRow.add(versionBadge); + + // Optional chrome zones (off by default; setChromeZones turns them on). + const taskBox = new BoxRenderable(ctx, { + id: "shell-task", + width: "100%", + height: 1, + flexShrink: 0, + flexDirection: "column", + backgroundColor: UI.ground, + visible: false, + }); + + const agentsBox = new BoxRenderable(ctx, { + id: "shell-agents", + width: "100%", + height: 1, + flexShrink: 0, + flexDirection: "column", + backgroundColor: UI.ground, + visible: false, + }); + + const transcript = new ScrollBoxRenderable(ctx, { + id: "shell-transcript", + width: "100%", + height: Math.max(1, layout.heights.transcript), + flexShrink: 0, + stickyScroll: true, + stickyStart: "bottom", + scrollY: true, + focusable: true, + rootOptions: { backgroundColor: UI.ground }, + contentOptions: { backgroundColor: UI.ground }, + viewportOptions: { backgroundColor: UI.ground }, + }); + // The transcript scrolls with the keyboard, and the bar spent a column on + // every row to say so. Position is legible from the content itself. + transcript.verticalScrollBar.visible = false; + transcript.horizontalScrollBar.visible = false; + + // Leading filler that bottom-anchors a short transcript; see + // `syncTranscriptSpacer`. Zero height until the first sync call. + const transcriptSpacer = new BoxRenderable(ctx, { + id: "shell-transcript-spacer", + width: "100%", + height: 0, + flexShrink: 0, + backgroundColor: UI.ground, + }); + transcript.add(transcriptSpacer); + + const landingAbove = createLandingAbove(ctx, reducedMotion); + const landingBelowState = landingBelowContent({ + rows: splitLandingRows(layout.heights.transcript).below, + columns: layout.contentWidth, + telemetryNotice: options?.telemetryNotice, + }); + const landingBelow = createLandingBelow(ctx, landingBelowState); + + const overlayView = createOverlayView(ctx); + const { host: overlayHost, title: overlayTitle, body: overlayBody } = overlayView; + + // Transient only: the resolver gives it a row when paintChrome asks for one. + const notice = new TextRenderable(ctx, { + id: "shell-notice", + height: Math.max(1, layout.heights.notice), + content: "", + fg: UI.textDim, + visible: layout.heights.notice > 0, + }); + + const promptBox = new BoxRenderable(ctx, { + id: "shell-prompt-region", + width: "100%", + height: Math.max(1, layout.heights.prompt), + flexShrink: 0, + flexDirection: "column", + backgroundColor: UI.ground, + }); + // The box is drawn in three pieces rather than as one bordered Box because + // both horizontal rules carry content the frame's own border cannot: a + // right-aligned label that the rule breaks around, and an animated lockup + // whose cells are individually coloured. + const promptTopRule = new TextRenderable(ctx, { + id: "shell-prompt-top-rule", + height: 1, + content: "", + fg: UI.textFaint, + }); + const promptBottomRule = new TextRenderable(ctx, { + id: "shell-prompt-bottom-rule", + height: 1, + content: "", + fg: UI.textFaint, + }); + const promptField = new BoxRenderable(ctx, { + id: "shell-prompt-frame", + width: "100%", + height: Math.max(1, layout.heights.prompt - 2), + flexShrink: 0, + border: ["left", "right"], + borderStyle: "rounded", + borderColor: UI.textFaint, + focusedBorderColor: UI.textDim, + backgroundColor: UI.ground, + paddingLeft: 1, + paddingRight: 1, + }); + const prompt = createPromptInput(ctx, { + id: "shell-prompt", + width: "100%", + height: Math.max(1, layout.heights.prompt - 2), + placeholder: "message…", + backgroundColor: UI.ground, + focusedBackgroundColor: UI.ground, + textColor: UI.text, + cursorColor: UI.text, + placeholderColor: UI.textFaint, + }); + routePromptWheelToTranscript(prompt, transcript); + promptField.add(prompt); + promptBox.add(promptTopRule); + promptBox.add(promptField); + promptBox.add(promptBottomRule); + + root.add(topPad); + root.add(transcript); + root.add(overlayHost); + root.add(agentsBox); + root.add(taskBox); + root.add(notice); + root.add(promptBox); + root.add(landingBelow); + root.add(bottomPad); + root.add(versionRow); + + if (mount) { + renderer.root.add(root); + } + + let disposed = false; + let session = createSessionQueue(run); + const seedPending = Math.max(0, Math.floor(options?.pendingQueue ?? 0)); + for (let i = 0; i < seedPending; i++) { + session = enqueue(session, `seed-${i + 1}`); + } + + const onEnter = (): void => { + if (disposed || shell.overlayList) return; + if (shellInternals(shell)?.inputSuspended === true) return; + // Mid-run Enter soft-steers (deliver at next tool.boundary); the bridge + // upgrades it to an immediate new turn while the parent is idle with a + // live fleet (idle-with-fleet, CL-7057). Alt+Enter is follow-up (quiet + // wait until idle). Idle sends ignore "kind". + submitPrompt(shell, "steer"); + }; + + // Per frame rather than per keystroke: the editor view's wrapped-line table is + // rebuilt during layout, so on the content-changed callback it still describes + // the text before the edit and the box would size itself one keystroke behind. + const onFrame = (): void => { + if (disposed) return; + // Streaming row retexts coalesce here: deltas only mark the open row + // dirty, and this frame hook applies the accumulated text once — the + // row's markdown body is reparsed whole per retext, so per-delta + // replacement is quadratic across a message. + flushStreamRowUpdates(shell); + syncPromptRows(shell); + syncPromptHighlights(shell); + // Applied after a natural render, not at mutation time: a row's own box + // needs a layout pass to size itself, and claiming the padding first + // starves that pass of room to lay the row out in. + syncTranscriptSpacer(shell); + syncNoticeAfterLayout(shell); + }; + + const onResize = (width: number, height: number): void => { + if (disposed) return; + const bag = shellInternals(shell); + // A decision overlay's body was shaped against the old height's context + // budget; a shorter terminal can no longer afford as much of it without + // crowding out the choices, so it is re-shaped before asking for rows. + if (shell.overlayList && isDecisionOverlay(shell.overlayKind) && bag) { + applyOverlayBodyText(shell, bag.overlayRawBodyText, 0, height); + relayoutOverlayHost(shell, shell.overlayItems.length); + } + relayout(shell, { + columns: width, + rows: height, + overlayMode: bag?.overlayMode ?? "closed", + ...(bag?.overlayBodyRows !== undefined ? { overlayBodyRows: bag.overlayBodyRows } : {}), + }); + }; + + renderer.on(CliRenderEvents.FRAME, onFrame); + renderer.on(CliRenderEvents.RESIZE, onResize); + + // Declared before shell so dispose can off() the same function reference; + // body closes over shell after createAppShell finishes assigning it. + const onSelection = (selection: Selection): void => { + if (disposed) return; + copyFinishedSelection( + { + clipboard: shell.clipboard, + flash: (text) => setStatusFlash(shell, text, { ttlMs: RUNTIME_FLASH_MS }), + clearSelection: () => { + renderer.clearSelection(); + }, + }, + selection, + ); + }; + renderer.on(CliRenderEvents.SELECTION, onSelection); + + const shell: AppShell = { + renderer, + root, + topPad, + bottomPad, + versionRow, + taskBox, + agentsBox, + transcript, + overlayView, + overlayHost, + overlayTitle, + overlayBody, + prompt, + promptBox, + promptField, + promptTopRule, + promptBottomRule, + notice, + layout, + focus: createFocusState(), + session, + pendingQueue: badgeCount(session), + lineCount: 0, + streamLog: [], + streamLogBase: 0, + agentVoices: new Set(), + baseTitle: title, + modelLabel: null, + workspace: { cwd: options?.cwd ?? process.cwd(), branch: null }, + overlayList: null, + overlayItems, + overlayKind: null, + overlayBodyLines: [], + overlayBodyFgs: [], + paletteCommands: [], + clipboard: options?.clipboard ?? createRecordingClipboard(), + mouseCapture: options?.mouseCapture ?? null, + copyTargets: null, + statusFlash: null, + mcpNeedsAuth: [], + pluginNeedsAttention: false, + lockupNowMs: 0, + inFlightTool: null, + lockupAnimating: false, + lockupPhase: null, + lockupChangedMs: 0, + lockupRampPhase: null, + lockupStalledForMs: null, + costContext: null, + observe: null, + parentStreamLog: null, + parentStreamLogBase: null, + promptKillRing: emptyKillRing, + pendingAttachments: [], + sentHistory: createSentHistoryBrowse([]), + disposed: false, + dispose: () => { + if (disposed) return; + dropDeferredCommandOverlay(shell); + // Unwind a stacked palette first, then let the primary overlay's owner + // release subscriptions or settle awaited cancellation exactly once. + let overlayGuard = 4; + while (shell.overlayList !== null && overlayGuard-- > 0) closeInsetOverlay(shell); + abortOverlayHostReservations(shell); + disposed = true; + shell.disposed = true; + // Quit paths that skip idle Ctrl+C still drop Corbits-created files. + clearPendingAttachments(shell); + if (wireKeys) { + renderer.keyInput.off("keypress", onKey); + renderer.keyInput.off("paste", onPaste); + prompt.onSubmit = undefined; + } + renderer.off(CliRenderEvents.FRAME, onFrame); + renderer.off(CliRenderEvents.RESIZE, onResize); + renderer.off(CliRenderEvents.SELECTION, onSelection); + shellInternals(shell)?.landingIdleTimerCancel?.(); + flashTimers.get(shell)?.(); + flashTimers.delete(shell); + try { + renderer.root.remove(root); + } catch { + // Root may already be torn down in tests. + } + destroySubtree(root); + }, + }; + + if (options?.flashSchedule) { + shellFlashSchedules.set(shell, options.flashSchedule); + } + + initShellInternals(shell, { + visibility, + promptContentRows, + overlayMode: "closed", + overlayBodyRows: undefined, + overlayMinBodyRows: undefined, + overlayRawBodyText: "", + priorOverlay: null, + overlayGeneration: 0, + primaryBindings: { ...EMPTY_PRIMARY_BINDINGS }, + overlayEchoChoice: true, + inputSuspended: false, + overlayAnswer: null, + overlayTitleText: "", + overlayClosedListeners: new Set(), + deferredCommandOverlay: null, + deferredFlushScheduled: false, + overlayHostReservations: 0, + overlayReservationEpoch: 0, + paletteCatalog: paletteCatalogOpt, + paletteFilter: null, + listFilter: null, + landing: { above: landingAbove, below: landingBelow }, + landingNotice: options?.telemetryNotice ?? null, + landingDeferredRows: [], + landingBelow: landingBelowState, + landingSuggestionsVisible: true, + landingAnimating: false, + landingNowMs: 0, + reducedMotion, + landingIdleTimerCancel: null, + chrome: { task: [], tasksRaw: [], agents: [] }, + // CL-5847: the manage_tasks checklist panel is hidden by default. The + // panel owns too much of the screen for the operator to want it forced + // into view on a fresh shell; Alt+T (toggleTasksPanel) opts in for the + // shell's lifetime. Live task data still lands in tasksRaw while hidden, + // so the first toggle shows current data rather than a stale snapshot. + tasksPanelHidden: true, + }); + // The landing's snow needs a frame source that keeps running while the + // turn monitor is deliberately quiet (idle, no session yet). A plain timer + // armed at mount is that source: it does not depend on the renderer + // scheduling further frames, so it cannot stall the way riding the + // renderer's FRAME event does: FRAME follows dirty rows, not a clock. + // + // Only repaints while idle (`landingAnimating` false): while a turn is + // processing, `paintPhaseAt` in runtime-bridge.ts drives the mountain's + // own draw/fill/fade loop off the turn monitor's clock, and this timer + // must not stomp that with an unrelated real-clock value. + // + // Cleared on whichever teardown happens first: the landing going away + // (`clearLandingMark`, first transcript row) or the whole shell disposing + // (`dispose` below, e.g. tests that never grow a transcript). + // + // Also self-cancels on `renderer.isDestroyed`: a real terminal session + // always disposes the shell, but headless test harnesses commonly destroy + // the renderer directly (`withTestRenderer`'s cleanup) without ever + // calling `shell.dispose()`. Without this check the timer would keep + // firing against renderables the harness already tore down. + // + // Reduced motion never starts the timer: there is no snow to advance + // and the mountain stays on its filled frame. + if (!reducedMotion) { + const landingIdleHandle = setInterval(() => { + if (renderer.isDestroyed) { + clearInterval(landingIdleHandle); + return; + } + const bag = shellInternals(shell); + if (bag?.landing == null || bag.landingAnimating) return; + paintLanding(shell, Date.now(), false); + }, LANDING_IDLE_REPAINT_INTERVAL_MS); + landingIdleHandle.unref?.(); + { + const bag = shellInternals(shell); + if (bag !== undefined) { + bag.landingIdleTimerCancel = () => clearInterval(landingIdleHandle); + } else { + clearInterval(landingIdleHandle); + } + } + } + setTranscriptSpacer(shell, transcriptSpacer); + if (onCommandOpt) setPaletteOnCommand(shell, onCommandOpt); + if (onObserveRequestOpt) { + setPaletteOnObserveRequest(shell, onObserveRequestOpt); + } + const { onKey, onPaste } = createShellKeyHandlers(shell, { isDisposed: () => disposed }); + if (wireKeys) { + renderer.keyInput.on("keypress", onKey); + renderer.keyInput.on("paste", onPaste); + prompt.onSubmit = onEnter; + } + + applyLayout(shell, layout); + // Added after the first layout pass so the scroll box sizes it against the + // resolved transcript height rather than the pre-layout placeholder. + transcript.add(landingAbove.box); + applyFocus(shell); + return shell; +} diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts new file mode 100644 index 000000000..b4ecc7789 --- /dev/null +++ b/src/tui/shell/internals.ts @@ -0,0 +1,987 @@ +/** + * Shared shell state: the AppShell surface types, the ShellInternals bag, and the per-shell WeakMap registries. Imports no sibling shell module — everything else imports this. + */ +import { type AgentPanelRow, type TaskPanelRow } from "../chrome-state.js"; +import { + BoxRenderable, + ScrollBoxRenderable, + SelectRenderable, + TextRenderable, + type CliRenderer, + type KeyEvent, +} from "@opentui/core"; +import { parseAtState, type AtState } from "../components/at-mention/parse.js"; +import { type ClipboardImageResult, type PendingImageAttachment } from "../image-attachments.js"; +import { type SentHistoryBrowse } from "../sent-message-history.js"; +import { type PromptRecognitionSource } from "../prompt-recognition.js"; +import { type PromptInput } from "../prompt-input.js"; +import type { RampPhase, StallAge } from "../ramp.js"; +import type { ActivityState } from "../session-chrome.js"; +import { type CostContextMeter } from "../prompt-border.js"; +import { type FocusState } from "../focus/index.js"; +import { type GeometryLayout, type OverlayMode, type ZoneVisibility } from "../geometry/index.js"; +import { type LandingAbove, type LandingBelowContent } from "../landing.js"; +import { type PaletteCommand } from "../command-catalog.js"; +import { type ObserveSession } from "../residuals.js"; +import { type ClipboardPort, type CopyTarget } from "../copy-path.js"; +import { type RunState, type SessionQueueState } from "../session-queue.js"; +import { type StreamRow } from "../stream.js"; +import { createOverlayView } from "../overlay-view.js"; +import { type KillRing } from "../prompt-kill-ring.js"; + +export const shellExitHandlers = new WeakMap void>(); + +/** + * Register the host's quit path (the same one Ctrl+C twice runs) so a bare `exit` / + * `quit` typed at the prompt tears down through finalize instead of a second, + * cleanup-skipping exit route. + */ +export function setShellExitHandler(shell: AppShell, onExit: () => void): void { + shellExitHandlers.set(shell, onExit); +} + +export function clearShellExitHandler(shell: AppShell): void { + shellExitHandlers.delete(shell); +} + +export const effortCycleHandlers = new WeakMap void>(); + +/** Shift+Tab host callback: cycle reasoning effort for the live session. */ +export function setEffortCycleHandler(shell: AppShell, onCycle: () => void): void { + effortCycleHandlers.set(shell, onCycle); +} + +/** Optional Wave-4 bridge hooks (runtime-bridge attaches exclusively). */ +export interface ShellBridgeHooks { + onSubmit: ( + text: string, + kind: "queue" | "steer" | "immediate" | "reinject", + attachments?: readonly PendingImageAttachment[], + ) => void; + onInterrupt: () => void; + exclusive: boolean; +} + +const shellBridgeHooks = new WeakMap(); + +export function setShellBridgeHooks(shell: AppShell, hooks: ShellBridgeHooks): void { + shellBridgeHooks.set(shell, hooks); +} + +export function clearShellBridgeHooks(shell: AppShell): void { + shellBridgeHooks.delete(shell); +} + +export function getShellBridgeHooks(shell: AppShell): ShellBridgeHooks | undefined { + return shellBridgeHooks.get(shell); +} + +/** + * What the focused overlay row is, and what choosing it costs. Painted in the + * fixed description zone under every overlay list that opts in via `describe`. + */ +export interface ItemDescription { + /** What the focused thing is. One line. */ + readonly what: string; + /** What choosing it costs or changes. One line. Omit when there is nothing true to say. */ + readonly impact?: string; + /** "consequence" paints impact in UI.warning — billing, trust, anything that spends or extends reach. */ + + readonly tone?: "plain" | "consequence"; +} + +/** + * Payload delivered when the operator accepts an overlay list selection. + * Hosts map this into ApprovalOutcome / OperatorResult / model switch. + */ +export interface OverlaySelection { + readonly kind: PrimaryOverlayKind; + readonly index: number; + readonly label: string; + /** Stable id when the host provided `itemIds`; otherwise omitted. */ + readonly id?: string; + /** Plain chosen value when the host provided `itemValues`; otherwise omitted. */ + readonly value?: string; +} + +/** + * Shell-level overlay accept hooks. Host binds authz / ask_operator / settings. + * Kind-specific hooks win over `onSelect`. Per-open `onAccept` (on open opts) + * takes precedence for that open's lifetime. + */ +export interface ShellOverlayHooks { + readonly onPermission?: (selection: OverlaySelection) => void; + readonly onOperator?: (selection: OverlaySelection) => void; + readonly onModel?: (selection: OverlaySelection) => void; + readonly onSettings?: (selection: OverlaySelection) => void; + readonly onHelp?: (selection: OverlaySelection) => void; + readonly onPlugins?: (selection: OverlaySelection) => void; + readonly onResume?: (selection: OverlaySelection) => void; + readonly onMentions?: (selection: OverlaySelection) => void; + /** Catch-all for non-palette kinds when no kind-specific hook is set. */ + readonly onSelect?: (selection: OverlaySelection) => void; +} + +const shellOverlayHooks = new WeakMap(); + +export function setShellOverlayHooks(shell: AppShell, hooks: ShellOverlayHooks): void { + shellOverlayHooks.set(shell, hooks); +} + +export function clearShellOverlayHooks(shell: AppShell): void { + shellOverlayHooks.delete(shell); +} + +export function getShellOverlayHooks(shell: AppShell): ShellOverlayHooks | undefined { + return shellOverlayHooks.get(shell); +} + +/** + * Injectable handler for registry-backed palette selections (`dispatch: "command"`). + * Residual openers still go through `runPaletteAction`. Host binds real handlers + * (slash command run, overlay open, etc.) without the palette importing the registry. + */ +export type PaletteOnCommand = (name: string) => void; + +const shellPaletteOnCommand = new WeakMap(); + +export function setPaletteOnCommand(shell: AppShell, handler: PaletteOnCommand | undefined): void { + if (handler) shellPaletteOnCommand.set(shell, handler); + else shellPaletteOnCommand.delete(shell); +} + +export function getPaletteOnCommand(shell: AppShell): PaletteOnCommand | undefined { + return shellPaletteOnCommand.get(shell); +} + +/** + * Clipboard image reader behind Ctrl+P. Injectable so tests (and non-macOS + * hosts) can supply their own source instead of shelling out to osascript. + */ +export type PromptImageSource = () => Promise; + +export const shellPromptImageSource = new WeakMap(); + +export function setPromptImageSource(shell: AppShell, source: PromptImageSource | undefined): void { + if (source) shellPromptImageSource.set(shell, source); + else shellPromptImageSource.delete(shell); +} + +/** Filesystem suggestions behind the @-mention overlay. */ +export type MentionSuggestionSource = (prefix: string) => Promise; + +export const shellMentionSource = new WeakMap(); + +export function setMentionSuggestionSource( + shell: AppShell, + source: MentionSuggestionSource | undefined, +): void { + if (source) shellMentionSource.set(shell, source); + else shellMentionSource.delete(shell); +} + +/** Names the prompt is allowed to highlight as leading `/command` tokens. */ +export const shellRecognitionSource = new WeakMap(); + +export function setPromptRecognitionSource( + shell: AppShell, + source: PromptRecognitionSource | undefined, +): void { + if (source) shellRecognitionSource.set(shell, source); + else shellRecognitionSource.delete(shell); +} + +/** + * Injectable handler for the palette "observe" action. Host resolves a live + * `ObserveSession` (or `null` when no subagent is running). Demo/smoke keep + * using `makeObserveFixture()` by leaving this unset. + */ +export type PaletteOnObserveRequest = () => ObserveSession | null; + +const shellPaletteOnObserveRequest = new WeakMap(); + +export function setPaletteOnObserveRequest( + shell: AppShell, + handler: PaletteOnObserveRequest | undefined, +): void { + if (handler) shellPaletteOnObserveRequest.set(shell, handler); + else shellPaletteOnObserveRequest.delete(shell); +} + +export function getPaletteOnObserveRequest(shell: AppShell): PaletteOnObserveRequest | undefined { + return shellPaletteOnObserveRequest.get(shell); +} + +/** Renderer surface required by the shell (CliRenderer / createTestRenderer). */ +export type ShellRenderer = Pick< + CliRenderer, + "root" | "width" | "height" | "keyInput" | "on" | "off" | "isDestroyed" | "clearSelection" +>; + +export interface AppShellOptions { + /** Session name. Default "corbits". Not painted as chrome. */ + readonly title?: string; + /** Working directory carried by the prompt box's bottom border. */ + readonly cwd?: string; + /** Zone visibility overrides for resolveGeometry. Optional strips off by default. */ + readonly visibility?: ZoneVisibility; + /** Requested prompt content rows (geometry caps at 40%). Default 3. */ + readonly promptContentRows?: number; + /** Pending queue count seed. Default 0. */ + readonly pendingQueue?: number; + /** Wire Tab + product keys (Enter/Alt+Enter/Ctrl+C/Esc/overlay). Default true. */ + readonly wireKeys?: boolean; + /** Mount shell.root on renderer.root. Default true. */ + readonly mount?: boolean; + /** Initial terminal size override (tests). Defaults to renderer.width/height. */ + readonly terminal?: { readonly columns: number; readonly rows: number }; + /** Simulated agent run state. Default "busy" (queue-default mid-run). */ + readonly run?: RunState; + /** Overlay list labels for inset demo. */ + readonly overlayItems?: readonly string[]; + /** + * Default palette catalog when `openPalette` is called without `catalog`. + * Host typically passes `buildPaletteCatalog({ commands: listCommands() })`. + * Static array or lazy builder. Defaults to residual openers only. + */ + readonly paletteCatalog?: readonly PaletteCommand[] | (() => readonly PaletteCommand[]); + /** + * Invoked when a registry-backed palette item is accepted (`dispatch: "command"`). + * Residual openers never hit this path. + */ + readonly onCommand?: PaletteOnCommand; + /** + * Invoked when the palette "observe" action runs. Returns the live + * `ObserveSession` to enter, or `null` when no subagent is running. + * Unset (demo/smoke) falls back to `makeObserveFixture()`. + */ + readonly onObserveRequest?: PaletteOnObserveRequest; + /** + * First-run telemetry disclosure for the landing screen. Omitted once the + * notice has been shown, so it is not permanent chrome. + */ + readonly telemetryNotice?: string; + /** + * Suppress landing snow and mountain motion. The idle timer is not + * armed, and `paintLanding` holds a still mountain with no flakes. + */ + readonly reducedMotion?: boolean; + /** + * Clipboard port for Alt+C and drag-select auto-copy. Defaults to an + * in-memory recorder so tests and demos never shell out; the product host + * injects the system clipboard. + */ + readonly clipboard?: ClipboardPort; + /** + * Mouse-reporting switch behind Alt+M. Absent means the shell has no + * renderer-level control (tests, demos) and reports the toggle unavailable. + * While reporting is on, OpenTUI owns drag-select and auto-copies on + * mouse-up; Alt+M hands the mouse back for native terminal selection. + */ + readonly mouseCapture?: MouseCapturePort; + /** + * How timed flashes arm their expiry. Injectable so tests can lapse a + * confirmation window without waiting out `RUNTIME_FLASH_MS`. + */ + readonly flashSchedule?: FlashSchedule; +} + +/** + * Renderer-level DEC mouse reporting control. While reporting is on the + * terminal hands drags to OpenTUI (drag-to-copy on mouse-up); Alt+M hands + * reporting back so the terminal can run its own selection again. + */ +export interface MouseCapturePort { + readonly get: () => boolean; + readonly set: (enabled: boolean) => void; +} + +export interface AppShell { + readonly renderer: ShellRenderer; + readonly root: BoxRenderable; + /** Blank rows above the first transcript row (0 on short terminals). */ + readonly topPad: BoxRenderable; + /** Blank row below the prompt box (0 on short terminals). */ + readonly bottomPad: BoxRenderable; + /** + * Build version's row, pinned to the terminal's last line and right-aligned + * (persistent chrome, not part of the landing composition — visible + * whether or not landing is showing). Hides on a narrow/short terminal, + * ahead of anything actionable (`versionBadgeVisible`). + */ + readonly versionRow: BoxRenderable; + /** + * Optional chrome zones (constitution task/agents). Distinct panels: a + * task is a unit of work with a status, an agent is an executor. + * One row per rendered task-panel line; rebuilt whenever the line count + * or any row's status changes. + */ + readonly taskBox: BoxRenderable; + /** One row per rendered agents-panel line; rebuilt whenever the line count changes. */ + readonly agentsBox: BoxRenderable; + readonly transcript: ScrollBoxRenderable; + readonly overlayView: ReturnType; + readonly overlayHost: BoxRenderable; + readonly overlayTitle: TextRenderable; + readonly overlayBody: BoxRenderable; + readonly prompt: PromptInput; + readonly promptBox: BoxRenderable; + /** The input's own row, bordered left and right only. */ + readonly promptField: BoxRenderable; + /** Top border of the prompt box — carries the model label. */ + readonly promptTopRule: TextRenderable; + /** Bottom border — carries the brand lockup and the workspace label. */ + readonly promptBottomRule: TextRenderable; + /** Transient state row above the prompt box (hidden when it has nothing to say). */ + readonly notice: TextRenderable; + /** Latest geometry resolution (updated on resize / relayout). */ + layout: GeometryLayout; + /** Focus tree + scroll lease (updated by shell helpers). */ + focus: FocusState; + /** Session queue / steer / interrupt bag. */ + session: SessionQueueState; + /** Pending queue count (mirrors badgeCount(session)). */ + pendingQueue: number; + /** Transcript line count (append counter / full log length). */ + lineCount: number; + /** + * Retained tail of the stream log — capped at MAX_RETAINED_STREAM_ROWS, so + * this is never the full session history on a long run. + */ + streamLog: StreamRow[]; + /** + * Absolute index of `streamLog[0]`. Every index the bridge holds onto + * across calls (tool-call rows, the open streaming row, the retry + * boundary) is absolute, so it stays valid once eviction has shifted the + * array itself. Bumped by the number of rows dropped on each trim. + */ + streamLogBase: number; + /** + * Distinct writers in the visible transcript. Rows carry a name and icon only + * once this holds more than one, so identity appears where it disambiguates. + */ + agentVoices: Set; + /** + * Session name. Held for hosts that rename a session; it is not chrome — + * an unnamed session shows nothing rather than a placeholder. + */ + baseTitle: string; + /** Composed `profile · model · effort` label carried by the top border. */ + modelLabel: string | null; + /** Working directory and git branch carried by the bottom border. */ + workspace: { cwd: string; branch: string | null }; + /** Overlay list state (null when closed). */ + overlayList: OverlayList | null; + /** Overlay item labels currently shown. */ + overlayItems: readonly string[]; + /** Which primary overlay is open (null when closed). */ + overlayKind: PrimaryOverlayKind | null; + /** Optional long body lines painted above the list (operator question). */ + overlayBodyLines: readonly string[]; + /** Palette role per body line, aligned with overlayBodyLines. */ + overlayBodyFgs: readonly string[]; + /** Palette command ids aligned with overlayItems when kind is palette. */ + paletteCommands: readonly PaletteCommand[]; + /** Clipboard port for keyboard copy (tests inject recording port). */ + clipboard: ClipboardPort; + /** Mouse-reporting control for Alt+M, or null when the host has none. */ + mouseCapture: MouseCapturePort | null; + /** + * Frozen copy targets while the copy overlay is open (null when closed). + * Confirm writes from this snapshot, not live streamLog. + */ + copyTargets: readonly CopyTarget[] | null; + /** + * Short transient flash (copy feedback, etc.). Cleared when replaced or + * set to null; never appended to the stream log. + */ + statusFlash: string | null; + /** MCP servers awaiting authorization; the top rule carries `mcp !`. */ + mcpNeedsAuth: readonly string[]; + /** + * Plugin load left standing warnings (skill misses, failed tool starts, …). + * The top rule carries `plugin !` (or `mcp ! · plugin !` with MCP). Cleared + * only when the warning set is empty — not merely dismissed. + */ + pluginNeedsAttention: boolean; + /** + * Clock, motion and content state for the bottom-left status slot. The bridge + * pushes all of it off its existing monitor tick (`setLockupFrame`); the + * shell never reads a clock of its own, so a shell without a bridge simply + * paints the settled idle slot. + */ + lockupNowMs: number; + /** + * Parent tool currently in flight, for the steer `waiting on` notice. + * Null when no parent tools remain or the run is idle. Not TurnState. + */ + inFlightTool: { name: string; startedAt: number } | null; + lockupAnimating: boolean; + /** + * Live activity state the slot shows, or null for the idle wordmark. + * Typed to the closed set (not `string`) so a raw tool/MCP/plugin + * identifier reaching this field is a compile error, not just a test one. + */ + lockupPhase: ActivityState | null; + /** Clock reading when `lockupPhase` last changed — the fade's origin. */ + lockupChangedMs: number; + /** Density ramp phase for the same turn — drives the slot's pulse cell and tint. */ + lockupRampPhase: RampPhase | null; + /** How long the turn has been stalled, or null when it is not — bounds the blink. */ + lockupStalledForMs: StallAge; + /** + * Cost/context meter carried by the bottom border, or null when the active + * session has nothing to report (context window unknown). Pushed by the + * host whenever the run sink's usage changes — no timer of its own. + */ + costContext: CostContextMeter | null; + /** + * Active subagent observe session (null when viewing parent). + * Independent stream window; Esc restores parent lease. + */ + observe: { + sessionId: string; + agentId: string; + description: string; + lines: StreamRow[]; + } | null; + /** Parent stream snapshot while observe is active. */ + parentStreamLog: StreamRow[] | null; + /** Absolute base for `parentStreamLog`, saved/restored across observe (see `streamLogBase`). */ + parentStreamLogBase: number | null; + /** + * Readline kill ring backing Ctrl+Y/Alt+Y. Ctrl+K/U/W and Alt+D feed it; + * the text widget itself has no concept of a kill ring (see + * ./prompt-kill-ring.js). + */ + promptKillRing: KillRing; + /** Images attached with Ctrl+P, sent with the next prompt submit. */ + pendingAttachments: PendingImageAttachment[]; + /** Up/Down recall of messages already sent in this session. */ + sentHistory: SentHistoryBrowse; + /** Detach key/resize listeners and unmount root. */ + dispose: () => void; + /** + * True once `dispose` has run. Paint entry points read this: a caller that + * outlives the shell — a poll timer, a resolved async continuation — would + * otherwise write into renderables whose native buffers are already freed. + */ + disposed: boolean; +} + +export type PrimaryOverlayKind = + | "permissions" + | "operator" + | "model_picker" + | "add_provider" + | "demo" + | "palette" + | "settings" + | "help" + | "plugins" + | "resume" + | "mentions" + | "copy" + | "hooks" + | "mcp" + | "plugin_credentials"; + +/** Whether the transcript viewport is stuck to the bottom (FOLLOW vs PINNED). */ +export function isTranscriptFollowing(shell: AppShell): boolean { + const { transcript } = shell; + const max = Math.max(0, transcript.scrollHeight - transcript.height); + return transcript.scrollTop >= max - 1; +} + +/** Sticky-scroll mode label (surfaced on the notice row only when PINNED). */ +export function stickyMode(shell: AppShell): "FOLLOW" | "PINNED" { + return isTranscriptFollowing(shell) ? "FOLLOW" : "PINNED"; +} + +/** + * How a timed flash arms its own expiry. Injectable so tests can lapse a + * window without waiting out its real duration; returns the cancel. + */ +export type FlashSchedule = (fn: () => void, ms: number) => () => void; + +export interface FlashOptions { + /** Lifetime of the flash; omitted means it stays until something replaces it. */ + readonly ttlMs?: number; + readonly schedule?: FlashSchedule; +} + +/** Cancel for the flash currently counting down, per shell. */ +export const flashTimers = new WeakMap void>(); + +/** Per-shell override for how timed flashes arm their expiry (tests). */ +export const shellFlashSchedules = new WeakMap(); + +/** + * Free-text answer field an overlay can offer alongside (or instead of) its + * choices. `active` is whether keystrokes are going into it rather than into + * list navigation — the row is painted either way, so the affordance is on + * screen rather than behind a chord nobody knows about. + */ +export interface OverlayAnswerState { + text: string; + active: boolean; + readonly onSubmit: (text: string) => void; +} + +/** Item window the SelectRenderable currently shows. */ +export interface OverlayListRange { + /** Inclusive start index into the full list. */ + readonly start: number; + /** Exclusive end index into the full list. */ + readonly end: number; +} + +/** + * The open overlay's list, backed by @opentui/core's SelectRenderable. + * OpenTUI owns selection clamping, movement and scroll-keep-visible; this + * wrapper exposes the item-count view the shell reads (the renderable counts + * terminal rows, `rowsPerItem` converts) and rebuilds the renderable when its + * geometry changes, because the renderable only recomputes its visible-item + * capacity in its constructor and renderer resize callbacks. + */ +export interface OverlayList { + readonly select: SelectRenderable; + readonly activeIndex: number; + /** Item-row capacity reserved by layout (not the renderable's row height). */ + readonly height: number; + readonly offset: number; + readonly count: number; + move(delta: number): void; + page(dir: -1 | 1): void; + jump(index: number): void; + setCount(count: number): void; + setHeight(items: number, rowsPerItem?: number): void; + visibleRange(): OverlayListRange; +} + +interface PrimaryOverlayBindings { + /** Optional stable ids aligned with overlayItems for the open primary. */ + itemIds: readonly string[]; + /** Optional plain chosen values aligned with overlayItems for the open primary. */ + itemValues: readonly (string | undefined)[]; + /** Per-open accept callback; cleared on close without invoke (Esc path). */ + onAccept: ((selection: OverlaySelection) => void) | null; + /** Per-open expand/collapse hook for the open primary overlay. */ + onToggleExpand: (() => void) | null; + /** Per-open ← → cycle hook for the open primary overlay (settings inline cycling). */ + onCycle: ((itemId: string, direction: -1 | 1) => void) | null; + /** Per-open description-zone source; null keeps the zone off (no rows charged). */ + describe: ((itemId: string) => ItemDescription | null) | null; + /** Per-open bare-key claim for the open primary overlay. */ + onAction: ((itemId: string, key: KeyEvent) => boolean) | null; + /** Per-open bracketed-paste owner for synthetic text panes. */ + onPaste: ((text: string) => void) | null; + /** + * Per-open dismiss hook for promise-backed overlays (permissions, operator). + * Esc/closeInsetOverlay invokes this instead of silently dropping the + * pending promise the way palette/mentions/copy overlays correctly do. + */ + onCancel: (() => void) | null; + /** + * Per-open cleanup for a replaced or dismissed overlay (MCP unsubscribe). + * closeReplaceableOverlay still runs this; it skips onCancel so + * Esc-only navigation (add-provider back to models) does not fire. + */ + onDispose: (() => void) | null; + /** True while the open primary is a decision gate that must not be replaced. */ + isGate: boolean; + /** Whether the open primary advertises Alt+A and yields å/Å from type-to-filter. */ + addProviderHint: boolean; + /** Whether the open primary advertises Alt+D in the footer hints. */ + setDefaultHint: boolean; + /** Whether the open `/mcp` list advertises Alt+D / Alt+R. */ + mcpManageHint: boolean; + /** Whether the open `/mcp` list advertises Alt+A add. */ + mcpAddHint: boolean; +} + +export const EMPTY_PRIMARY_BINDINGS: Readonly = { + itemIds: [], + itemValues: [], + onAccept: null, + onToggleExpand: null, + onCycle: null, + describe: null, + onAction: null, + onPaste: null, + onCancel: null, + onDispose: null, + isGate: false, + addProviderHint: false, + setDefaultHint: false, + mcpManageHint: false, + mcpAddHint: false, +}; + +interface PriorOverlaySnapshot { + readonly kind: PrimaryOverlayKind | null; + readonly items: readonly string[]; + readonly bodyLines: readonly string[]; + readonly bodyFgs: readonly string[]; + readonly list: OverlayList; + readonly title: string; + readonly paletteCommands: readonly PaletteCommand[]; + readonly primaryBindings: Readonly; + readonly answer: OverlayAnswerState | null; + readonly titleText: string; +} + +interface ShellInternals { + visibility: ZoneVisibility; + promptContentRows: number | undefined; + overlayMode: OverlayMode; + overlayBodyRows: number | undefined; + overlayMinBodyRows: number | undefined; + /** + * Raw (unwrapped) text last passed to `applyOverlayBodyText`, kept so a + * resize can re-shape a decision overlay's body against the new height's + * context budget instead of leaving it fixed at whatever it opened with. + */ + overlayRawBodyText: string; + /** Snapshot when palette stacks over another primary overlay. */ + priorOverlay: PriorOverlaySnapshot | null; + /** Advances on a new overlay taking the host, and when the host empties. */ + overlayGeneration: number; + primaryBindings: PrimaryOverlayBindings; + /** False while an overlay that reports its own outcome is open. */ + overlayEchoChoice: boolean; + /** + * While true the shell ignores its own key/paste/submit handlers. Set for + * the lifetime of a full-screen surface (inline provider connect) that + * shares this renderer — two live key handlers on one stdin would both + * act on every keystroke. + */ + inputSuspended: boolean; + /** Per-open free-text answer field, when the overlay opted into one. */ + overlayAnswer: OverlayAnswerState | null; + /** Bare title of the open overlay, so its key hints can be re-composed. */ + overlayTitleText: string; + /** Fired once the overlay host is idle, so queued gates can re-open. */ + overlayClosedListeners: Set<() => void>; + /** + * Command-surface open while a live overlay still holds the host. One slot; + * a newer command replaces an older one. Flushed only after that overlay + * has actually closed and the host is idle — never from idle-notify, which + * would let wireGates drain a queued gate onto the same host. + */ + deferredCommandOverlay: OpenListOverlayOpts | null; + /** True while a microtask to flush deferredCommandOverlay is queued. */ + deferredFlushScheduled: boolean; + /** + * Host-owned holds that outlive overlayList being null (async /settings + * list(), etc.). While > 0, idle-notify must not fire so a queued gate + * cannot drain into the gap before the surface paints. + */ + overlayHostReservations: number; + /** + * Advanced when Esc aborts in-flight reservations so a stale `release()` + * cannot decrement a newer hold. + */ + overlayReservationEpoch: number; + /** + * Registry-backed `/` command catalog (static or lazy), host-injected. Empty + * when unset. + */ + paletteCatalog: readonly PaletteCommand[] | (() => readonly PaletteCommand[]) | null; + /** Live filter state for the open palette, so typing can re-filter it. */ + paletteFilter: PaletteFilterState | null; + /** Live type-to-filter state for a non-palette list overlay (model picker). */ + listFilter: ListFilterState | null; + /** + * Landing composition shown while the transcript has no content: the mark + * above the prompt box, the disclosure and starters below it. Dropped (not + * hidden) on the first row so it never occupies a transcript line later. + */ + landing: { readonly above: LandingAbove; readonly below: BoxRenderable } | null; + /** + * The disclosure the landing is showing. Re-appended to the transcript when + * the landing tears down so consent-by-proceeding leaves a durable record + * rather than a screen the first prompt wipes. + */ + landingNotice: string | null; + /** + * System/runtime notices that arrived while the landing was still up (MCP + * load failures, width-contract warnings, hook failures). Held here and + * painted on the notice strip so they never call `clearLandingMark`; flushed + * into the transcript when the first real session row ends the landing. + */ + landingDeferredRows: StreamRow[]; + /** What the rows below the box are painting, so they can be repainted. */ + landingBelow: LandingBelowContent | null; + /** Starters are offered only while the prompt is empty. */ + landingSuggestionsVisible: boolean; + /** Whether the last painted mark frame was a moving one. */ + landingAnimating: boolean; + /** Clock of the last painted mark frame, so a resize can redraw in place. */ + landingNowMs: number; + /** + * Mount-time reduced-motion flag. When true, the idle snow timer is + * never armed and every landing paint holds a still mountain with no + * flakes. Set once at `createAppShell`; not a per-paint argument. + */ + reducedMotion: boolean; + /** + * Cancels the mount-scoped idle repaint timer armed in `createAppShell`, + * or null while none is armed. Cleared by whichever teardown happens + * first — the landing going away (`clearLandingMark`) or the whole shell + * disposing (`dispose`) — so it can never outlive either. + */ + landingIdleTimerCancel: (() => void) | null; + /** Chrome content (empty array = zone off). */ + chrome: { + /** + * Rendered task rows — empty when there is nothing to show OR the panel + * is hidden by the operator toggle. `tasksRaw` holds the live data + * independent of that toggle, so un-hiding shows the current list + * without waiting on the next manage_tasks write. + */ + task: readonly TaskPanelRow[]; + /** Last live task rows pushed via setChromeZones, regardless of hidden state. */ + tasksRaw: readonly TaskPanelRow[]; + /** Agents panel rows (empty array = zone off), one row per rendered line. */ + agents: readonly AgentPanelRow[]; + }; + /** Operator toggle for the task panel; in-memory, held for the life of the shell. */ + tasksPanelHidden: boolean; +} + +export const internals = new WeakMap(); + +/** + * Leading filler row inside the transcript's scroll content. Bottom-anchors a + * short transcript against the prompt box below: sized to the leftover + * viewport space so few rows sit at the foot of the zone instead of stranded + * at its top. Once rows fill the viewport the filler settles at zero and + * sticky-scroll behaves exactly as it did before this existed. + * + * A real child rather than padding: the content box's `minHeight: "100%"` + * (`@opentui/core`'s own default, so it never reads shorter than the + * viewport) means padding cannot be measured back out of `scrollHeight` — + * it always reads as the viewport height regardless of how little real + * content there is. A child's own height is unaffected by that floor, so + * `scrollHeight - spacer.height` reliably isolates the rows' real height. + * + * This does cost every row-index code path (`getChildren()`-based lookups + * below, and the two external tests noted at their call sites) one constant + * offset: index 0 is always the spacer, never a row. + */ +export const transcriptSpacers = new WeakMap(); + +/** True while the landing composition is still mounted. */ +export function isLanding(shell: AppShell): boolean { + return (internals.get(shell)?.landing ?? null) !== null; +} + +export interface OpenListOverlayOpts { + readonly kind?: PrimaryOverlayKind; + readonly title?: string; + readonly items?: readonly string[]; + /** Optional stable ids aligned with `items` (permission scope ids, model ids). */ + readonly itemIds?: readonly string[]; + /** + * Optional plain chosen-value aligned with `items`, for rows whose display + * label carries more than the value itself (a cycled field's name, padding, + * and `‹ ›` markers around the active option). The accept echo reads this + * instead of recovering the value by parsing the label back apart. + */ + readonly itemValues?: readonly (string | undefined)[]; + readonly body?: string; + readonly activeIndex?: number; + readonly frameId?: string; + /** + * Per-open accept callback. Takes precedence over shell-level overlay hooks + * for this open. Not invoked on Esc / closeInsetOverlay. + */ + readonly onAccept?: (selection: OverlaySelection) => void; + /** + * Per-open expand/collapse hook. When set, the modal overlay claims a bare + * key for it (see OVERLAY_EXPAND_KEY) — no global binding is needed because + * the overlay owns the keyboard while it is open. + */ + readonly onToggleExpand?: () => void; + /** + * Per-open ← → cycle hook. When set, the overlay claims Left/Right for it + * instead of leaving them unbound — settings-style inline value cycling. + * Scoped to this open only, the way `onToggleExpand` and `typeToFilter` are. + */ + readonly onCycle?: (itemId: string, direction: -1 | 1) => void; + /** + * Per-open Esc/dismiss hook for promise-backed overlays (permissions, + * operator). Invoked by closeInsetOverlay before the accept path is + * cleared, so the caller's awaited promise resolves instead of hanging. + */ + readonly onCancel?: () => void; + /** + * Per-open cleanup for replace and dismiss. closeReplaceableOverlay + * invokes this and skips `onCancel`, which is Esc/dismiss only. + */ + readonly onDispose?: () => void; + /** + * True when this open is a permission/operator decision gate. Command + * surfaces call `closeReplaceableOverlay` to free the host; that no-ops + * while this is set so a live gate is not torn down. + */ + readonly isGate?: boolean; + /** + * Invoked only after this open actually takes the host (including a + * deferred flush). Busy no-ops and deferred stashes do not run it. + */ + readonly onOpened?: () => void; + /** + * Description-zone source. Called with the focused item's id on every move + * (falling back to its label when no `itemIds` were supplied). Returning + * null renders the zone blank, not collapsed — the fixed two-line zone is + * charged to the row budget whenever this is set, whether or not the current + * item has anything to say. + */ + readonly describe?: (itemId: string) => ItemDescription | null; + /** + * Per-open bare-key claim, checked before list navigation. Returning false + * leaves the key available to the ordinary j/k and arrow handlers. Scoped to + * this open only, so it cannot shadow prompt typing. + */ + readonly onAction?: (itemId: string, key: KeyEvent) => boolean; + /** Per-open bracketed-paste target for synthetic text panes. */ + readonly onPaste?: (text: string) => void; + /** + * Per-open free-text answer. When set the overlay paints an answer field the + * operator can Tab into and type into, and submitting it closes the overlay + * through this callback instead of the selection path. + */ + readonly onTextAnswer?: (text: string) => void; + /** + * Open with the answer field already taking keystrokes. Used when there is + * nothing to choose, so the overlay is never a chooser with an empty list. + */ + readonly textAnswerActive?: boolean; + /** + * Suppress the `chose (kind): label` transcript echo for this open. + * + * The echo exists so a choice with no other visible result still leaves a + * trace. A surface that reports the outcome itself does not need it, and the + * echo is worse than silent there: it quotes the row's label from *before* + * the action, so authorizing a server leaves a permanent line saying that + * server needs authorization. + */ + readonly echoChoice?: boolean; + /** + * Claim printable keys for a `>` filter row so the list narrows as you type. + * Opt-in per open (model picker, palette, resume). Overlays without it keep j/k + * navigation; with it, j/k type into the filter and arrows still navigate. + */ + readonly typeToFilter?: boolean; + /** + * Advertise Alt+A and /connect in the footer and yield composed Option+A + * (å/Å) from type-to-filter. Set only when the caller actually wired an + * add-provider handler via `onAction`, so the hint never names a dead chord. + */ + readonly addProviderHint?: boolean; + /** + * Advertise the Alt+D set-default hint in the footer for this open. Set + * only when the caller actually wired an Alt+D handler via `onAction`. + */ + readonly setDefaultHint?: boolean; + /** + * Advertise Alt+D disable / Alt+R remove in the `/mcp` footer. Confirm + * overlays leave this unset so they fall back to DEFAULT_OVERLAY_HINTS. + */ + readonly mcpManageHint?: boolean; + /** + * Advertise Alt+A add in the `/mcp` footer. False while local settings + * shadow global MCP (add is hidden and Alt+A is a dead chord). + */ + readonly mcpAddHint?: boolean; + /** + * When the host is already showing a non-palette overlay, stash this open + * in the one deferred slot and print a system line. Off by default: a + * busy open is a silent no-op (demo, mentions, same-kind re-open of + * surfaces that do not call `closeReplaceableOverlay` first). + */ + readonly deferIfBusy?: boolean; +} + +/** Palette open state that survives a re-filter. */ +interface PaletteFilterState { + query: string; + readonly title: string; + readonly catalog: readonly PaletteCommand[] | null; + readonly typeToFilter: boolean; +} + +/** + * Live type-to-filter state for a non-palette list overlay (model picker). + * Holds the full unfiltered row set so each keystroke can re-narrow in place + * without reopening the overlay (a busy open is a silent no-op unless + * `deferIfBusy` is set). + */ +interface ListFilterState { + query: string; + readonly allItems: readonly string[]; + readonly allItemIds: readonly string[]; + readonly allItemValues: readonly (string | undefined)[]; +} + +export interface MentionAcceptState { + readonly suggestions: readonly string[]; + readonly generation: number; + readonly atStart: number; +} + +export const mentionPopups = new WeakSet(); + +export const mentionGenerations = new WeakMap(); + +export const mentionAcceptState = new WeakMap(); + +/** Drop accept state and invalidate in-flight lookups on operator dismiss. */ +export function clearMentionAccept(shell: AppShell): void { + mentionAcceptState.delete(shell); + mentionGenerations.set(shell, (mentionGenerations.get(shell) ?? 0) + 1); +} + +/** Live accept snapshot, or null when there is no state, generation is stale, or the cursor left this @. */ +export function liveMentionAccept( + shell: AppShell, +): { state: MentionAcceptState; live: AtState } | null { + const state = mentionAcceptState.get(shell); + if (state === undefined) return null; + if (mentionGenerations.get(shell) !== state.generation) return null; + const live = parseAtState(shell.prompt.value, shell.prompt.cursorOffset); + if (live === null || live.atStart !== state.atStart) return null; + return { state, live }; +} + +export const slashPopups = new WeakSet(); + +/** True while the `/` command popup owns typed characters. */ +export function isSlashPopupOpen(shell: AppShell): boolean { + return slashPopups.has(shell) && shell.overlayList !== null; +} + +/** + * Popup query = prompt text after the leading `/`. Null once the operator has + * typed whitespace: at that point the name is settled and the rest is arguments. + */ +export function slashPopupQuery(shell: AppShell): string | null { + const value = shell.prompt.value; + if (!value.startsWith("/")) return null; + const head = value.slice(1); + return /\s/.test(head) ? null : head; +} + +export function shellInternals(shell: AppShell): ShellInternals | undefined { + return internals.get(shell); +} + +export function initShellInternals(shell: AppShell, bag: ShellInternals): void { + internals.set(shell, bag); +} + +export function setTranscriptSpacer(shell: AppShell, spacer: BoxRenderable): void { + transcriptSpacers.set(shell, spacer); +} diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts new file mode 100644 index 000000000..2217692cd --- /dev/null +++ b/src/tui/shell/keys.ts @@ -0,0 +1,689 @@ +/** + * Key routing: paste guard, kill-ring chords, the onKey dispatcher body, Ctrl+C arming. + */ +import { + ScrollBoxRenderable, + type BaseRenderable, + type KeyEvent, + type MouseEvent, +} from "@opentui/core"; +import { badgeCount } from "../session-queue.js"; + +import { + type AppShell, + type FlashOptions, + effortCycleHandlers, + isSlashPopupOpen, + type PrimaryOverlayKind, + shellExitHandlers, + shellInternals, +} from "./internals.js"; +import { + acceptOverlaySelection, + abortOverlayHostReservations, + closeInsetOverlay, + confirmCopySelection, + copyAllTargets, + exitOverlayAnswerMode, + handleOverlayAnswerKey, + notifyOverlayClosed, +} from "./overlay-host.js"; +import { + applyFocus, + applyLandingSuggestion, + setStatusFlash, + toggleShellFocus, + toggleTasksPanel, +} from "./chrome.js"; +import { + applyShellCancelLast, + attachClipboardImage, + clearPendingAttachments, + interruptShell, + submitPrompt, +} from "./prompt.js"; +import { + handleListFilterKey, + handleMentionPopupKey, + handlePaletteFilterKey, + handleSlashPopupKey, + MOTION_KEYS, + openAtMentionSuggestions, + openSlashCommands, + setPromptText, +} from "./palette.js"; +import { + cycleOverlaySelection, + moveOverlaySelection, + pageOverlaySelection, + runOverlayAction, + toggleOverlayExpand, +} from "./overlay-list.js"; +import { OVERLAY_EXPAND_KEY } from "./transcript.js"; +import { toggleCollapsedRow } from "./chrome.js"; +import { leaveSubagentObserve, observeActiveSubagent } from "./observe.js"; +import { enterCopyMode, toggleMouseCapture } from "./copy.js"; +import { canPopFocus, focusOwner, popFocus } from "../focus/index.js"; +import { + beginYank, + breakKillSequence, + killedTextBackward, + killedTextForward, + recordKill, + rotateYank, +} from "../prompt-kill-ring.js"; +import { promptCaretAtFirstRow, promptCaretAtLastRow } from "../prompt-input.js"; +import { + sentHistoryOnEdit, + stepSentHistoryDown, + stepSentHistoryUp, +} from "../sent-message-history.js"; +import { EXPAND_KEY } from "../stream.js"; + +// Human keystrokes land tens of milliseconds apart at the fastest; a paste +// replayed onto stdin without bracketed-paste framing lands effectively all +// at once. 15ms is an empirical guess at a gap comfortably under normal +// typing and comfortably over a replayed paste, not a measured figure -- +// too high false-positives on a very fast typist's real Enter (read as +// paste, so it inserts a newline instead of sending); too low misses a +// slow paste replay (read as typing, so a bare CR mid-paste still +// submits). Only matters before this terminal's first real paste event; +// see `sawBracketedPaste` below. +const PASTE_BURST_MS = 15; + +/** A single unmodified character, as opposed to a control chord or named key. */ +function isPrintableInsertKey(key: KeyEvent): boolean { + return ( + !key.ctrl && + !key.meta && + !key.option && + typeof key.sequence === "string" && + key.sequence.length === 1 && + key.sequence >= " " + ); +} + +/** + * Which open surface a chord toggles shut, or null when the chord is not a + * toggling opener. + * + * Only pickers appear here. An opener that performs an action (Ctrl+P attaches + * an image, Ctrl+C interrupts, the expand key expands a row) has nothing to + * toggle, and a decision surface — a permission or operator question — is + * deliberately absent: re-pressing whatever chord happened to be underneath it + * must not count as an answer. Those leave via a choice or Esc. + * + * `@` and `/` are openers too, but they are also characters being typed, so + * pressing them again inserts them rather than closing the popup. + */ +function toggledSurfaceFor(key: KeyEvent): PrimaryOverlayKind | null { + if ((key.meta || key.option) && !key.ctrl && (key.name === "c" || key.name === "C")) { + return "copy"; + } + return null; +} + +/** + * Re-pressing the chord that opened a picker closes it, through the same path + * Esc uses so key claims and focus are unwound identically. + */ +function toggleCloseOpenSurface(shell: AppShell, key: KeyEvent): boolean { + if (shell.overlayList === null) return false; + const kind = toggledSurfaceFor(key); + if (kind === null || kind !== shell.overlayKind) return false; + // The `/` popup borrows the palette overlay; there the chord is still a + // character the operator may be typing into the filter. + if (kind === "palette" && isSlashPopupOpen(shell)) return false; + closeInsetOverlay(shell); + return true; +} + +/** Window in which a second Ctrl+C is read as "yes, quit". */ +export const CTRL_C_EXIT_WINDOW_MS = 2000; + +const ctrlCArmedAt = new WeakMap(); + +/** + * Ctrl+C: interrupt / clear, and quit on a second press inside the window. + * The double press replaces the old Ink y/n exit confirm — same intent (an + * explicit second confirmation), no modal. Quitting routes through the + * registered exit handler so host finalize still runs. + */ +export function handleCtrlC(shell: AppShell, now = Date.now(), options?: FlashOptions): void { + const armedAt = ctrlCArmedAt.get(shell); + if (armedAt !== undefined && now - armedAt <= CTRL_C_EXIT_WINDOW_MS) { + ctrlCArmedAt.delete(shell); + const onExit = shellExitHandlers.get(shell); + if (onExit !== undefined) { + // Host teardown usually disposes; unlink here too so a stub/delayed + // onExit cannot leave Corbits-created clipboard files behind. + clearPendingAttachments(shell); + onExit(); + return; + } + } + + const idle = shell.session.run !== "busy" && badgeCount(shell.session) === 0; + const hasPromptText = shell.prompt.value.length > 0; + const hasAttachments = shell.pendingAttachments.length > 0; + if (idle && (hasPromptText || hasAttachments)) { + shell.prompt.value = ""; + clearPendingAttachments(shell); + if (!hasPromptText) return; + } + + ctrlCArmedAt.set(shell, now); + + if (shell.session.run === "busy" || badgeCount(shell.session) > 0) { + interruptShell(shell); + } + // The notice is exactly as true as the arming window is open, so it expires + // with it rather than waiting for some later flash to overwrite it. + setStatusFlash(shell, "press ctrl+c again to exit", { + ttlMs: CTRL_C_EXIT_WINDOW_MS, + ...(options?.schedule !== undefined ? { schedule: options.schedule } : {}), + }); +} + +/** + * Wheel/trackpad scroll landing on the prompt scrolls the chat instead. + * + * The prompt textarea is an editable buffer with its own `scrollY`, so + * OpenTUI's default routing — whichever renderable the wheel event hits, or + * the focused renderable when the hit misses — happily scrolls the prompt's + * own (usually one-screen, nothing-to-scroll) content. The prompt also holds + * keyboard focus for the whole session, so it is the fallback target for any + * wheel event that lands off the transcript's hit-tested rows. Overriding the + * scroll case here — rather than teaching the transcript's own scroll lease + * about wheel events — keeps the fix to exactly where wheel input actually + * arrives, without touching transcript viewport internals. + */ +export function routePromptWheelToTranscript( + prompt: BaseRenderable, + transcript: ScrollBoxRenderable, +): void { + (prompt as unknown as { onMouseEvent: (event: MouseEvent) => void }).onMouseEvent = ( + event: MouseEvent, + ) => { + if (event.type !== "scroll") return; + (transcript as unknown as { onMouseEvent: (event: MouseEvent) => void }).onMouseEvent(event); + }; +} + +interface ShellKeyHandlers { + onKey: (key: KeyEvent) => void; + onPaste: (event: { bytes: Uint8Array; preventDefault: () => void }) => void; +} + +/** + * The onKey/onPaste dispatcher bodies, extracted from createAppShell. The + * un-bracketed-paste guard state is only read by these handlers, so it lives + * in this closure rather than on the shared AppShell. + */ +export function createShellKeyHandlers( + shell: AppShell, + opts: { isDisposed: () => boolean }, +): ShellKeyHandlers { + // A real bracketed-paste event proves this terminal negotiates DEC 2004: + // every paste from here on arrives as one `paste` event, never as raw + // keystrokes, so the CRLF-submit fallback below has nothing left to guard + // against and turns itself off for the rest of the session. Terminals that + // never send one keep the guard, since they've never shown they can do + // better. Un-bracketed-paste bookkeeping only this key handler reads, so it + // lives in this closure rather than on the shared AppShell. + let sawBracketedPaste = false; + let lastKeyAt = 0; + let lastKeyWasPrintable = false; + let suppressNextLinefeed = false; + const onPaste = (event: { bytes: Uint8Array; preventDefault: () => void }): void => { + if (opts.isDisposed()) return; + const bag = shellInternals(shell); + if (bag?.inputSuspended === true) return; + sawBracketedPaste = true; + if (shell.overlayList !== null && bag?.primaryBindings.onPaste) { + event.preventDefault(); + bag.primaryBindings.onPaste(new TextDecoder().decode(event.bytes)); + } + }; + + const onKey = (key: KeyEvent): void => { + if (opts.isDisposed()) return; + if (shellInternals(shell)?.inputSuspended === true) return; + + if (key.name === "escape") { + if (exitOverlayAnswerMode(shell)) { + key.preventDefault(); + return; + } + if (shell.overlayList) { + key.preventDefault(); + abortOverlayHostReservations(shell); + closeInsetOverlay(shell); + return; + } + if (shellInternals(shell)?.overlayHostReservations) { + abortOverlayHostReservations(shell); + key.preventDefault(); + // Next tick so the same Esc cannot also dismiss a gate this abort drains. + queueMicrotask(() => notifyOverlayClosed(shell)); + return; + } + if (shell.observe) { + key.preventDefault(); + leaveSubagentObserve(shell); + return; + } + // Transcript browse (entered with Tab) is the remaining poppable frame: + // Esc hands typing back to the prompt. + if (canPopFocus(shell.focus)) { + key.preventDefault(); + shell.focus = popFocus(shell.focus); + applyFocus(shell); + return; + } + } + + // Landing starters. Only while the prompt is untouched, so the digit goes + // back to being a digit the moment the operator starts typing. + if ( + shell.overlayList === null && + !key.ctrl && + !key.meta && + !key.option && + typeof key.name === "string" && + applyLandingSuggestion(shell, key.name) + ) { + key.preventDefault(); + return; + } + + if (shell.overlayList) { + // Checked ahead of the filter handlers: an opener chord pressed again is + // a request to close, not a character to narrow the list with. + if (toggleCloseOpenSurface(shell, key)) { + key.preventDefault(); + return; + } + // The `/` popup filters as you type, so it claims printable keys before + // the overlay's j/k navigation can swallow them. + if (handleSlashPopupKey(shell, key)) { + key.preventDefault(); + return; + } + // Same reason as the `/` popup: the `@` popup narrows as you type, so it + // claims printable keys ahead of the overlay's j/k navigation. + if (handleMentionPopupKey(shell, key)) { + key.preventDefault(); + return; + } + // A live answer field owns every printable key, so an operator typing a + // free-form answer is not navigating the choice list instead. + if (handleOverlayAnswerKey(shell, key)) { + key.preventDefault(); + return; + } + // Type-to-filter overlays (palette, model picker) claim printables — + // including j/k that non-filter overlays still use to navigate. + if (handlePaletteFilterKey(shell, key)) { + key.preventDefault(); + return; + } + // Same opt-in for list overlays (model picker): type-to-filter claims + // printables so a long flat catalog narrows without a nested pane. + if (handleListFilterKey(shell, key)) { + key.preventDefault(); + return; + } + // Per-overlay bare-key owners (including text panes) get first refusal. + // Ordinary lists return false here, preserving j/k navigation below. + if (runOverlayAction(shell, key)) { + key.preventDefault(); + return; + } + if (key.name === "up" || key.name === "k") { + key.preventDefault(); + moveOverlaySelection(shell, -1); + return; + } + if (key.name === "down" || key.name === "j") { + key.preventDefault(); + moveOverlaySelection(shell, 1); + return; + } + // Left/Right only mean something to an overlay that opted into cycling + // (settings). Everywhere else they fall through unclaimed. + if ( + (key.name === "left" || key.name === "right") && + !key.ctrl && + !key.meta && + !key.option && + cycleOverlaySelection(shell, key.name === "left" ? -1 : 1) + ) { + key.preventDefault(); + return; + } + if (key.name === "pageup") { + key.preventDefault(); + pageOverlaySelection(shell, -1); + return; + } + if (key.name === "pagedown") { + key.preventDefault(); + pageOverlaySelection(shell, 1); + return; + } + if ( + key.name === OVERLAY_EXPAND_KEY && + !key.ctrl && + !key.meta && + !key.option && + toggleOverlayExpand(shell) + ) { + key.preventDefault(); + return; + } + if (shell.overlayKind === "copy") { + if (key.name === "y" && !key.ctrl && !key.meta && !key.option) { + key.preventDefault(); + confirmCopySelection(shell); + return; + } + if (key.name === "a" && !key.ctrl && !key.meta && !key.option) { + key.preventDefault(); + copyAllTargets(shell); + return; + } + } + if (key.name === "return" || key.name === "enter") { + if (!key.meta && !key.option && !key.ctrl) { + key.preventDefault(); + acceptOverlaySelection(shell); + return; + } + } + return; + } + + // Emacs-style prompt editing: Ctrl+B/F/D, arrow motion, and Alt+B/F word + // motion are already native InputRenderable bindings (see + // defaultTextareaKeyBindings in @opentui/core). What's missing is the + // kill ring — Ctrl+K/U/W and Alt+D delete natively but discard the text; + // Ctrl+Y/Alt+Y need somewhere to yank it back from. + const keyName = typeof key.name === "string" ? key.name.toLowerCase() : ""; + + // Everything below this line is the un-bracketed-paste fallback, and a + // terminal that has ever fired a real `paste` event has proven it never + // needs it: every future paste arrives as one `paste` event, not raw + // keystrokes, so re-running these checks on it would only risk a false + // positive for no benefit. + if (!sawBracketedPaste) { + // The LF half of a CRLF pair the block below just turned into a + // newline: without this, "line one\r\nline two" would insert two + // newlines, one for the converted CR and one for the LF right behind it. + const suppressLinefeed = suppressNextLinefeed; + suppressNextLinefeed = false; + if (suppressLinefeed && keyName === "linefeed" && !key.ctrl && !key.meta && !key.option) { + key.preventDefault(); + return; + } + + // A bare CR is the same "return" that submits. Left alone, pasting + // three lines here sends three separate messages instead of composing + // one. Detecting it needs two signals, not one: a lone fast Enter can + // happen (key rollover, a scripted "send keys"), and a lone printable + // character right before Enter is just typing. What never happens from + // a human is a printable character landing, then Enter, both inside a + // keystroke burst -- that shape is unique to a paste being replayed + // byte-for-byte. Gating on both keeps a deliberate Ctrl+J-then-Enter + // (newline, then send) safe, since Ctrl+J is not "a printable + // character," while still catching "...line oneline two...". + const now = Date.now(); + const sincePreviousKey = now - lastKeyAt; + const previousKeyWasPrintable = lastKeyWasPrintable; + lastKeyAt = now; + lastKeyWasPrintable = isPrintableInsertKey(key); + const isBareReturn = + !key.ctrl && !key.meta && !key.option && (keyName === "return" || keyName === "kpenter"); + if (isBareReturn && previousKeyWasPrintable && sincePreviousKey < PASTE_BURST_MS) { + key.preventDefault(); + shell.prompt.insertText("\n"); + suppressNextLinefeed = true; + return; + } + } + + const isCtrlKillYank = + key.ctrl && + !key.meta && + !key.option && + (keyName === "k" || keyName === "u" || keyName === "w" || keyName === "y"); + const isAltKillYank = + (key.meta || key.option) && !key.ctrl && (keyName === "d" || keyName === "y"); + if (!isCtrlKillYank && !isAltKillYank) { + shell.promptKillRing = breakKillSequence(shell.promptKillRing); + } + + if (key.ctrl && !key.meta && !key.option && keyName === "k") { + key.preventDefault(); + const before = shell.prompt.value; + const beforeCursor = shell.prompt.cursorOffset; + shell.prompt.deleteToLineEnd(); + const killed = killedTextForward(before, beforeCursor, shell.prompt.value); + shell.promptKillRing = recordKill(shell.promptKillRing, killed, "forward"); + return; + } + + if (key.ctrl && !key.meta && !key.option && keyName === "u") { + key.preventDefault(); + const before = shell.prompt.value; + const beforeCursor = shell.prompt.cursorOffset; + shell.prompt.deleteToLineStart(); + const killed = killedTextBackward(before, beforeCursor, shell.prompt.cursorOffset); + shell.promptKillRing = recordKill(shell.promptKillRing, killed, "backward"); + return; + } + + if (key.ctrl && !key.meta && !key.option && keyName === "w") { + key.preventDefault(); + const before = shell.prompt.value; + const beforeCursor = shell.prompt.cursorOffset; + shell.prompt.deleteWordBackward(); + const killed = killedTextBackward(before, beforeCursor, shell.prompt.cursorOffset); + shell.promptKillRing = recordKill(shell.promptKillRing, killed, "backward"); + return; + } + + if ((key.meta || key.option) && !key.ctrl && keyName === "d") { + key.preventDefault(); + const before = shell.prompt.value; + const beforeCursor = shell.prompt.cursorOffset; + shell.prompt.deleteWordForward(); + const killed = killedTextForward(before, beforeCursor, shell.prompt.value); + shell.promptKillRing = recordKill(shell.promptKillRing, killed, "forward"); + return; + } + + if (key.ctrl && !key.meta && !key.option && keyName === "y") { + key.preventDefault(); + const yank = beginYank(shell.promptKillRing, shell.prompt.cursorOffset); + if (yank !== null) { + shell.promptKillRing = yank.ring; + shell.prompt.insertText(yank.text); + } + return; + } + + if ((key.meta || key.option) && !key.ctrl && keyName === "y") { + key.preventDefault(); + const rotated = rotateYank(shell.promptKillRing); + if (rotated !== null && rotated.span.end <= shell.prompt.value.length) { + shell.promptKillRing = rotated.ring; + shell.prompt.setSelection(rotated.span.start, rotated.span.end); + shell.prompt.deleteSelection(); + shell.prompt.cursorOffset = rotated.span.start; + shell.prompt.insertText(rotated.text); + } + return; + } + + // Ctrl+V is a real keypress (0x16), not the system paste: the terminal + // turns CMD+V into bracketed paste, which OpenTUI delivers as its own + // `paste` event and the InputRenderable inserts as text. Binding Ctrl+V + // here therefore cannot swallow an ordinary text paste. + if (key.ctrl && !key.meta && !key.option && (keyName === "p" || keyName === "v")) { + key.preventDefault(); + void attachClipboardImage(shell); + return; + } + + // Typing @ at a token boundary opens path suggestions. The overlay owns + // focus while open, so the @ is inserted here rather than left to the + // InputRenderable, which would race the focus change. + if ( + !key.ctrl && + !key.meta && + !key.option && + key.sequence === "@" && + focusOwner(shell.focus) === "prompt" + ) { + const before = shell.prompt.value.slice(0, shell.prompt.cursorOffset); + if (before.length === 0 || /\s$/.test(before)) { + key.preventDefault(); + shell.prompt.insertText("@"); + void openAtMentionSuggestions(shell); + return; + } + } + + // A slash command is only valid as the whole prompt, so `/` pops the + // command list at the start of an empty prompt and nowhere else — mid-line + // it is just a path separator. + if ( + !key.ctrl && + !key.meta && + !key.option && + key.sequence === "/" && + focusOwner(shell.focus) === "prompt" && + shell.prompt.cursorOffset === 0 && + shell.prompt.value.trim().length === 0 + ) { + key.preventDefault(); + setPromptText(shell, "/"); + openSlashCommands(shell); + return; + } + + if ( + !key.ctrl && + !key.meta && + !key.option && + (key.name === "up" || key.name === "down") && + focusOwner(shell.focus) === "prompt" + ) { + // Multi-row prompt: Up/Down are caret motion first. Recall only fires at + // the buffer's edges, which is where a shell history is conventionally + // reachable and where the caret has nowhere left to go. + const stepped = + key.name === "up" + ? promptCaretAtFirstRow(shell.prompt) + ? stepSentHistoryUp(shell.sentHistory, shell.prompt.value) + : null + : promptCaretAtLastRow(shell.prompt) + ? stepSentHistoryDown(shell.sentHistory, shell.prompt.value, shell.prompt.value.length) + : null; + if (stepped !== null) { + key.preventDefault(); + shell.sentHistory = stepped.browse; + shell.prompt.value = stepped.value; + shell.prompt.cursorOffset = stepped.cursor; + return; + } + } else if (!MOTION_KEYS.has(keyName)) { + shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); + } + + if ( + ((key.name === "tab" && key.shift) || key.name === "backtab") && + !key.ctrl && + !key.meta && + !key.option + ) { + key.preventDefault(); + effortCycleHandlers.get(shell)?.(); + return; + } + + if (key.name === "tab" && !key.ctrl && !key.meta && !key.option && !key.shift) { + key.preventDefault(); + toggleShellFocus(shell); + return; + } + + // Alt+E, never bare: the prompt almost always holds focus, and a bare + // `e` would just type a letter into it instead of expanding a row. + if ((key.meta || key.option) && !key.ctrl && key.name === EXPAND_KEY) { + if (toggleCollapsedRow(shell)) { + key.preventDefault(); + return; + } + } + + if ((key.meta || key.option) && (key.name === "c" || key.name === "C") && !key.ctrl) { + // Alt+C: keyboard copy path (no mouse drag-select). + key.preventDefault(); + enterCopyMode(shell); + return; + } + + if ((key.meta || key.option) && (key.name === "m" || key.name === "M") && !key.ctrl) { + // Alt+M: release mouse reporting so the terminal can drag-select. + key.preventDefault(); + toggleMouseCapture(shell); + return; + } + + if ((key.meta || key.option) && (key.name === "t" || key.name === "T") && !key.ctrl) { + // Alt+T: the task panel's only entry point now that the palette is gone. + // Losing the palette must not lose the toggle with it. + key.preventDefault(); + toggleTasksPanel(shell); + return; + } + + if ((key.meta || key.option) && (key.name === "o" || key.name === "O") && !key.ctrl) { + // Alt+O: observe a live subagent, same rationale as Alt+T — this was + // the palette's "observe" action and needs a real chord now the + // palette is gone, not a silently orphaned feature. + key.preventDefault(); + observeActiveSubagent(shell); + return; + } + + if (key.ctrl && key.name === "c") { + key.preventDefault(); + handleCtrlC(shell); + return; + } + + if (key.ctrl && key.name === "g") { + // Readline/Emacs "abort" chord — unclaimed by both the textarea's + // default bindings and this shell's other chords, and already means + // "cancel the pending thing" to muscle memory, unlike Ctrl+X (cut). + key.preventDefault(); + applyShellCancelLast(shell); + return; + } + + if ((key.name === "return" || key.name === "enter") && (key.meta || key.option) && !key.ctrl) { + // Alt+Enter: follow-up — enqueue kind "queue"; deliver only when the + // run goes idle. Does not interrupt or reinject. Idle / empty: no-op + // (nothing to wait for). Soft steer is plain Enter below; reinject is + // not wired to any product chord. + key.preventDefault(); + if (shell.session.run !== "busy") return; + submitPrompt(shell, "queue"); + return; + } + }; + return { onKey, onPaste }; +} diff --git a/src/tui/shell/layout.ts b/src/tui/shell/layout.ts new file mode 100644 index 000000000..45274cb22 --- /dev/null +++ b/src/tui/shell/layout.ts @@ -0,0 +1,119 @@ +/** + * Terminal geometry: layout application, relayout, prompt-row sync, landing split. + */ +import { + FLEET_FLOOR_MIN_LANES, + FLEET_TRANSCRIPT_FLOOR, + type OverlayMode, + type ZoneVisibility, +} from "../geometry/index.js"; +import { splitLandingRows, versionBadgeVisible } from "../landing.js"; + +import { type AppShell, shellInternals, type ShellRenderer } from "./internals.js"; + +export function terminalOf( + renderer: ShellRenderer, + override?: { readonly columns: number; readonly rows: number }, +): { columns: number; rows: number } { + if (override) { + return { + columns: Math.max(1, Math.floor(override.columns)), + rows: Math.max(1, Math.floor(override.rows)), + }; + } + return { + columns: Math.max(1, Math.floor(renderer.width || 80)), + rows: Math.max(1, Math.floor(renderer.height || 24)), + }; +} + +/** + * The version row is real chrome, not a float — it holds its own reserved + * row at the foot of the shell rather than painting into the optical bottom + * pad (`BOTTOM_MARGIN_ROWS`), which is blank breathing room, not a content + * slot. + * + * This genuinely costs the rest of the shell a row, not just the space it + * paints in: the geometry resolver is handed `terminal.rows - 1`, so every + * height it derives from that — including `PROMPT_CAP_FRACTION * + * terminal.rows`, which runs before collapse and outside `COLLAPSE_ORDER` — + * is computed one row short of the real terminal. The badge does not sit in + * the collapse order and does not give the row back under prompt-growth + * pressure; it is not "free" chrome, it is chrome the operator pays a row + * for on the landing screen, same as the task or agents panel would. + */ +export function terminalForGeometry(terminal: { + readonly columns: number; + readonly rows: number; +}): { + columns: number; + rows: number; +} { + if (!versionBadgeVisible(terminal.columns, terminal.rows)) return terminal; + return { columns: terminal.columns, rows: Math.max(1, terminal.rows - 1) }; +} + +export function defaultVisibility(visibility?: ZoneVisibility): ZoneVisibility { + return { + notice: false, + progress: false, + progressDivider: false, + // Explicit 0 rather than left undefined: task and agents are row + // counts, and setChromeZones compares them by ===, so an undefined + // start forces one needless relayout the first time either is compared. + task: 0, + agents: 0, + ...visibility, + }; +} + +/** + * How the landing divides its rows around the prompt box. + * + * A floated overlay is clipped to the rows above the box so it never covers the + * thing the operator types into. Losing the tail of a long body to that clip is + * survivable; losing every choice is not, because then the surface cannot be + * answered. So the box slides down just far enough to keep the overlay's full, + * already fraction-capped height on screen, and the starters below it pay for + * the move. + */ +export function landingSplitFor( + landingRows: number, + minOverlayRows: number, + padRows: number, +): { readonly above: number; readonly below: number } { + const even = splitLandingRows(landingRows); + const needed = Math.min(landingRows, minOverlayRows - padRows); + if (minOverlayRows <= 0 || even.above >= needed) return even; + return { above: needed, below: Math.max(0, landingRows - needed) }; +} + +export interface RelayoutOpts { + readonly columns?: number; + readonly rows?: number; + readonly visibility?: ZoneVisibility; + readonly promptContentRows?: number; + readonly overlayMode?: OverlayMode; + readonly overlayBodyRows?: number; + /** + * Rows the open overlay cannot render without: border + title + at least + * one content row. Below this, the box paints past whatever height it was + * assigned instead of shrinking, so the resolver must never starve it here. + */ + readonly overlayMinBodyRows?: number; +} + +/** + * Rows the transcript holds back once a fleet is running. + * + * With several lanes live the operator is managing a fleet rather than reading + * a conversation, so the transcript gives up its idle floor to the board. It + * keeps enough to stay a live tail — the orchestrator reporting back and asking + * questions is still the main way the operator learns anything. + */ +export function fleetTranscriptFloor(shell: AppShell): { transcriptFloor?: number } { + const bag = shellInternals(shell); + if (!bag) return {}; + const lanes = bag.chrome.agents.filter((row) => row.kind === "lane").length; + return lanes >= FLEET_FLOOR_MIN_LANES ? { transcriptFloor: FLEET_TRANSCRIPT_FLOOR } : {}; +} diff --git a/src/tui/shell/observe.ts b/src/tui/shell/observe.ts new file mode 100644 index 000000000..144a0e7e5 --- /dev/null +++ b/src/tui/shell/observe.ts @@ -0,0 +1,114 @@ +/** + * Subagent observe surface insertion and teardown. + */ +import { focusOwner, openObserve, popFocus } from "../focus/index.js"; +import { type ObserveSession } from "../residuals.js"; + +import { type AppShell, getPaletteOnObserveRequest } from "./internals.js"; +import { + appendObserveStreamRow, + appendStreamRow, + applyFocus, + repaintTranscriptWindow, + setChromeZones, +} from "./chrome.js"; + +/** + * Enter a child subagent session view. + * Host passes live rows + agent label (`ObserveSession`); fixture via + * `makeObserveFixture()` is only for demo/tests. Esc restores parent lease. + */ +export function enterSubagentObserve(shell: AppShell, session: ObserveSession): void { + if (shell.observe) { + leaveSubagentObserve(shell); + } + + const seedLines = session.lines.slice(); + shell.parentStreamLog = shell.streamLog.slice(); + shell.parentStreamLogBase = shell.streamLogBase; + shell.observe = { + sessionId: session.sessionId, + agentId: session.agentId, + description: session.description, + lines: seedLines.slice(), + }; + + // A fresh log for the child view; its own indices start at zero regardless + // of how far the parent's retention cap has already trimmed. + shell.streamLog = seedLines; + shell.streamLogBase = 0; + shell.lineCount = shell.streamLog.length; + repaintTranscriptWindow(shell); + + shell.focus = openObserve(shell.focus, `observe-${session.sessionId}`); + setChromeZones(shell, { + agents: [ + { + label: `observe: ${session.agentId} — ${session.description}`, + tail: "", + stalled: false, + }, + ], + }); + // Child chrome toast — must not route to parent snapshot. + appendObserveStreamRow(shell, { + role: "system", + text: `Viewing ${session.agentId}: ${session.description}`, + meta: "observe", + }); + applyFocus(shell); +} + +/** Leave observe; restore parent stream + focus lease. */ +export function leaveSubagentObserve(shell: AppShell): void { + if (!shell.observe) return; + + const agentId = shell.observe.agentId; + shell.observe = null; + + if (shell.parentStreamLog) { + shell.streamLog = shell.parentStreamLog; + shell.streamLogBase = shell.parentStreamLogBase ?? 0; + shell.parentStreamLog = null; + shell.parentStreamLogBase = null; + } + shell.lineCount = shell.streamLog.length; + repaintTranscriptWindow(shell); + + let guard = 4; + while (guard-- > 0 && focusOwner(shell.focus) === "observe") { + shell.focus = popFocus(shell.focus); + } + // Drop any observe frames that weren't top. + const frames = shell.focus.frames.filter((f) => f.target !== "observe"); + if (frames.length > 0) shell.focus = { frames }; + + setChromeZones(shell, { agents: null }); + appendStreamRow(shell, { + role: "system", + text: `left observe (${agentId})`, + meta: "observe", + }); + applyFocus(shell); +} + +/** + * Alt+O: observe a live subagent (its only entry point now that the palette + * is gone — the palette's "observe" action used to call this same + * `onObserveRequest` host hook). An honest "nothing to observe" flash rather + * than doing nothing when there is no live session, so the chord is + * discoverable as working even when it currently has nothing to show. + */ +export function observeActiveSubagent(shell: AppShell): void { + const onObserveRequest = getPaletteOnObserveRequest(shell); + const session = onObserveRequest ? onObserveRequest() : null; + if (session) { + enterSubagentObserve(shell, session); + return; + } + appendStreamRow(shell, { + role: "system", + text: "no subagent session to observe", + meta: "observe", + }); +} diff --git a/src/tui/shell/overlay-host.ts b/src/tui/shell/overlay-host.ts new file mode 100644 index 000000000..c77323553 --- /dev/null +++ b/src/tui/shell/overlay-host.ts @@ -0,0 +1,881 @@ +/** + * The single overlay host: float/relayout, reservations, deferred commands, close paths, answer field. + */ +import { type CliRenderer, type KeyEvent } from "@opentui/core"; +import { RUNTIME_FLASH_MS } from "../runtime-notices.js"; +import { focusOwner, openOverlay, popFocus } from "../focus/index.js"; +import { OVERLAY_MAX_FRACTION, PROMPT_BASE_ROWS } from "../geometry/index.js"; +import { type PaletteCommand } from "../command-catalog.js"; +import { streamLogMarkdown, writeClipboard } from "../copy-path.js"; +import { UI } from "../theme.js"; +import { + isDecisionOverlay, + overlayRowWidth, + overlayRowsPerItem, + overlayTitleRows, + overlayChromeRows, + overlayMinHostRows, + OVERLAY_HOST_BORDER_ROWS, +} from "../overlay-view.js"; +import { + composeDecisionBody, + decisionContextBudget, + overlayChoiceText, + overlayKindWord, + wrapOverlayText, +} from "../overlay-body.js"; + +import { + type AppShell, + clearMentionAccept, + EMPTY_PRIMARY_BINDINGS, + getPaletteOnCommand, + isSlashPopupOpen, + liveMentionAccept, + mentionPopups, + type OpenListOverlayOpts, + type OverlaySelection, + type PrimaryOverlayKind, + shellInternals, + slashPopups, +} from "./internals.js"; +import { createOverlayList, dispatchOverlayAccept, relayoutOverlayHost } from "./overlay-list.js"; +import { + appendStreamRow, + applyFocus, + overlayAnswerState, + paintOverlayList, + relayout, + setStatusFlash, +} from "./chrome.js"; + +function refreshOverlayTitle(shell: AppShell): void { + const bag = shellInternals(shell); + if (!bag) return; + shell.overlayView.paintTitle( + { + title: bag.overlayTitleText, + kind: shell.overlayKind, + hasChoices: shell.overlayItems.length > 0, + answer: overlayAnswerState(shell), + addProviderHint: bag.primaryBindings.addProviderHint, + setDefaultHint: bag.primaryBindings.setDefaultHint, + mcpManageHint: bag.primaryBindings.mcpManageHint, + mcpAddHint: bag.primaryBindings.mcpAddHint, + }, + shell.layout.contentWidth, + ); +} + +const OVERLAY_FRAME_ID = "inset-demo"; + +/** Re-shape and store the open overlay's body rows for the current width. */ +export function applyOverlayBodyText( + shell: AppShell, + text: string, + maxLines: number, + terminalHeight = shell.renderer.height, +): void { + const width = overlayRowWidth(shell.layout.contentWidth); + const bag = shellInternals(shell); + // Scoped to decision overlays: a palette stacked over an open approval + // calls this too, with its own (usually empty) body text. Caching that + // would overwrite the approval's cached raw text with the palette's, and + // popping the palette restores the approval's `overlayBodyLines` but not + // this cache (`PriorOverlaySnapshot` never carried it) — so a resize right + // after would re-shape the approval's body from the palette's stale empty + // string instead of its own, blanking it. The palette itself never reads + // this cache (not a decision overlay), so it never needs to be cached. + if (bag && isDecisionOverlay(shell.overlayKind)) bag.overlayRawBodyText = text; + if (text.length === 0) { + shell.overlayBodyLines = []; + shell.overlayBodyFgs = []; + return; + } + if (isDecisionOverlay(shell.overlayKind)) { + const rows = composeDecisionBody( + text, + width, + decisionContextBudget({ + terminalHeight, + overlayRowsPerItem: overlayRowsPerItem(shell.overlayKind), + overlayTitleRows: overlayTitleRows(shell.overlayKind), + overlayHostBorderRows: OVERLAY_HOST_BORDER_ROWS, + overlayMaxFraction: OVERLAY_MAX_FRACTION, + promptBaseRows: PROMPT_BASE_ROWS, + }), + ); + shell.overlayBodyLines = rows.map((r) => r.text); + shell.overlayBodyFgs = rows.map((r) => r.fg); + return; + } + const lines = wrapOverlayText(text, width, maxLines); + shell.overlayBodyLines = lines; + shell.overlayBodyFgs = lines.map(() => UI.text); +} + +/** + * Open an inset list overlay on the shared host (permissions / operator / picker / palette). + * Measures body + list into geometry — no guessed absolute paint. + * + * Single host: a non-palette open while anything is showing is a silent no-op + * unless `deferIfBusy` is set, in which case it waits in one deferred slot + * with a system line. Callers that replace a non-gate list close it first. + * Palette may stack over a prior primary. + */ +export function openListOverlay(shell: AppShell, opts?: OpenListOverlayOpts): void { + const kind = opts?.kind ?? "demo"; + const isPalette = kind === "palette"; + + // Single host: non-palette open is a silent no-op while anything is open, + // unless the caller opted into the one deferred command-surface slot. + // Command surfaces that should replace a non-gate list call + // closeReplaceableOverlay first. Palette may stack over a prior primary. + if (shell.overlayList) { + if (!isPalette) { + if (opts?.deferIfBusy === true) deferBusyCommandOpen(shell, opts); + return; + } + if (shell.overlayKind !== "palette") { + const bag = shellInternals(shell); + if (bag) { + bag.priorOverlay = { + kind: shell.overlayKind, + items: shell.overlayItems, + bodyLines: shell.overlayBodyLines, + bodyFgs: shell.overlayBodyFgs, + list: shell.overlayList, + title: String(shell.overlayTitle.content), + paletteCommands: shell.paletteCommands, + primaryBindings: { ...bag.primaryBindings }, + answer: bag.overlayAnswer, + titleText: bag.overlayTitleText, + }; + } + // Leave prior overlay focus frame; palette will stack above it. + } else { + // Already palette — pop palette frame only so we re-push cleanly. + let guard = 4; + while (guard-- > 0 && focusOwner(shell.focus) === "palette") { + shell.focus = popFocus(shell.focus); + } + } + } + + const labels = opts?.items ?? shell.overlayItems; + shell.overlayItems = labels; + shell.overlayKind = kind; + if (!isPalette) shell.paletteCommands = []; + + const bag = shellInternals(shell); + if (bag) { + bag.overlayGeneration += 1; + // A stacked palette borrows the primary bindings until restoration. + if (!isPalette || !bag.priorOverlay) { + bag.primaryBindings = { + itemIds: opts?.itemIds ? [...opts.itemIds] : [], + itemValues: opts?.itemValues ? [...opts.itemValues] : [], + onAccept: opts?.onAccept ?? null, + onToggleExpand: opts?.onToggleExpand ?? null, + onCycle: opts?.onCycle ?? null, + describe: opts?.describe ?? null, + onAction: opts?.onAction ?? null, + onPaste: opts?.onPaste ?? null, + onCancel: opts?.onCancel ?? null, + onDispose: opts?.onDispose ?? null, + isGate: opts?.isGate === true, + addProviderHint: opts?.addProviderHint ?? false, + setDefaultHint: opts?.setDefaultHint ?? false, + mcpManageHint: opts?.mcpManageHint ?? false, + mcpAddHint: opts?.mcpAddHint ?? false, + }; + bag.overlayEchoChoice = opts?.echoChoice ?? true; + // Capture the full unfiltered set so typing can re-narrow in place. + bag.listFilter = + !isPalette && opts?.typeToFilter === true + ? { + query: "", + allItems: [...labels], + allItemIds: opts?.itemIds ? [...opts.itemIds] : [], + allItemValues: opts?.itemValues ? [...opts.itemValues] : [], + } + : null; + } + if (!isPalette) { + bag.overlayAnswer = + opts?.onTextAnswer === undefined + ? null + : { + text: "", + // With nothing to choose, typing is the only way to answer, so + // the field takes the keys immediately. + active: opts.textAnswerActive ?? labels.length === 0, + onSubmit: opts.onTextAnswer, + }; + } + } + + // Type-to-filter list overlays paint a `>` query row; everything else uses + // the caller's body text (or empty). + const bodyText = + !isPalette && opts?.typeToFilter === true + ? `> ${bag?.listFilter?.query ?? ""}` + : (opts?.body ?? ""); + // Operator question and permission approval context get body lines; other + // list-only overlays keep the body empty. + applyOverlayBodyText(shell, bodyText, 0); + + // Ask for exactly what the content needs. The resolver caps the request + // against OVERLAY_MAX_FRACTION and the transcript floor, and applyLayout + // shrinks the viewport to whatever survived — so a longer list scrolls + // instead of growing, and a short one leaves no dead rows below it. + // An empty list charges no rows: a chooser with nothing to choose must not + // reserve a blank band the operator can neither read nor act on. + const listItems = labels.length; + + shell.overlayList = createOverlayList(shell.renderer as CliRenderer, { + count: labels.length, + items: Math.max(1, listItems), + activeIndex: opts?.activeIndex ?? 0, + }); + + if (bag) bag.overlayTitleText = opts?.title ?? "permission"; + refreshOverlayTitle(shell); + + const frameId = opts?.frameId ?? OVERLAY_FRAME_ID; + const focusTarget = isPalette ? "palette" : "overlay"; + shell.focus = openOverlay(shell.focus, frameId, { + target: focusTarget, + scrollOwner: isPalette ? "palette" : "overlay", + }); + relayoutOverlayHost(shell, listItems); + applyFocus(shell); + paintOverlayList(shell); + opts?.onOpened?.(); +} + +/** Open inset permission/palette stub; focus stack owns keys; Esc closes. */ +export function openInsetOverlay(shell: AppShell, items?: readonly string[]): void { + openListOverlay(shell, { + kind: "demo", + title: "permission", + items: items ?? shell.overlayItems, + frameId: OVERLAY_FRAME_ID, + }); +} + +export function repaintListFilter(shell: AppShell): void { + const bag = shellInternals(shell); + const state = bag?.listFilter; + if (!state) return; + const q = state.query.trim().toLowerCase(); + const matched: { label: string; id: string; value: string | undefined }[] = []; + for (let i = 0; i < state.allItems.length; i++) { + const label = state.allItems[i] ?? ""; + const id = state.allItemIds[i] ?? label; + if (q.length > 0) { + const hay = `${label} ${id}`.toLowerCase(); + if (!hay.includes(q)) continue; + } + matched.push({ + label, + id, + value: state.allItemValues[i], + }); + } + const labels = matched.length > 0 ? matched.map((m) => m.label) : ["(no matches)"]; + const ids = matched.length > 0 ? matched.map((m) => m.id) : [""]; + const values = + state.allItemValues.length > 0 + ? matched.length > 0 + ? matched.map((m) => m.value) + : [undefined] + : undefined; + setOverlayItems(shell, labels, ids, values); + setOverlayBody(shell, `> ${state.query}`); +} + +/** + * Move the open overlay's free-text field in or out of taking keystrokes. + * Returns false when the overlay offers no such field. + */ +export function setOverlayAnswerActive(shell: AppShell, active: boolean): boolean { + const answer = overlayAnswerState(shell); + if (answer === null || shell.overlayList === null) return false; + if (answer.active === active) return false; + answer.active = active; + refreshOverlayTitle(shell); + paintOverlayList(shell); + return true; +} + +/** + * Esc inside a live answer field means "back to the choices", not "abandon the + * question" — but only when there are choices to go back to. + */ +export function exitOverlayAnswerMode(shell: AppShell): boolean { + const answer = overlayAnswerState(shell); + if (answer === null || !answer.active) return false; + if (shell.overlayItems.length === 0) return false; + return setOverlayAnswerActive(shell, false); +} + +/** + * Keys the free-text answer field claims while it is taking input. Printable + * characters and backspace edit the answer; Enter submits it and closes the + * overlay through the per-open `onTextAnswer` callback. + */ +export function handleOverlayAnswerKey(shell: AppShell, key: KeyEvent): boolean { + const answer = overlayAnswerState(shell); + if (answer === null || shell.overlayList === null) return false; + + if (key.name === "tab" && !key.shift && !key.ctrl && !key.meta && !key.option && !answer.active) { + return setOverlayAnswerActive(shell, true); + } + if (!answer.active) return false; + if (key.ctrl || key.meta || key.option) return false; + + if (key.name === "return" || key.name === "enter") { + if (answer.text.length === 0) return true; + const text = answer.text; + const submit = answer.onSubmit; + const bag = shellInternals(shell); + if (bag?.overlayEchoChoice !== false) { + appendStreamRow(shell, { + role: "system", + text: `answered: ${text}`, + meta: overlayKindWord(shell.overlayKind ?? "operator"), + }); + } + // Deliberate submit, not a dismiss — closeInsetOverlay must not also fire + // the Esc/cancel path. + if (bag) bag.primaryBindings.onCancel = null; + closeInsetOverlay(shell); + submit(text); + return true; + } + if (key.name === "backspace") { + if (answer.text.length > 0) { + answer.text = answer.text.slice(0, -1); + paintOverlayList(shell); + } + return true; + } + + const seq = typeof key.sequence === "string" ? key.sequence : ""; + if (seq.length !== 1 || seq < " ") return false; + answer.text += seq; + paintOverlayList(shell); + return true; +} + +/** Close overlay/palette if open; restore prior focus (or prior overlay under palette). */ +export function closeInsetOverlay(shell: AppShell): void { + if (!shell.overlayList) return; + // Esc (or any other dismiss) must also drop the `/` and `@` popups' key claim. + slashPopups.delete(shell); + if (mentionPopups.has(shell)) clearMentionAccept(shell); + mentionPopups.delete(shell); + + const wasPalette = shell.overlayKind === "palette"; + if (wasPalette) { + const filterBag = shellInternals(shell); + if (filterBag) filterBag.paletteFilter = null; + } + const bag = shellInternals(shell); + if (bag) bag.listFilter = null; + const prior = wasPalette ? (bag?.priorOverlay ?? null) : null; + // A primary overlay that registers onCancel owns cleanup for every dismiss + // path. A palette stacked over another overlay restores that prior frame + // instead, so its callback must remain untouched. + const onCancel = !prior ? (bag?.primaryBindings.onCancel ?? null) : null; + const onDispose = !prior ? (bag?.primaryBindings.onDispose ?? null) : null; + + shell.overlayList = null; + shell.overlayKind = null; + shell.overlayBodyLines = []; + shell.overlayBodyFgs = []; + shell.paletteCommands = []; + shell.copyTargets = null; + shell.overlayView.clearBody(); + // Esc / dismiss: drop accept path without invoking it (onCancel above is + // captured before this clears, and is invoked separately once state settles). + if (bag && !prior) { + bag.primaryBindings = { ...EMPTY_PRIMARY_BINDINGS }; + bag.overlayAnswer = null; + } + + // Pop exactly one frame (palette or overlay). + if (focusOwner(shell.focus) === "overlay" || focusOwner(shell.focus) === "palette") { + shell.focus = popFocus(shell.focus); + } + + if (prior && bag) { + bag.priorOverlay = null; + // Restore prior primary overlay paint; focus should already be overlay. + shell.overlayItems = prior.items; + shell.overlayKind = prior.kind; + shell.overlayBodyLines = prior.bodyLines; + shell.overlayBodyFgs = prior.bodyFgs; + shell.overlayList = prior.list; + shell.paletteCommands = prior.paletteCommands; + shell.overlayTitle.visible = true; + shell.overlayTitle.content = prior.title; + bag.primaryBindings = { ...prior.primaryBindings }; + bag.overlayAnswer = prior.answer; + bag.overlayTitleText = prior.titleText; + // If focus was not stacked (edge case), re-open overlay frame. + if (focusOwner(shell.focus) !== "overlay") { + shell.focus = openOverlay(shell.focus, OVERLAY_FRAME_ID, { + target: "overlay", + scrollOwner: "overlay", + }); + } + relayoutOverlayHost(shell, prior.list.count); + applyFocus(shell); + paintOverlayList(shell); + return; + } + + // Ensure no leftover overlay/palette frames. + let guard = 4; + while ( + guard-- > 0 && + (focusOwner(shell.focus) === "overlay" || focusOwner(shell.focus) === "palette") + ) { + shell.focus = popFocus(shell.focus); + } + + relayout(shell, { overlayMode: "closed" }); + applyFocus(shell); + if (bag) bag.overlayGeneration += 1; + if (isOverlayHostIdle(shell)) notifyOverlayClosed(shell); + try { + onDispose?.(); + onCancel?.(); + } finally { + scheduleDeferredCommandFlush(shell); + } +} + +/** + * Close the current overlay only when dismissing it does not settle a + * decision gate (`isGate`). Command surfaces that need a fresh host + * (settings cycle, plugins, mcp) call this instead of `closeInsetOverlay` + * so a live gate is left in place and `openListOverlay` can defer. + * Overlays that bind `onDispose` for cleanup (mcp unsubscribe) still + * run that hook; `onCancel` is Esc/dismiss only and is skipped here. + */ +export function closeReplaceableOverlay(shell: AppShell): void { + const bag = shellInternals(shell); + if (bag?.primaryBindings.isGate === true) return; + if (bag) bag.primaryBindings.onCancel = null; + closeInsetOverlay(shell); +} + +/** + * Subscribe to "the overlay host is idle". Idle means no live list, no + * deferred command surface, and no host reservations. Callers that must not + * lose an open (gate wiring) queue on this instead of racing a busy host. + */ +export function onOverlayClosed(shell: AppShell, listener: () => void): () => void { + const bag = shellInternals(shell); + if (!bag) return () => undefined; + bag.overlayClosedListeners.add(listener); + return () => { + bag.overlayClosedListeners.delete(listener); + }; +} + +/** + * True when the shared overlay host can accept a new primary open: the shell + * is live, no list is showing, no deferred command is waiting, and nothing + * holds a reservation. + */ +export function isOverlayHostIdle(shell: AppShell): boolean { + if (shell.disposed) return false; + const bag = shellInternals(shell); + return ( + shell.overlayList === null && + (bag?.deferredCommandOverlay ?? null) === null && + (bag?.overlayHostReservations ?? 0) === 0 + ); +} + +export function notifyOverlayClosed(shell: AppShell): void { + if (!isOverlayHostIdle(shell)) return; + const bag = shellInternals(shell); + if (!bag) return; + // Copied: a listener may re-open an overlay and unsubscribe mid-iteration. + for (const listener of [...bag.overlayClosedListeners]) listener(); +} + +/** + * Hold the overlay host idle-notify while an async command surface is still + * claiming it (permissions.list() before settings/permissions paint). Release + * clears the hold, flushes a deferred surface if one is waiting, and notifies + * if the host is actually idle. + */ +export function reserveOverlayHost(shell: AppShell): () => void { + const bag = shellInternals(shell); + if (!bag) return () => undefined; + bag.overlayHostReservations += 1; + const epoch = bag.overlayReservationEpoch; + let released = false; + return () => { + if (released) return; + released = true; + const current = shellInternals(shell); + if (!current || current.overlayReservationEpoch !== epoch) return; + if (current.overlayHostReservations > 0) current.overlayHostReservations -= 1; + scheduleDeferredCommandFlush(shell); + notifyOverlayClosed(shell); + }; +} + +/** Drop in-flight host holds. Stale `release()` callbacks become no-ops. */ +export function abortOverlayHostReservations(shell: AppShell): void { + const bag = shellInternals(shell); + if (!bag || bag.overlayHostReservations === 0) return; + bag.overlayReservationEpoch += 1; + bag.overlayHostReservations = 0; + bag.overlayGeneration += 1; + scheduleDeferredCommandFlush(shell); +} + +/** One deferred command-surface slot while the host is busy. */ +function deferBusyCommandOpen(shell: AppShell, opts: OpenListOverlayOpts): void { + const bag = shellInternals(shell); + if (!bag) return; + bag.deferredCommandOverlay = opts.kind === undefined ? { ...opts, kind: "demo" } : opts; + const kind = overlayKindWord(opts.kind ?? "demo"); + appendStreamRow(shell, { + role: "system", + text: `${kind} will open when the current list closes.`, + }); + scheduleDeferredCommandFlush(shell); +} + +function scheduleDeferredCommandFlush(shell: AppShell): void { + const bag = shellInternals(shell); + if (!bag || bag.deferredCommandOverlay === null || bag.deferredFlushScheduled) return; + bag.deferredFlushScheduled = true; + queueMicrotask(() => { + bag.deferredFlushScheduled = false; + if (shell.disposed) { + bag.deferredCommandOverlay = null; + return; + } + flushDeferredCommandOverlay(shell); + }); +} + +function flushDeferredCommandOverlay(shell: AppShell): void { + const bag = shellInternals(shell); + if (!bag) return; + // Live list still occupies the host — keep the slot. + if (shell.overlayList !== null) return; + const opts = bag.deferredCommandOverlay; + if (opts === null) { + notifyOverlayClosed(shell); + return; + } + bag.deferredCommandOverlay = null; + // Reservations/disposed still occupy the host; restore the slot. + if (!isOverlayHostIdle(shell)) { + bag.deferredCommandOverlay = opts; + return; + } + openListOverlay(shell, opts); +} + +export function dropDeferredCommandOverlay(shell: AppShell): void { + const bag = shellInternals(shell); + if (!bag) return; + bag.deferredCommandOverlay = null; + bag.deferredFlushScheduled = false; +} + +/** Replace the open overlay's body text in place (re-wrap + relayout). */ +export function setOverlayBody(shell: AppShell, text: string, maxLines = 8): void { + if (!shell.overlayList) return; + applyOverlayBodyText(shell, text, maxLines); + // Ask for the whole list again, not the height it currently has: a body that + // shrank should hand its rows back to the choices rather than leave the + // viewport stuck at the size an earlier, taller body forced it to. + const perItem = overlayRowsPerItem(shell.overlayKind); + const chrome = overlayChromeRows( + shell.overlayKind, + shell.overlayBodyLines.length, + !!shellInternals(shell)?.primaryBindings.describe, + overlayAnswerState(shell) !== null, + ); + const hostRows = chrome + Math.max(1, shell.overlayItems.length) * perItem; + const minHostRows = overlayMinHostRows(chrome, perItem, shell.overlayItems.length > 0); + relayout(shell, { + overlayMode: "inset", + overlayBodyRows: hostRows, + overlayMinBodyRows: minHostRows, + }); + paintOverlayList(shell); +} + +export interface OverlayContinuationToken { + readonly generation: number; +} + +/** Capture overlay generation for an async continuation. Stale after a newer open, a full close, or Esc abort. */ +export function captureOverlayContinuation(shell: AppShell): OverlayContinuationToken { + return { generation: shellInternals(shell)?.overlayGeneration ?? -1 }; +} + +/** True only while no newer overlay has taken ownership of the shared host. */ +export function isOverlayContinuationCurrent( + shell: AppShell, + token: OverlayContinuationToken, +): boolean { + return isOverlayGenerationCurrent(shell, token) && shell.overlayList === null; +} + +/** True while the shell is live and generation has not advanced. */ +export function isOverlayGenerationCurrent( + shell: AppShell, + token: OverlayContinuationToken, +): boolean { + return !shell.disposed && shellInternals(shell)?.overlayGeneration === token.generation; +} + +/** + * Refresh an overlay owned by either the foreground or the frame beneath a + * stacked palette. Returns false once that overlay no longer owns either slot. + */ +export function setOwnedOverlayItems( + shell: AppShell, + kind: PrimaryOverlayKind, + items: readonly string[], + itemIds: readonly string[], +): boolean { + const bag = shellInternals(shell); + if (!bag) return false; + + if (shell.overlayKind === kind && shell.overlayList !== null) { + const previousCount = shell.overlayItems.length; + const activeId = bag.primaryBindings.itemIds[shell.overlayList.activeIndex]; + const filter = bag.listFilter; + if (filter) { + bag.listFilter = { + query: filter.query, + allItems: [...items], + allItemIds: [...itemIds], + allItemValues: filter.allItemValues, + }; + repaintListFilter(shell); + } else { + setOverlayItems(shell, items, itemIds); + } + const displayedCount = shell.overlayItems.length; + const activeIndex = activeId === undefined ? -1 : bag.primaryBindings.itemIds.indexOf(activeId); + if (activeIndex >= 0 && shell.overlayList.activeIndex !== activeIndex) { + shell.overlayList.jump(activeIndex); + paintOverlayList(shell); + } + if (displayedCount !== previousCount) { + shell.overlayList?.setHeight(Math.max(1, displayedCount)); + relayoutOverlayHost(shell, displayedCount); + paintOverlayList(shell); + } + return true; + } + + const prior = bag.priorOverlay; + if (prior?.kind !== kind) return false; + const activeId = prior.primaryBindings.itemIds[prior.list.activeIndex]; + const activeIndex = activeId === undefined ? -1 : itemIds.indexOf(activeId); + bag.priorOverlay = { + ...prior, + items: [...items], + primaryBindings: { ...prior.primaryBindings, itemIds: [...itemIds] }, + list: createOverlayList(shell.renderer as CliRenderer, { + count: items.length, + items: prior.list.height, + activeIndex: activeIndex >= 0 ? activeIndex : prior.list.activeIndex, + }), + }; + return true; +} + +/** + * Replace the open overlay's item labels (and optionally ids) in place, + * keeping the active row's position. Cycling a value redraws the row it + * changed rather than closing and reopening the overlay, which would lose + * the cursor and retrigger the open animation for a one-key edit. + */ +export function setOverlayItems( + shell: AppShell, + items: readonly string[], + itemIds?: readonly string[], + itemValues?: readonly (string | undefined)[], + opts?: { readonly resetActive?: boolean }, +): void { + if (!shell.overlayList) return; + shell.overlayItems = items; + const bag = shellInternals(shell); + if (bag && itemIds) bag.primaryBindings.itemIds = [...itemIds]; + if (bag && itemValues) bag.primaryBindings.itemValues = [...itemValues]; + // Most callers (mention/model-picker filtering) keep the operator's current + // selection as the list narrows. The `/` popup instead resets to the top + // row on every keystroke, matching pre-refresh behavior where each filter + // reopened the overlay fresh. + shell.overlayList.setCount(items.length); + if (opts?.resetActive) shell.overlayList.jump(0); + paintOverlayList(shell); +} + +/** Accept active overlay item → callback + system line + close (palette dispatches action). + * Mention Enter that is not live (stale generation or cursor off that `@`) dismisses. */ +export function acceptOverlaySelection(shell: AppShell): void { + if (!shell.overlayList) return; + + if (shell.overlayKind === "copy") { + confirmCopySelection(shell); + return; + } + // Nothing to choose: Enter must not synthesize a phantom row and resolve the + // gate with it. The answer field (when offered) already claimed Enter. + if (shell.overlayItems.length === 0) return; + + const idx = shell.overlayList.activeIndex; + const label = shell.overlayItems[idx] ?? `item ${idx}`; + const kind = shell.overlayKind ?? "demo"; + const bag = shellInternals(shell); + + if (kind === "palette") { + const cmd = shell.paletteCommands[idx]; + if (!cmd) { + // Type-to-filter plants a "(no matches)" row with no command. Stay open. + // Slash popup (`typeToFilter: false`) still closes — intentional dismiss. + if (bag?.paletteFilter?.typeToFilter === true && !isSlashPopupOpen(shell)) return; + closeInsetOverlay(shell); + return; + } + const release = reserveOverlayHost(shell); + closeInsetOverlay(shell); + try { + dispatchPaletteSelection(shell, cmd); + } finally { + release(); + } + return; + } + + if (kind === "mentions" && mentionPopups.has(shell) && liveMentionAccept(shell) === null) { + // Stale generation or cursor off the @token: operator dismiss, not accept. + closeInsetOverlay(shell); + return; + } + + const id = bag?.primaryBindings.itemIds[idx]; + // Type-to-filter plants "(no matches)" with an empty-id sentinel. Stay open. + if (id === "") return; + const value = bag?.primaryBindings.itemValues[idx]; + const selection: OverlaySelection = { + kind, + index: idx, + label, + ...(id !== undefined ? { id } : {}), + ...(value !== undefined ? { value } : {}), + }; + // Capture before close clears per-open state. + const perOpen = bag?.primaryBindings.onAccept ?? null; + // This is a deliberate accept, not a dismiss — closeInsetOverlay must not + // also fire the Esc/cancel path below. + if (bag) bag.primaryBindings.onCancel = null; + + if (bag?.overlayEchoChoice !== false) { + appendStreamRow(shell, { + role: "system", + text: overlayChoiceText(label, id, value), + meta: overlayKindWord(kind), + }); + } + // Accept is not operator dismiss: keep mention accept state for onAccept + // after this close (closeInsetOverlay would otherwise bump the generation). + if (kind === "mentions") mentionPopups.delete(shell); + const release = reserveOverlayHost(shell); + closeInsetOverlay(shell); + try { + dispatchOverlayAccept(shell, selection, perOpen); + } finally { + release(); + } +} + +/** + * Dispatch a selected `/` command list item after the popup has closed. + * Every entry is registry-backed — the host's `onCommand(name)` runs it. + */ +export function dispatchPaletteSelection(shell: AppShell, cmd: PaletteCommand): void { + const onCommand = getPaletteOnCommand(shell); + if (onCommand) { + onCommand(cmd.id); + return; + } + appendStreamRow(shell, { + role: "system", + text: `palette: /${cmd.id} (no onCommand handler)`, + }); +} + +/** Write the frozen target at the active list index; status flash only. */ +export function confirmCopySelection(shell: AppShell): boolean { + const targets = shell.copyTargets; + if (!targets || targets.length === 0 || !shell.overlayList) { + setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); + closeInsetOverlay(shell); + return false; + } + const idx = Math.max(0, Math.min(targets.length - 1, shell.overlayList.activeIndex)); + const target = targets[idx]; + if (!target) { + setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); + closeInsetOverlay(shell); + return false; + } + const preview = + target.text.length > 48 ? `${target.text.slice(0, 45).replace(/\s+/g, " ")}…` : target.text; + writeClipboard(shell.clipboard, target.text, { + onSuccess: () => { + setStatusFlash(shell, `Copied ${target.label} (${target.text.length} chars): ${preview}`, { + ttlMs: RUNTIME_FLASH_MS, + }); + }, + onFailure: () => { + setStatusFlash(shell, "Copy failed", { ttlMs: RUNTIME_FLASH_MS }); + }, + }); + closeInsetOverlay(shell); + return true; +} + +/** Copy all frozen targets as markdown; status flash only. */ +export function copyAllTargets(shell: AppShell): boolean { + const targets = shell.copyTargets; + if (!targets || targets.length === 0) { + setStatusFlash(shell, "nothing to copy", { ttlMs: RUNTIME_FLASH_MS }); + if (shell.overlayKind === "copy") closeInsetOverlay(shell); + return false; + } + const text = streamLogMarkdown(targets); + writeClipboard(shell.clipboard, text, { + onSuccess: () => { + setStatusFlash(shell, `Copied all (${targets.length} items, ${text.length} chars)`, { + ttlMs: RUNTIME_FLASH_MS, + }); + }, + onFailure: () => { + setStatusFlash(shell, "Copy failed", { ttlMs: RUNTIME_FLASH_MS }); + }, + }); + closeInsetOverlay(shell); + return true; +} diff --git a/src/tui/shell/overlay-list.ts b/src/tui/shell/overlay-list.ts new file mode 100644 index 000000000..b1d03eadd --- /dev/null +++ b/src/tui/shell/overlay-list.ts @@ -0,0 +1,275 @@ +/** + * The overlay list wrapper (SelectRenderable) and its selection delegations. + */ +import { + SelectRenderable, + type KeyEvent, + type RenderContext, + type SelectOption, +} from "@opentui/core"; +import { UI } from "../theme.js"; +import { overlayRowsPerItem, overlayChromeRows, overlayMinHostRows } from "../overlay-view.js"; + +import { + type AppShell, + getShellOverlayHooks, + type OverlayList, + type OverlaySelection, + shellInternals, +} from "./internals.js"; +import { activeOverlayItemId, overlayAnswerState, paintOverlayList, relayout } from "./chrome.js"; + +/** Dispatch accept to per-open callback, then shell-level kind hooks. */ +export function dispatchOverlayAccept( + shell: AppShell, + selection: OverlaySelection, + perOpen: ((selection: OverlaySelection) => void) | null, +): void { + if (perOpen) { + perOpen(selection); + return; + } + const hooks = getShellOverlayHooks(shell); + if (!hooks) return; + switch (selection.kind) { + case "permissions": + if (hooks.onPermission) { + hooks.onPermission(selection); + return; + } + break; + case "operator": + if (hooks.onOperator) { + hooks.onOperator(selection); + return; + } + break; + case "model_picker": + if (hooks.onModel) { + hooks.onModel(selection); + return; + } + break; + case "settings": + if (hooks.onSettings) { + hooks.onSettings(selection); + return; + } + break; + case "help": + if (hooks.onHelp) { + hooks.onHelp(selection); + return; + } + break; + case "plugins": + if (hooks.onPlugins) { + hooks.onPlugins(selection); + return; + } + break; + case "resume": + if (hooks.onResume) { + hooks.onResume(selection); + return; + } + break; + case "mentions": + if (hooks.onMentions) { + hooks.onMentions(selection); + return; + } + break; + default: + break; + } + hooks.onSelect?.(selection); +} + +/** + * Recompute the overlay host's row budget from the current item count and + * relayout into it. Callers that refresh an already-open overlay's items in + * place (rather than reopening) must call this themselves — a filter that + * narrows a list and then widens it again would otherwise stay pinned at + * whatever size it first opened at. + */ +export function relayoutOverlayHost(shell: AppShell, itemCount: number): void { + const perItem = overlayRowsPerItem(shell.overlayKind); + const chrome = overlayChromeRows( + shell.overlayKind, + shell.overlayBodyLines.length, + !!shellInternals(shell)?.primaryBindings.describe, + overlayAnswerState(shell) !== null, + ); + const hostRows = chrome + itemCount * perItem; + const minHostRows = overlayMinHostRows(chrome, perItem, itemCount > 0); + relayout(shell, { + overlayMode: "inset", + overlayBodyRows: hostRows, + overlayMinBodyRows: minHostRows, + }); +} + +interface OverlayListShape { + items: number; + rowsPerItem: number; +} + +function placeholderOptions(count: number): SelectOption[] { + return Array.from({ length: Math.max(0, count) }, () => ({ name: "", description: "" })); +} + +/** + * SelectRenderable keeps its scroll offset and visible-item capacity private + * in its type surface (@opentui/core 0.5.10 exposes no accessors for either), + * so the wrapper reads them reflectively and narrows the values instead of + * asserting a shape. + */ +function selectScrollState(select: SelectRenderable): { offset: number; visible: number } { + const numberProp = (name: string): number => { + const value = Object.getOwnPropertyDescriptor(select, name)?.value; + return typeof value === "number" ? value : 1; + }; + return { + offset: numberProp("scrollOffset"), + visible: Math.max(1, numberProp("maxVisibleItems")), + }; +} + +export function createOverlayList( + ctx: RenderContext, + opts: { count: number; items: number; activeIndex?: number }, +): OverlayList { + let shape: OverlayListShape = { items: Math.max(1, opts.items), rowsPerItem: 1 }; + let count = Math.max(0, opts.count); + const activeIndex = opts.activeIndex ?? 0; + let options = placeholderOptions(count); + + const build = (): SelectRenderable => + new SelectRenderable(ctx, { + options, + selectedIndex: activeIndex, + height: shape.items * shape.rowsPerItem, + width: "100%", + flexShrink: 0, + showDescription: shape.rowsPerItem > 1, + showSelectionIndicator: true, + itemSpacing: 0, + // Selection is a text colour, not a filled band: the highlighted row + // already stands out, and a block would fight the host's background. + backgroundColor: UI.ground, + focusedBackgroundColor: UI.ground, + selectedBackgroundColor: UI.ground, + textColor: UI.textDim, + focusedTextColor: UI.textDim, + selectedTextColor: UI.text, + descriptionColor: UI.textDim, + selectedDescriptionColor: UI.text, + }); + + let select = build(); + + const reshape = (next: Partial): void => { + const merged = { ...shape, ...next }; + if (merged.items === shape.items && merged.rowsPerItem === shape.rowsPerItem) { + return; + } + // A rebuild lands on the open-time index; carry the live selection across + // (clamped to the new count) so a resize does not snap the cursor back. + const current = select.getSelectedIndex(); + shape = merged; + select = build(); + select.setSelectedIndex(count === 0 ? 0 : Math.min(count - 1, Math.max(0, current))); + }; + + return { + get select() { + return select; + }, + get activeIndex() { + return select.getSelectedIndex(); + }, + get height() { + return shape.items; + }, + get offset() { + return selectScrollState(select).offset; + }, + get count() { + return count; + }, + move(delta: number) { + if (delta < 0) select.moveUp(-delta); + else if (delta > 0) select.moveDown(delta); + }, + page(dir: -1 | 1) { + this.move(dir * (shape.items > 1 ? shape.items - 1 : 1)); + }, + jump(index: number) { + if (count === 0) return; + select.setSelectedIndex(Math.max(0, Math.min(count - 1, Math.floor(index)))); + }, + setCount(next: number) { + count = Math.max(0, Math.floor(next)); + options = placeholderOptions(count); + select.options = options; + }, + setHeight(items: number, rowsPerItem?: number) { + reshape({ items: Math.max(1, Math.floor(items)), ...(rowsPerItem ? { rowsPerItem } : {}) }); + }, + visibleRange() { + const { offset, visible } = selectScrollState(select); + return { start: offset, end: Math.min(count, offset + visible) }; + }, + }; +} + +/** Run the open overlay's expand/collapse hook; true when one was bound. */ +export function toggleOverlayExpand(shell: AppShell): boolean { + if (!shell.overlayList) return false; + const hook = shellInternals(shell)?.primaryBindings.onToggleExpand ?? null; + if (!hook) return false; + hook(); + return true; +} + +/** Move overlay selection (j/k / arrows). */ +export function moveOverlaySelection(shell: AppShell, delta: number): void { + if (!shell.overlayList) return; + shell.overlayList.move(delta); + paintOverlayList(shell); +} + +/** + * Cycle the focused row's value in place, for overlays that opted in via + * `onCycle` (settings inline cycling). No-op when the open overlay did not + * supply a cycle hook, so Left/Right stay unclaimed everywhere else. + */ +export function cycleOverlaySelection(shell: AppShell, direction: -1 | 1): boolean { + const list = shell.overlayList; + if (!list) return false; + const onCycle = shellInternals(shell)?.primaryBindings.onCycle; + if (!onCycle) return false; + onCycle(activeOverlayItemId(shell, list), direction); + return true; +} + +/** + * Run the open overlay's bare-key claim, for overlays that opted in via + * `onAction`. No-op when the open overlay did not supply one, so the key + * falls through unclaimed everywhere else. + */ +export function runOverlayAction(shell: AppShell, key: KeyEvent): boolean { + const list = shell.overlayList; + if (!list) return false; + const onAction = shellInternals(shell)?.primaryBindings.onAction; + if (!onAction) return false; + return onAction(activeOverlayItemId(shell, list), key); +} + +/** Page overlay selection (PgUp/PgDn). */ +export function pageOverlaySelection(shell: AppShell, dir: -1 | 1): void { + if (!shell.overlayList) return; + shell.overlayList.page(dir); + paintOverlayList(shell); +} diff --git a/src/tui/shell/palette.ts b/src/tui/shell/palette.ts new file mode 100644 index 000000000..732ba7699 --- /dev/null +++ b/src/tui/shell/palette.ts @@ -0,0 +1,588 @@ +/** + * Palette, slash and mention popups: filtering, open/close, key handling. + */ +import { type KeyEvent } from "@opentui/core"; +import { listPathSuggestions } from "../components/at-mention/list.js"; +import { parseAtState } from "../components/at-mention/parse.js"; +import { sentHistoryOnEdit } from "../sent-message-history.js"; +import { spliceMentionCompletion } from "../prompt-attachments.js"; +import { filterPaletteCommands, paletteLabels, type PaletteCommand } from "../command-catalog.js"; +import { helpItems } from "../keybindings.js"; +import { filterMentionSuggestions, splitMentionToken } from "../mention-filter.js"; + +import { + type AppShell, + clearMentionAccept, + isSlashPopupOpen, + type ItemDescription, + liveMentionAccept, + mentionAcceptState, + type MentionAcceptState, + mentionGenerations, + mentionPopups, + type MentionSuggestionSource, + type OverlaySelection, + shellInternals, + shellMentionSource, + slashPopupQuery, + slashPopups, +} from "./internals.js"; +import { relayoutOverlayHost } from "./overlay-list.js"; +import { + closeInsetOverlay, + closeReplaceableOverlay, + dispatchPaletteSelection, + openListOverlay, + repaintListFilter, + reserveOverlayHost, + setOverlayItems, +} from "./overlay-host.js"; +import { paintOverlayList } from "./chrome.js"; + +/** Resolve the shell's registry-backed command catalog (host-injected). */ +export function resolvePaletteCatalog(shell: AppShell): readonly PaletteCommand[] { + const bag = shellInternals(shell); + const raw = bag?.paletteCatalog; + if (raw === null || raw === undefined) return []; + return typeof raw === "function" ? raw() : raw; +} + +/** + * Replace the shell's `/` command catalog (host rebinds after registry load). + * Pass null to clear it. + */ +export function setPaletteCatalog( + shell: AppShell, + catalog: readonly PaletteCommand[] | (() => readonly PaletteCommand[]) | null, +): void { + const bag = shellInternals(shell); + if (bag) bag.paletteCatalog = catalog; +} + +/** + * Open the `/` command list overlay. Catalog: opts.catalog when given, else + * the shell's registry-backed default (see `resolvePaletteCatalog`). + */ +export function openPalette( + shell: AppShell, + opts?: { + readonly query?: string; + readonly catalog?: readonly PaletteCommand[]; + readonly title?: string; + /** Claim printable keys for the `>` filter row. Off for the `/` popup. */ + readonly typeToFilter?: boolean; + }, +): void { + const title = opts?.title ?? "command palette"; + const bag = shellInternals(shell); + if (bag) { + bag.paletteFilter = { + query: opts?.query ?? "", + title, + // `/` passes a pre-narrowed catalog; omitting it re-resolves the shell + // default so a registry loaded later is picked up. + catalog: opts?.catalog ?? null, + // The `/` popup keeps its query in the prompt and drives its own reopen. + typeToFilter: opts?.typeToFilter ?? false, + }; + } + repaintPalette(shell); +} + +/** Re-open the palette against the current filter state (used on every keystroke). */ +function repaintPalette(shell: AppShell): void { + const state = shellInternals(shell)?.paletteFilter; + if (!state) return; + const catalog = state.catalog ?? resolvePaletteCatalog(shell); + const commands = filterPaletteCommands(state.query, catalog); + const labels = commands.length > 0 ? paletteLabels(commands) : ["(no matches)"]; + shell.paletteCommands = commands; + openListOverlay(shell, { + kind: "palette", + title: state.title, + items: labels, + itemIds: commands.map((c) => c.id), + describe: (id) => { + const cmd = commands.find((c) => c.id === id); + const what = cmd?.description?.trim(); + return what ? { what } : null; + }, + // Typed filter row only when the overlay owns keystrokes. The `/` popup + // keeps its query in the prompt, so a body of `>` would be orphan chrome. + ...(state.typeToFilter ? { body: `> ${state.query}` } : {}), + frameId: "command-palette", + }); + // No title rule row: the box is only ever the palette, and when a filter + // row is present it already shows what's typed. + shell.overlayTitle.visible = false; + shell.overlayTitle.content = ""; + paintOverlayList(shell); +} + +/** + * Keys a type-to-filter list claims while it is open, so the `>` row filters + * as you type. + * + * Opt-in per open (`typeToFilter`): palette, the flat model picker, and the + * resume picker give up j/k navigation so printable keys feed the filter. + * Overlays without type-to-filter (permissions, workers, copy, …) keep j/k. Arrow and + * page keys are never claimed here, so they keep working in every overlay + * including type-to-filter ones. + */ +export function handlePaletteFilterKey(shell: AppShell, key: KeyEvent): boolean { + const state = shellInternals(shell)?.paletteFilter; + if (!state?.typeToFilter) return false; + if (shell.overlayKind !== "palette" || shell.overlayList === null) return false; + if (key.ctrl || key.meta || key.option) return false; + + if (key.name === "backspace") { + if (state.query.length === 0) return true; + state.query = state.query.slice(0, -1); + repaintPalette(shell); + return true; + } + + const seq = typeof key.sequence === "string" ? key.sequence : ""; + if (seq.length !== 1 || seq < " ") return false; + + state.query += seq; + repaintPalette(shell); + return true; +} + +/** + * Glyphs some terminals emit for Option+A without setting meta/option. + */ +const OPTION_A_COMPOSED_CHARS = new Set(["å", "Å"]); + +/** + * True when a key event is the model-picker Alt+A add-provider chord. + * Terminals may deliver Option+A as å/Å without meta/option. + */ +export function isAddProviderShortcutKey(key: KeyEvent): boolean { + if (key.ctrl) return false; + const name = typeof key.name === "string" ? key.name : ""; + const seq = typeof key.sequence === "string" ? key.sequence : ""; + if ((key.meta || key.option) && name.toLowerCase() === "a") return true; + if (OPTION_A_COMPOSED_CHARS.has(name) || OPTION_A_COMPOSED_CHARS.has(seq)) return true; + return false; +} + +/** + * Keys a type-to-filter list overlay claims while open, so the `>` row + * narrows as you type. Mirrors the palette filter, but updates the open + * list in place via setOverlayItems (a busy openListOverlay is a silent + * no-op unless `deferIfBusy` is set). + */ +export function handleListFilterKey(shell: AppShell, key: KeyEvent): boolean { + const bag = shellInternals(shell); + const state = bag?.listFilter; + if (!state || shell.overlayList === null) return false; + if (shell.overlayKind === "palette") return false; + if (key.ctrl || key.meta || key.option) return false; + + // addProviderHint also gates this filter-bypass so composed Option+A + // (å/Å) reaches runOverlayAction instead of type-to-filter. + if ( + bag?.primaryBindings.addProviderHint === true && + shell.overlayKind === "model_picker" && + isAddProviderShortcutKey(key) + ) { + return false; + } + + if (key.name === "backspace") { + if (state.query.length === 0) return true; + state.query = state.query.slice(0, -1); + repaintListFilter(shell); + return true; + } + + const seq = typeof key.sequence === "string" ? key.sequence : ""; + if (seq.length !== 1 || seq < " ") return false; + + state.query += seq; + repaintListFilter(shell); + return true; +} + +/** + * Host-injected residual list open. `items` is owned by the caller — there is + * no fallback, so a missing dependency must produce an honest empty state or + * a surfaced error upstream rather than reach this with nothing to show. + * Per-open `onAccept` wins over shell-level residual hooks for that open. + */ +export interface OpenResidualListOpts { + readonly items: readonly string[]; + /** Stable ids aligned with `items` (setting keys, session ids, paths). */ + readonly itemIds?: readonly string[]; + /** Plain chosen value aligned with `items`, for the accept echo (see `OpenListOverlayOpts.itemValues`). */ + readonly itemValues?: readonly (string | undefined)[]; + readonly activeIndex?: number; + /** Per-open accept; host binds toggle / resume / mention insert. */ + readonly onAccept?: (selection: OverlaySelection) => void; + /** Per-open ← → cycle hook (settings inline value cycling). */ + readonly onCycle?: (itemId: string, direction: -1 | 1) => void; + /** Per-open description-zone source. */ + readonly describe?: (itemId: string) => ItemDescription | null; +} + +export function openSettingsOverlay(shell: AppShell, opts: OpenResidualListOpts): void { + openListOverlay(shell, { + kind: "settings", + title: "settings", + items: opts.items, + activeIndex: opts.activeIndex ?? 0, + frameId: "overlay-settings", + deferIfBusy: true, + ...(opts.itemIds !== undefined ? { itemIds: opts.itemIds } : {}), + ...(opts.itemValues !== undefined ? { itemValues: opts.itemValues } : {}), + ...(opts.onAccept !== undefined ? { onAccept: opts.onAccept } : {}), + ...(opts.onCycle !== undefined ? { onCycle: opts.onCycle } : {}), + ...(opts.describe !== undefined ? { describe: opts.describe } : {}), + }); +} + +export function openHelpOverlay(shell: AppShell): void { + const release = reserveOverlayHost(shell); + try { + closeReplaceableOverlay(shell); + openListOverlay(shell, { + kind: "help", + title: "help · keymap", + items: helpItems(), + activeIndex: 0, + frameId: "overlay-help", + deferIfBusy: true, + }); + } finally { + release(); + } +} + +export function openMentionsOverlay(shell: AppShell, opts: OpenResidualListOpts): void { + openListOverlay(shell, { + kind: "mentions", + title: "mentions", + items: opts.items, + activeIndex: opts.activeIndex ?? 0, + frameId: "overlay-mentions", + ...(opts.itemIds !== undefined ? { itemIds: opts.itemIds } : {}), + ...(opts.onAccept !== undefined ? { onAccept: opts.onAccept } : {}), + }); +} + +/** Keys that only move the caret — they must not cancel history browsing. */ +export const MOTION_KEYS: ReadonlySet = new Set([ + "up", + "down", + "left", + "right", + "home", + "end", + "pageup", + "pagedown", + "tab", + "escape", +]); + +const defaultMentionSource: MentionSuggestionSource = (prefix) => + listPathSuggestions(prefix, process.cwd()); + +/** + * Open path suggestions for the @token under the cursor and splice the + * accepted entry back into the prompt. Directory picks re-open one level + * down so the operator can drill in without typing the path. + * Returns false when the cursor is not inside an @token, nothing matched, + * a newer lookup superseded this one, or the overlay host was taken. + * + * Accept requires a current generation and a live `@` token under the cursor. + * A lookup that finishes after the cursor has left this token does not open. + */ +export async function openAtMentionSuggestions(shell: AppShell): Promise { + const at = parseAtState(shell.prompt.value, shell.prompt.cursorOffset); + if (at === null) { + closeMentionPopup(shell); + return false; + } + + // Every keystroke re-queries; a slower earlier query must not overwrite the + // list a later one already produced. + const generation = (mentionGenerations.get(shell) ?? 0) + 1; + mentionGenerations.set(shell, generation); + + const source = shellMentionSource.get(shell) ?? defaultMentionSource; + const token = splitMentionToken(at.prefix); + let suggestions = filterMentionSuggestions(await source(token.dir), token.fragment); + // Quitting mid-lookup tears down the renderer/TextBuffer this function + // writes into below; a resolved-but-stale lookup must not touch them. + if (shell.disposed) return false; + // The source caps how many entries it returns per directory, so a large + // directory can cap out before the interior match appears. Asking it to do + // its own prefix filter puts that cap after the narrowing instead of before. + if (suggestions.length === 0 && token.fragment.length > 0) { + suggestions = await source(at.prefix); + if (shell.disposed) return false; + } + if (mentionGenerations.get(shell) !== generation) return false; + + if (suggestions.length === 0) { + // Mirrors `/`'s no-match contract: close the popup and leave the typed + // text standing, with no empty-state message. + closeMentionPopup(shell); + return false; + } + + // The operator may have left this token while the lookup was in flight. + // Do not open, and do not arm accept, unless the cursor is still on this @ + // (same atStart). A different live @token is not this lookup. + const liveAt = parseAtState(shell.prompt.value, shell.prompt.cursorOffset); + if (liveAt === null || liveAt.atStart !== at.atStart) { + closeMentionPopup(shell); + return false; + } + + // The onAccept closure reads mentionAcceptState rather than closing over + // `suggestions` directly, so a same-session refresh can update what accept + // splices without re-binding the callback. atStart is the @ this lookup + // started on; the splice end is the live cursor. + const acceptState: MentionAcceptState = { + suggestions, + generation, + atStart: at.atStart, + }; + + // Every keystroke lands here while the popup is already open. Closing and + // reopening the overlay released the host between the two calls — long + // enough for a queued permission/operator gate to open on it — and left the + // gate's overlay on screen while `mentionPopups` still claimed ownership. + // Refreshing the open list in place never releases the host, so a queued + // gate has nothing to drain into. + if (isMentionPopupOpen(shell)) { + mentionAcceptState.set(shell, acceptState); + setOverlayItems(shell, [...suggestions]); + return true; + } + + closeMentionPopup(shell); + openMentionsOverlay(shell, { + items: [...suggestions], + onAccept: (selection) => { + const ready = liveMentionAccept(shell); + if (ready === null) return; + const completion = ready.state.suggestions[selection.index]; + if (completion === undefined) return; + const spliced = spliceMentionCompletion( + shell.prompt.value, + ready.live.atStart, + shell.prompt.cursorOffset, + completion, + ); + editPromptAt(shell, spliced.value, spliced.cursor); + if (completion.endsWith("/")) void openAtMentionSuggestions(shell); + }, + }); + if (shell.overlayKind !== "mentions") return false; + mentionAcceptState.set(shell, acceptState); + mentionPopups.add(shell); + return true; +} + +/** True while the `@` path popup owns typed characters. */ +export function isMentionPopupOpen(shell: AppShell): boolean { + return mentionPopups.has(shell) && shell.overlayKind === "mentions"; +} + +export function closeMentionPopup(shell: AppShell): void { + if (!mentionPopups.has(shell)) return; + clearMentionAccept(shell); + mentionPopups.delete(shell); + if (shell.overlayList) closeInsetOverlay(shell); +} + +function editPromptAt(shell: AppShell, value: string, cursor: number): void { + shell.prompt.value = value; + shell.prompt.cursorOffset = cursor; + shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); +} + +/** + * Keys the `@` popup claims while open — the same contract as the `/` popup: + * printable characters narrow the list, Backspace widens it, and a query that + * matches nothing closes the popup with the typed text left in place. + * + * The prompt does not hold focus while the overlay is open, so this inserts and + * deletes the characters itself rather than letting the InputRenderable do it. + */ +export function handleMentionPopupKey(shell: AppShell, key: KeyEvent): boolean { + if (!isMentionPopupOpen(shell) || shell.overlayList === null) return false; + if (key.ctrl || key.meta || key.option) return false; + + const value = shell.prompt.value; + const cursor = shell.prompt.cursorOffset; + + if (key.name === "backspace") { + if (cursor === 0) { + closeMentionPopup(shell); + return true; + } + editPromptAt(shell, value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1); + // Deleting the `@` itself ends the mention; there is nothing left to filter. + if (value[cursor - 1] === "@") closeMentionPopup(shell); + else void openAtMentionSuggestions(shell); + return true; + } + + const seq = typeof key.sequence === "string" ? key.sequence : ""; + if (seq.length !== 1 || seq < " ") return false; + + editPromptAt(shell, value.slice(0, cursor) + seq + value.slice(cursor), cursor + 1); + // Whitespace terminates the @token, so the popup has nothing left to narrow. + if (/\s/.test(seq)) closeMentionPopup(shell); + else void openAtMentionSuggestions(shell); + return true; +} + +export function closeSlashPopup(shell: AppShell): void { + if (!slashPopups.has(shell)) return; + slashPopups.delete(shell); + if (shell.overlayList) closeInsetOverlay(shell); +} + +/** + * Open (or refresh) the `/` command popup for the name being typed. Reuses the + * palette overlay so accept dispatches through the same registry path as a + * typed `/name`. Returns false when nothing matches — the typed text stays. + */ +export function openSlashCommands(shell: AppShell): boolean { + const query = slashPopupQuery(shell); + if (query === null) { + closeSlashPopup(shell); + return false; + } + // Name-prefix, not the palette's fuzzy label match: at the prompt the + // operator is typing the command they already mean. + const q = query.toLowerCase(); + const matches = resolvePaletteCatalog(shell).filter((cmd) => cmd.id.toLowerCase().startsWith(q)); + + // Every keystroke lands here while the popup is already open. Closing and + // reopening released the overlay host between the two calls (closeSlashPopup + // routes through closeInsetOverlay, which idle-notifies) — long enough for a + // queued permission/operator gate to drain onto it. Refreshing the open + // palette in place never releases the host, so a queued gate has nothing to + // drain into. priorOverlay stacking is untouched here (it is only ever + // written by openListOverlay's stack-on-open path), so a palette stacked + // over a prior overlay keeps that snapshot across the refresh. + // + // A typo that zeroes the matches must not fall through to closeSlashPopup + // while the popup is already open — that closes through the same idle-notify + // path and drains a queued gate mid-filter. Instead this refreshes in place + // to a "(no matches)" row, same as the general palette does, and holds the + // host until a real dismiss (deleting the `/`, Esc, accept) or a backspace + // that restores matches. + if (isSlashPopupOpen(shell) && shell.overlayKind === "palette") { + refreshSlashPopupInPlace(shell, matches); + return true; + } + + if (matches.length === 0) { + closeSlashPopup(shell); + return false; + } + + closeSlashPopup(shell); + openPalette(shell, { catalog: matches, title: "commands · /" }); + slashPopups.add(shell); + return true; +} + +/** Refresh the already-open `/` popup's rows in place for the given matches. */ +function refreshSlashPopupInPlace(shell: AppShell, matches: readonly PaletteCommand[]): void { + const labels = matches.length > 0 ? paletteLabels(matches) : ["(no matches)"]; + shell.paletteCommands = matches; + const bag = shellInternals(shell); + if (bag) { + bag.paletteFilter = { + query: bag.paletteFilter?.query ?? "", + title: "commands · /", + catalog: matches, + typeToFilter: false, + }; + bag.primaryBindings.describe = (id) => { + const cmd = matches.find((c) => c.id === id); + const what = cmd?.description?.trim(); + return what ? { what } : null; + }; + } + setOverlayItems( + shell, + labels, + matches.map((c) => c.id), + undefined, + { + resetActive: true, + }, + ); + relayoutOverlayHost(shell, labels.length); +} + +export function setPromptText(shell: AppShell, value: string): void { + shell.prompt.value = value; + shell.prompt.cursorOffset = value.length; + shell.sentHistory = sentHistoryOnEdit(shell.sentHistory); +} + +/** + * Keys the `/` popup claims while open. Returns true when handled. + * + * Enter runs the highlighted command with no arguments; Tab instead completes + * the name and leaves the popup so arguments can be typed — a command that + * needs arguments should not fire bare just because its name matched. + */ +export function handleSlashPopupKey(shell: AppShell, key: KeyEvent): boolean { + if (!isSlashPopupOpen(shell) || shell.overlayList === null) return false; + + if (key.name === "backspace" && !key.ctrl && !key.meta && !key.option) { + setPromptText(shell, shell.prompt.value.slice(0, -1)); + openSlashCommands(shell); + return true; + } + + const active = shell.paletteCommands[shell.overlayList.activeIndex]; + + if (key.name === "tab" && !key.shift && !key.ctrl && !key.meta && !key.option) { + if (active) setPromptText(shell, `/${active.id} `); + closeSlashPopup(shell); + return true; + } + + if ((key.name === "return" || key.name === "enter") && !key.ctrl && !key.meta && !key.option) { + // Genuine dismiss (zero matches) still notifies immediately so a queued + // gate can drain. Accept-with-match keeps the host until dispatch settles. + if (!active) { + closeSlashPopup(shell); + return true; + } + setPromptText(shell, ""); + slashPopups.delete(shell); + const release = reserveOverlayHost(shell); + closeInsetOverlay(shell); + try { + dispatchPaletteSelection(shell, active); + } finally { + release(); + } + return true; + } + + const seq = typeof key.sequence === "string" ? key.sequence : ""; + const printable = + seq.length === 1 && seq >= " " && seq !== "" && !key.ctrl && !key.meta && !key.option; + if (!printable) return false; + + setPromptText(shell, shell.prompt.value + seq); + // Whitespace ends the name; keep the popup out of the way while args are typed. + if (/\s/.test(seq)) closeSlashPopup(shell); + else openSlashCommands(shell); + return true; +} diff --git a/src/tui/shell/prompt.ts b/src/tui/shell/prompt.ts new file mode 100644 index 000000000..018f95778 --- /dev/null +++ b/src/tui/shell/prompt.ts @@ -0,0 +1,404 @@ +/** + * Prompt box: border rules, labels, highlights, attachments, submit/queue/interrupt paths. + */ +import { unlinkSync } from "node:fs"; +import { SyntaxStyle } from "@opentui/core"; +import { isExitCommand } from "../exit-command.js"; +import { + composePromptActionBarModelLabel, + type PromptActionBarModelLabelInput, +} from "../components/prompt-action-bar-label.js"; +import { + findDuplicateAttachment, + readClipboardImage, + userRowText, + type PendingImageAttachment, +} from "../image-attachments.js"; +import { createSentHistoryBrowse } from "../sent-message-history.js"; +import { + resolvePromptHighlightSpans, + resolvePromptRecognitionMatcher, +} from "../prompt-recognition.js"; +import { RUNTIME_FLASH_MS } from "../runtime-notices.js"; +import { composeCostContextMeter, meterEquals } from "../prompt-border.js"; +import { + badgeCount, + cancelLast, + clearInterruptFlash, + enqueue, + enqueueSteer, + interrupt, +} from "../session-queue.js"; +import { UI } from "../theme.js"; + +import { + type AppShell, + getShellBridgeHooks, + isLanding, + shellExitHandlers, + shellInternals, + shellPromptImageSource, + shellRecognitionSource, +} from "./internals.js"; +import { streamRowAt } from "./transcript.js"; +import { + appendStreamRow, + paintChrome, + paintPromptBorder, + replaceStreamRowAt, + setStatusFlash, +} from "./chrome.js"; + +/** Queue an image for the next submit and reflect it on the notice row. */ +export function addPendingAttachment(shell: AppShell, attachment: PendingImageAttachment): void { + shell.pendingAttachments = [...shell.pendingAttachments, attachment]; + paintChrome(shell); +} + +export function clearPendingAttachments(shell: AppShell): void { + const pending = shell.pendingAttachments; + shell.pendingAttachments = []; + paintChrome(shell); + for (const attachment of pending) { + const ephemeral = attachment.ephemeralPath; + if (ephemeral === undefined) continue; + try { + unlinkSync(ephemeral); + } catch (err) { + if (!isENOENT(err)) throw err; + } + } +} + +function isENOENT(err: unknown): boolean { + return typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT"; +} + +/** + * Ctrl+P: read an image off the clipboard into the pending set. + * Resolves false (with a status flash) when nothing was attached. + */ +export async function attachClipboardImage(shell: AppShell): Promise { + const source = shellPromptImageSource.get(shell) ?? readClipboardImage; + // Sticky until the read resolves — mid-async progress, not a confirmation. + setStatusFlash(shell, "reading clipboard image…"); + const result = await source(); + // Quitting while the clipboard read is pending tears down the shell's + // renderables; a stale continuation must not mutate them on resume. + if (shell.disposed) return false; + if (!result.ok) { + setStatusFlash(shell, `image attach failed: ${result.reason}`, { + ttlMs: RUNTIME_FLASH_MS, + }); + return false; + } + const duplicate = findDuplicateAttachment(shell.pendingAttachments, result.attachment); + if (duplicate !== undefined) { + setStatusFlash(shell, `${duplicate.name} is already attached`, { + ttlMs: RUNTIME_FLASH_MS, + }); + return false; + } + addPendingAttachment(shell, result.attachment); + setStatusFlash(shell, `attached ${result.attachment.name}`, { + ttlMs: RUNTIME_FLASH_MS, + }); + return true; +} + +/** Seed the Up/Down recall list (host replays persisted session messages). */ +export function setSentMessageHistory(shell: AppShell, sent: readonly string[]): void { + shell.sentHistory = createSentHistoryBrowse(sent); +} + +function recordSentMessage(shell: AppShell, text: string): void { + shell.sentHistory = createSentHistoryBrowse([...shell.sentHistory.sent, text]); +} + +/** Publish the `profile · model · effort` label carried by the top border. */ +export function setPromptModelLabel(shell: AppShell, input: PromptActionBarModelLabelInput): void { + const label = composePromptActionBarModelLabel(input) ?? null; + if (label === shell.modelLabel) return; + shell.modelLabel = label; + paintPromptBorder(shell); +} + +/** Publish the working directory and git branch carried by the bottom border. */ +export function setPromptWorkspace( + shell: AppShell, + input: { readonly cwd?: string; readonly branch?: string | null }, +): void { + const cwd = input.cwd ?? shell.workspace.cwd; + const branch = input.branch === undefined ? shell.workspace.branch : input.branch; + if (cwd === shell.workspace.cwd && branch === shell.workspace.branch) return; + shell.workspace = { cwd, branch }; + paintPromptBorder(shell); +} + +/** + * Publish the cost/context meter carried by the bottom border. Driven by + * usage changes (a completed turn), not a timer: the percentage does not move + * between turns, so there is nothing to animate on the idle tick. + */ +export function setPromptCostContext( + shell: AppShell, + input: { + readonly contextPercentUsed: number | null; + readonly costLabel?: string | null; + readonly contextIsEstimate: boolean; + }, +): void { + const meter = composeCostContextMeter(input); + if (meterEquals(meter, shell.costContext)) return; + shell.costContext = meter; + paintPromptBorder(shell); +} + +let cachedPromptSyntaxStyle: SyntaxStyle | null = null; + +let cachedPromptRecognizedStyleId: number | null = null; + +/** + * The style registry backing the prompt's highlights, plus the one style id + * this feature uses. Lazy for the same reason as `transcriptSyntaxStyle`: + * construction reaches into the native render lib. + */ +function promptRecognizedStyleId(): number { + if (cachedPromptSyntaxStyle === null) { + cachedPromptSyntaxStyle = SyntaxStyle.fromStyles({ + recognized: { fg: UI.action }, + }); + } + if (cachedPromptRecognizedStyleId === null) { + cachedPromptRecognizedStyleId = cachedPromptSyntaxStyle.resolveStyleId("recognized") ?? 0; + } + return cachedPromptRecognizedStyleId; +} + +const promptHighlightedValue = new WeakMap(); + +/** + * Re-mark leading slash commands and @mentions in the prompt. Runs once per frame + * (see `onFrame` in `createShell`), and only does anything when the prompt's + * text actually changed since the last frame — typing that doesn't touch a + * token, and every non-typing frame, is a no-op string comparison. + */ +export function syncPromptHighlights(shell: AppShell): void { + const source = shellRecognitionSource.get(shell); + if (source === undefined) return; + const value = shell.prompt.value; + if (promptHighlightedValue.get(shell) === value) return; + promptHighlightedValue.set(shell, value); + + const styleId = promptRecognizedStyleId(); + shell.prompt.syntaxStyle = cachedPromptSyntaxStyle; + shell.prompt.clearAllHighlights(); + const matcher = resolvePromptRecognitionMatcher(source); + for (const span of resolvePromptHighlightSpans(value, matcher)) { + shell.prompt.addHighlightByCharRange({ start: span.start, end: span.end, styleId }); + } +} + +/** + * Surface a runtime/load notice without stealing the landing hero. + * + * MCP connection failures, hook failures and similar startup chatter used to + * call `appendStreamRow` → `clearLandingMark`, wiping the mountain the moment + * anything went wrong on load (CL-5618 / CL-5600). While the landing is still + * mounted the wording rides the notice strip and the row is held for flush + * once a real session row ends the landing; after that it is a normal system + * row. + * + * Every producer of a system-class row belongs here rather than at + * `appendStreamRow`. CL-5618 fixed the MCP and hook producers one at a time + * and the plugin producer kept the defect, which is what per-call-site rules + * buy you. Reaching for `appendStreamRow` directly is the bug. + */ +/** + * Suspend or resume the shell's own key/paste/submit handling. A full-screen + * surface that borrows this renderer (the inline provider connect) owns the + * keyboard for its lifetime; without this, Ctrl+C during a sign-in would + * also reach the shell and interrupt the running agent. + */ +export function setShellInputSuspended(shell: AppShell, suspended: boolean): void { + const bag = shellInternals(shell); + if (bag !== undefined) bag.inputSuspended = suspended; +} + +export function surfaceSystemNotice(shell: AppShell, text: string): void { + if (isLanding(shell)) { + const bag = shellInternals(shell); + if (bag !== undefined) { + bag.landingDeferredRows.push({ role: "system", text }); + } + setStatusFlash(shell, text); + return; + } + appendStreamRow(shell, { role: "system", text }); +} + +/** + * Submit the prompt. Product chords (CL-6290): + * - "steer": mid-run Enter — soft steer at the next tool.boundary. + * - "queue": mid-run Alt+Enter — follow-up; deliver only when the run goes + * idle. Idle Alt+Enter is a no-op at the key handler (never reaches here + * with kind "queue" while idle from the product chord). + * - "reinject": hard-stop and restart from this message. No product chord + * wires this anymore; kept for tests / direct API callers. No-op when the + * run isn't busy, or the prompt is empty. + * - Idle Enter (either queue or steer kind) goes straight through; "kind" + * only matters while a run is in flight. + */ +export function submitPrompt( + shell: AppShell, + kind: "queue" | "steer" | "reinject" = "queue", +): void { + const text = shell.prompt.value; + const t = text.trim(); + const attachments = shell.pendingAttachments; + if (t.length === 0 && attachments.length === 0) { + // Empty Enter still reaches the exclusive host so multi-turn /feedback + // can cancel; non-exclusive shells have nothing to do with a blank line. + const hooks = getShellBridgeHooks(shell); + if (hooks?.exclusive) { + hooks.onSubmit(text, "immediate", attachments); + } + return; + } + // Reinject is unwired from product chords; still guard idle for API callers. + if (kind === "reinject" && shell.session.run !== "busy") return; + // Follow-up idle no-op lives on the Alt+Enter key handler (kind "queue" is + // also the default for submitPrompt and must still send when idle). + + // Shell/REPL muscle memory: a bare `exit` or `quit` quits rather than being + // sent to the model. Attachments mean the operator meant it as a message. + if (attachments.length === 0 && isExitCommand(t)) { + const onExit = shellExitHandlers.get(shell); + if (onExit !== undefined) { + shell.prompt.value = ""; + onExit(); + return; + } + } + + if (t.length > 0) recordSentMessage(shell, t); + const hooks = getShellBridgeHooks(shell); + if (hooks?.exclusive) { + shell.prompt.value = ""; + clearPendingAttachments(shell); + const resolved: "queue" | "steer" | "immediate" | "reinject" = + kind === "reinject" ? "reinject" : shell.session.run === "idle" ? "immediate" : kind; + hooks.onSubmit(text, resolved, attachments); + return; + } + + if (kind === "reinject") { + // Unwired from product chords (CL-6290); kept for tests / direct callers. + shell.session = interrupt(shell.session); + shell.prompt.value = ""; + clearPendingAttachments(shell); + appendStreamRow(shell, { + role: "system", + text: "stop — restarting from your message", + meta: "stop", + }); + appendStreamRow(shell, { + role: "user", + text: userRowText(t, attachments), + meta: "reinject", + }); + paintChrome(shell); + return; + } + + if (shell.session.run === "idle") { + appendStreamRow(shell, { role: "user", text: t }); + shell.prompt.value = ""; + clearPendingAttachments(shell); + return; + } + + shell.session = + kind === "steer" + ? enqueueSteer(shell.session, t, undefined, attachments) + : enqueue(shell.session, t, "queue", undefined, attachments); + const queued = shell.session.items[shell.session.items.length - 1]; + shell.prompt.value = ""; + clearPendingAttachments(shell); + // Show the message itself, not the internal transition ("queue +1 → + // pending N") — the notice row already carries the depth once, in plain + // language, so this row's job is making the pending item identifiable. + appendStreamRow(shell, { + role: "user", + text: userRowText(t, attachments), + meta: kind === "steer" ? "steer" : "queue", + ...(queued !== undefined ? { queueItemId: queued.id } : {}), + }); + paintChrome(shell); +} + +/** + * Find the transcript row a still-pending queue/steer item echoed, so a + * cancel can retract it instead of leaving a message tagged "queue" that will + * never dispatch. Absolute index, matching `replaceStreamRowAt`. + */ +function findQueueRowIndex(shell: AppShell, queueItemId: string): number | undefined { + for (let local = shell.streamLog.length - 1; local >= 0; local--) { + if (shell.streamLog[local]?.queueItemId === queueItemId) { + return shell.streamLogBase + local; + } + } + return undefined; +} + +/** + * Cancel the most recently queued or steered message (last-only: see + * `cancelLast`'s doc comment for why picking an earlier item is out of + * scope). Retracts it from the queue and rewrites its transcript row so the + * readout never shows a message tagged "queue"/"steer" that will not send. + */ +export function applyShellCancelLast(shell: AppShell): void { + const { state, item } = cancelLast(shell.session); + if (item === null) return; + shell.session = state; + const index = findQueueRowIndex(shell, item.id); + if (index !== undefined) { + const row = streamRowAt(shell, index); + if (row !== undefined) { + // `cancelled` stays a flag, not a `text` rewrite — `paintStreamRow` + // owns turning it into the "[cancelled]" prefix, so `row.text` still + // holds what the operator actually typed for anything else that reads + // it (copy mode, a resumed transcript). + replaceStreamRowAt(shell, index, { ...row, meta: "cancelled", cancelled: true }); + } + } + paintChrome(shell); +} + +/** Local interrupt mutation (no bridge re-entry). */ +export function applyShellInterrupt(shell: AppShell): void { + const had = badgeCount(shell.session); + shell.session = interrupt(shell.session); + shell.prompt.value = ""; + appendStreamRow(shell, { + role: "system", + text: had > 0 ? `${had} pending kept` : "stopped", + meta: "stop", + }); + paintChrome(shell); +} + +/** Ctrl+C interrupt path: keep pending, flash, idle. */ +export function interruptShell(shell: AppShell): void { + const hooks = getShellBridgeHooks(shell); + if (hooks?.exclusive) { + hooks.onInterrupt(); + return; + } + applyShellInterrupt(shell); +} + +export function clearShellInterruptFlash(shell: AppShell): void { + shell.session = clearInterruptFlash(shell.session); + paintChrome(shell); +} diff --git a/src/tui/shell/row-retext.ts b/src/tui/shell/row-retext.ts new file mode 100644 index 000000000..7e9c352a9 --- /dev/null +++ b/src/tui/shell/row-retext.ts @@ -0,0 +1,136 @@ +/** + * In-place retext for the styled-line and structured row kinds (diff, tool + * sentence, expansion, MCP structured): these rewrite their paint nodes' + * content instead of being destroyed and rebuilt on every update. A shape + * change (line count, arrow presence) still returns false so the caller + * rebuilds — only how updates apply changes, never what renders. + */ +import { + BoxRenderable, + StyledText, + TextRenderable, + TextTableRenderable, + bold as boldChunk, + fg as fgChunk, + type BaseRenderable, + type TextChunk, +} from "@opentui/core"; +import { stringWidth } from "../view/height.js"; +import { viewToTableContent, type McpStructuredView } from "../mcp-view.js"; +import { + splitTrailingArrow, + expandedRowLines, + isSentenceRow, + streamRowGutter, + toolRowLines, + toolSentenceLines, + type PaintedStreamLine, + type RowLayout, + type StreamRow, + type StyledBodyLine, +} from "../stream.js"; + +/** Map one styled body line's segments to native text chunks. */ +export function diffLineChunks(line: StyledBodyLine): TextChunk[] { + return line.map((segment) => { + const chunk = fgChunk(segment.fg)(segment.text); + return segment.bold === true ? boldChunk(chunk) : chunk; + }); +} + +/** Columns a sentence row's single line paints into, beside its gutter. */ +function sentenceColumns(row: StreamRow, layout: RowLayout): number { + return Math.max(1, layout.width - stringWidth(streamRowGutter(row, layout).content)); +} + +/** + * Retext a styled-lines or structured row kind on its existing node, mirroring + * `buildRowNode`'s kind dispatch. Returns false when the row is not one of + * these kinds or the node shape no longer matches, leaving the caller to + * rebuild. + */ +export function retextStyledKindRow( + node: BaseRenderable, + row: StreamRow, + layout: RowLayout, +): boolean { + if (isSentenceRow(row)) { + const columns = sentenceColumns(row, layout); + if (row.structured !== undefined) { + return row.expanded === true + ? retextStructuredRow(node, row, layout, toolSentenceLines(row, columns), row.structured) + : retextStyledLinesRow(node, row, layout, toolSentenceLines(row, columns)); + } + return retextStyledLinesRow(node, row, layout, toolRowLines(row, columns)); + } + if (row.diff !== undefined) return retextStyledLinesRow(node, row, layout, row.diff.lines); + const expanded = expandedRowLines(row, layout); + if (expanded !== null) return retextStyledLinesRow(node, row, layout, expanded); + if (row.structured !== undefined) { + return retextStructuredRow(node, row, layout, [], row.structured); + } + return false; +} + +function retextGutter(node: TextRenderable, gutter: PaintedStreamLine): void { + node.content = gutter.content; + node.fg = gutter.fg; + node.width = stringWidth(gutter.content); +} + +function retextStyledLinesRow( + node: BaseRenderable, + row: StreamRow, + layout: RowLayout, + lines: readonly StyledBodyLine[], +): boolean { + if (!(node instanceof BoxRenderable)) return false; + const [gutterNode, bodyNode] = node.getChildren(); + if (!(gutterNode instanceof TextRenderable) || !(bodyNode instanceof BoxRenderable)) return false; + const lineNodes = bodyNode.getChildren(); + // A different line count is a different shape — the caller rebuilds. + if (lineNodes.length !== lines.length) return false; + for (const [i, line] of lines.entries()) { + if (!retextBodyLine(lineNodes[i], line)) return false; + } + retextGutter(gutterNode, streamRowGutter(row, layout)); + return true; +} + +/** One body line in place; an arrow line retextes only its body segment. */ +function retextBodyLine(node: BaseRenderable | undefined, line: StyledBodyLine): boolean { + const split = splitTrailingArrow(line); + if (node instanceof TextRenderable) { + if (split !== null) return false; + node.content = new StyledText(diffLineChunks(line)); + return true; + } + if (!(node instanceof BoxRenderable) || split === null) return false; + const [bodyNode] = node.getChildren(); + if (!(bodyNode instanceof TextRenderable)) return false; + bodyNode.content = new StyledText(diffLineChunks(split.body)); + return true; +} + +function retextStructuredRow( + node: BaseRenderable, + row: StreamRow, + layout: RowLayout, + head: readonly StyledBodyLine[], + view: McpStructuredView, +): boolean { + if (!(node instanceof BoxRenderable)) return false; + const [gutterNode, bodyNode] = node.getChildren(); + if (!(gutterNode instanceof TextRenderable) || !(bodyNode instanceof BoxRenderable)) return false; + const children = bodyNode.getChildren(); + if (children.length !== head.length + 1) return false; + const tableNode = children[head.length]; + if (!(tableNode instanceof TextTableRenderable)) return false; + for (const [i, line] of head.entries()) { + if (!retextBodyLine(children[i], line)) return false; + } + retextGutter(gutterNode, streamRowGutter(row, layout)); + // TextTableRenderable diffs cells in place on content assignment. + tableNode.content = viewToTableContent(view); + return true; +} diff --git a/src/tui/shell/transcript.ts b/src/tui/shell/transcript.ts new file mode 100644 index 000000000..76b0b4dfc --- /dev/null +++ b/src/tui/shell/transcript.ts @@ -0,0 +1,486 @@ +/** + * Transcript rows: append/replace/retext, windowed repaint, spacer, row renderable builders. + */ +import { + BoxRenderable, + MarkdownRenderable, + TextRenderable, + TextTableRenderable, + StyledText, + type BaseRenderable, + type CliRenderer, +} from "@opentui/core"; +import { stringWidth } from "../view/height.js"; +import { viewToTableContent, type McpStructuredView } from "../mcp-view.js"; +import { splitAtSettledHeading, withholdIncompleteHeading } from "../markdown-parser.js"; +import { diffLineChunks, retextStyledKindRow } from "./row-retext.js"; +import { + blockLabel, + EXPAND_KEY, + expandedRowLines, + splitTrailingArrow, + isMarkdownRow, + isSentenceRow, + MAIN_AGENT, + paintStreamRow, + rowGroupGap, + streamRowGutter, + toolRowLines, + toolSentenceLines, + transcriptSyntaxStyle, + type PaintedStreamLine, + type RowLayout, + type StreamRow, + type StyledBodyLine, +} from "../stream.js"; + +import { type AppShell } from "./internals.js"; + +/** + * Surface every row is laid out against: the transcript's own column budget + * (rows right-align and wrap themselves) and whether writers need naming. + * The scroll bars are hidden, so the transcript owns the whole content zone. + */ +export function transcriptRowLayout(shell: AppShell): RowLayout { + return { + width: Math.max(1, shell.layout.contentWidth), + multiAgent: shell.agentVoices.size > 1, + }; +} + +/** + * Record a row's writer. Returns true when the transcript has just gained a + * second voice — every earlier row now needs the label it was painted without. + */ +export function noteAgentVoice(shell: AppShell, row: StreamRow): boolean { + if (row.role === "user") return false; + const before = shell.agentVoices.size; + shell.agentVoices.add(row.agent ?? MAIN_AGENT); + return before === 1 && shell.agentVoices.size === 2; +} + +/** Row immediately before `index` in the log, or undefined at the start. */ +function rowBefore(shell: AppShell, index: number): StreamRow | undefined { + return index > 0 ? shell.streamLog[index - 1] : undefined; +} + +/** Blank rows the row at `index` claims above itself. */ +export function gapBefore(shell: AppShell, index: number): number { + const row = shell.streamLog[index]; + if (row === undefined) return 0; + return rowGroupGap(rowBefore(shell, index), row); +} + +/** + * Writer label the row at `index` carries above it, or null mid-block. + * A block is exactly a gap-free run from one writer, so this tracks + * `gapBefore` rather than keeping its own notion of block boundaries. + */ +export function labelBefore(shell: AppShell, index: number): string | null { + const row = shell.streamLog[index]; + if (row === undefined) return null; + return blockLabel(rowBefore(shell, index), row, transcriptRowLayout(shell)); +} + +/** Row count of the log `appendStreamRow` currently targets (parent or observe). */ +export function streamRowCount(shell: AppShell): number { + return shell.observe !== null && shell.parentStreamLog !== null + ? shell.parentStreamLog.length + : shell.streamLogBase + shell.streamLog.length; +} + +/** + * Row at absolute `index` on the log `appendStreamRow` currently targets. A + * tool result rewrites the call row it answers rather than appending its + * own, and needs to read that row back to fold into it. + * + * `index` is absolute (see `streamLogBase`); a row already evicted by the + * retention cap reads back as undefined, same as one past the end. + */ +export function streamRowAt(shell: AppShell, index: number): StreamRow | undefined { + if (shell.observe !== null && shell.parentStreamLog !== null) { + const local = index - (shell.parentStreamLogBase ?? 0); + return local >= 0 && local < shell.parentStreamLog.length + ? shell.parentStreamLog[local] + : undefined; + } + const local = index - shell.streamLogBase; + return local >= 0 && local < shell.streamLog.length ? shell.streamLog[local] : undefined; +} + +/** + * Identifies a transcript child as the eviction notice rather than a row. + * Identity, not position or state, is the source of truth: `streamLogBase` + * flips to nonzero the instant a trim happens, one step before the notice + * node itself exists in the paint tree, so deriving "is there a marker" + * from state would misalign row indices for exactly that transitional call. + */ +export const evictionMarkers = new WeakSet(); + +/** + * Row-index code paths (below, and the two windowed-rebuild callers) treat + * `getChildren()` as a 1:1 array with `streamLog`. The leading bottom-anchor + * spacer (see `transcriptSpacers`) and, once retention has evicted anything, + * the eviction notice above the oldest retained row both break that — every + * consumer that needs the row-only view goes through here rather than the + * raw call. + */ +export function transcriptRowChildren(shell: AppShell): readonly BaseRenderable[] { + const children = shell.transcript.getChildren().slice(1); + return children.length > 0 && evictionMarkers.has(children[0]!) ? children.slice(1) : children; +} + +/** The eviction-notice node, if the retention cap has dropped anything. */ +export function transcriptMarker(shell: AppShell): BaseRenderable | undefined { + const children = shell.transcript.getChildren().slice(1); + return children.length > 0 && evictionMarkers.has(children[0]!) ? children[0] : undefined; +} + +/** Raw child-list offset before the first row: the spacer, plus the notice if present. */ +export function transcriptRowOffset(shell: AppShell): number { + return transcriptMarker(shell) === undefined ? 1 : 2; +} + +/** + * Rewrite a row's body on its existing paint node. + * + * Every row kind retextes in place — streaming markdown keeps the parser's + * block state, and the styled kinds (diff, tool sentence, expansion, + * structured) rewrite their line and table content. Returns false when the + * node shape does not match the row (a label or arrow appearing, a line-count + * change) and the caller must rebuild it. + */ +export function retextStreamRow( + shell: AppShell, + node: BaseRenderable, + row: StreamRow, + label: string | null, +): boolean { + const layout = transcriptRowLayout(shell); + if (label !== null) { + if (!(node instanceof BoxRenderable)) return false; + const [headerNode, innerNode] = node.getChildren(); + if (!(headerNode instanceof TextRenderable) || innerNode === undefined) return false; + if (!retextStreamRowBody(innerNode, row, layout)) return false; + headerNode.content = label; + return true; + } + return retextStreamRowBody(node, row, layout); +} + +/** The shape-matching rewrite shared by labelled and unlabelled rows. */ +function retextStreamRowBody(node: BaseRenderable, row: StreamRow, layout: RowLayout): boolean { + if (retextStyledKindRow(node, row, layout)) return true; + if (row.diff !== undefined || row.structured !== undefined || isSentenceRow(row)) return false; + if (node instanceof TextRenderable) { + if (isMarkdownRow(row)) return false; + node.content = paintStreamRow(row, layout).content; + return true; + } + + if (!(node instanceof BoxRenderable) || !isMarkdownRow(row)) return false; + const [gutterNode, bodyNode] = node.getChildren(); + if (!(gutterNode instanceof TextRenderable)) return false; + const gutter = streamRowGutter(row, layout); + gutterNode.content = gutter.content; + gutterNode.width = stringWidth(gutter.content); + const width = markdownBodyColumns(gutter, layout); + const content = markdownContent(row); + const split = splitAtSettledHeading(content); + + // No settled heading behind the tail: a lone renderer, same as an unsplit + // body. A shape change (a heading just closed, or one just left the window + // a full rebuild trimmed) falls through to the caller's rebuild. + if (split === null) { + if (!(bodyNode instanceof MarkdownRenderable)) return false; + bodyNode.width = width; + bodyNode.content = content; + bodyNode.streaming = row.streaming === true; + return true; + } + + if (!(bodyNode instanceof BoxRenderable)) return false; + const [frozenNode, liveNode] = bodyNode.getChildren(); + if (!(frozenNode instanceof MarkdownRenderable) || !(liveNode instanceof MarkdownRenderable)) { + return false; + } + bodyNode.width = width; + frozenNode.width = width; + frozenNode.content = split.frozen; + liveNode.width = width; + liveNode.content = split.live; + liveNode.streaming = row.streaming === true; + liveNode.marginTop = split.gapRows; + return true; +} + +/** + * Prefix column beside a body the renderer owns. Width is pinned to the painted + * columns so an empty gutter — a lone agent's own prose — costs none, and the + * answer starts on the transcript's first column. + */ +function gutterNode(ctx: CliRenderer, gutter: PaintedStreamLine): TextRenderable { + return new TextRenderable(ctx, { + content: gutter.content, + fg: gutter.fg, + flexShrink: 0, + width: stringWidth(gutter.content), + }); +} + +/** + * Columns a markdown body may paint into: the transcript budget less the + * row's own prefix. Pinned rather than left to `flexGrow`, which reports the + * body's intrinsic width to yoga and lets a wide table paint past the edge. + */ +function markdownBodyColumns(gutter: PaintedStreamLine, layout: RowLayout): number { + return Math.max(1, layout.width - stringWidth(gutter.content)); +} + +/** + * Markdown tables shrink to the row's column budget rather than overflowing: + * columns are fitted proportionally and cells wrap on word boundaries. A table + * still too wide for its narrowest fit is clipped by the body's pinned width, + * which keeps it inside the transcript instead of painting over the chrome. + */ +const TRANSCRIPT_TABLE_OPTIONS = { + wrapMode: "word", + columnFitter: "proportional", +} as const; + +function markdownContent(row: StreamRow): string { + if (row.streaming !== true) return row.text; + return withholdIncompleteHeading(row.text); +} + +/** + * Build the row-shaped paint node: a MarkdownRenderable body next to a plain + * gutter for markdown-bearing rows (assistant replies), a TextTableRenderable + * for structured rows (MCP results), a coloured diff body for edit-tool rows, + * and literal text for everything else. + */ +export function buildRowNode( + ctx: CliRenderer, + row: StreamRow, + layout: RowLayout, + onToggle?: () => void, +): TextRenderable | BoxRenderable { + if (isSentenceRow(row)) { + // The sentence is one line: it is cut to the columns beside the marker + // rather than wrapped, so a long URL or query cannot double the row. + const columns = Math.max(1, layout.width - stringWidth(streamRowGutter(row, layout).content)); + if (row.structured !== undefined) { + // The table is what the sentence hides; collapsed, the sentence is the row. + return row.expanded === true + ? createStructuredRowRenderable( + ctx, + row, + layout, + row.structured, + toolSentenceLines(row, columns), + onToggle, + ) + : createStyledLinesRowRenderable( + ctx, + row, + layout, + toolSentenceLines(row, columns), + onToggle, + ); + } + return createStyledLinesRowRenderable(ctx, row, layout, toolRowLines(row, columns), onToggle); + } + + if (row.diff !== undefined) { + return createStyledLinesRowRenderable(ctx, row, layout, row.diff.lines); + } + + const expanded = expandedRowLines(row, layout); + if (expanded !== null) { + return createStyledLinesRowRenderable(ctx, row, layout, expanded); + } + + if (row.structured !== undefined) { + return createStructuredRowRenderable(ctx, row, layout, row.structured); + } + + if (!isMarkdownRow(row)) { + const painted = paintStreamRow(row, layout); + return new TextRenderable(ctx, { content: painted.content, fg: painted.fg }); + } + + const gutter = streamRowGutter(row, layout); + const wrapper = new BoxRenderable(ctx, { flexDirection: "row", width: "100%" }); + wrapper.add(gutterNode(ctx, gutter)); + wrapper.add(createMarkdownBody(ctx, row, gutter, layout)); + return wrapper; +} + +/** Shared construction options for a transcript markdown body's renderer. */ +function markdownBodyOptions(gutter: PaintedStreamLine, width: number) { + return { + syntaxStyle: transcriptSyntaxStyle(), + fg: gutter.fg, + width, + flexShrink: 0, + tableOptions: TRANSCRIPT_TABLE_OPTIONS, + } as const; +} + +/** + * A markdown row's body. Most rows have no settled heading yet (no heading at + * all, or the only one is still the open tail), and paint through a single + * renderer, same as before this fix existed. Once a heading closes, the body + * becomes a settled `frozen` renderer — everything through that heading, + * never streaming, never handed new content while the tail keeps growing, so + * it is never asked to re-highlight once written — stacked above the still + * `live` one, which carries the row's own streaming flag. Both halves use the + * library's default block mode, so paragraphs, lists and tables inside either + * one lay out exactly as a single unsplit body would. + */ +function createMarkdownBody( + ctx: CliRenderer, + row: StreamRow, + gutter: PaintedStreamLine, + layout: RowLayout, +): MarkdownRenderable | BoxRenderable { + const width = markdownBodyColumns(gutter, layout); + const content = markdownContent(row); + const split = splitAtSettledHeading(content); + if (split === null) { + return new MarkdownRenderable(ctx, { + ...markdownBodyOptions(gutter, width), + content, + // Native incremental block stability: only the trailing block is unstable. + streaming: row.streaming === true, + }); + } + const column = new BoxRenderable(ctx, { flexDirection: "column", width }); + column.add( + new MarkdownRenderable(ctx, { + ...markdownBodyOptions(gutter, width), + content: split.frozen, + streaming: false, + }), + ); + column.add( + new MarkdownRenderable(ctx, { + ...markdownBodyOptions(gutter, width), + content: split.live, + streaming: row.streaming === true, + marginTop: split.gapRows, + }), + ); + return column; +} + +/** + * Gutter + one text line per body row, for bodies that arrive already coloured + * and already laid out (a diff, an expanded tool call's structured arguments). + * Each line paints inside the body column, so a wrapped line lands under the + * body rather than in the shell's gutter. + */ +function createStyledLinesRowRenderable( + ctx: CliRenderer, + row: StreamRow, + layout: RowLayout, + lines: readonly StyledBodyLine[], + onToggle?: () => void, +): BoxRenderable { + const gutter = streamRowGutter(row, layout); + const wrapper = new BoxRenderable(ctx, { + flexDirection: "row", + width: "100%", + }); + wrapper.add(gutterNode(ctx, gutter)); + const body = new BoxRenderable(ctx, { + flexDirection: "column", + flexGrow: 1, + }); + for (const line of lines) { + body.add(bodyLineNode(ctx, line, onToggle)); + } + wrapper.add(body); + return wrapper; +} + +/** + * One painted body line. A line ending in an expand arrow is split so the + * arrow is its own renderable and can answer a click; every other line is a + * single text node, as before. + */ +function bodyLineNode( + ctx: CliRenderer, + line: StyledBodyLine, + onToggle?: () => void, +): TextRenderable | BoxRenderable { + const split = onToggle === undefined ? null : splitTrailingArrow(line); + if (split === null || onToggle === undefined) { + return new TextRenderable(ctx, { content: new StyledText(diffLineChunks(line)) }); + } + const wrapper = new BoxRenderable(ctx, { flexDirection: "row", flexGrow: 1 }); + wrapper.add( + new TextRenderable(ctx, { + content: new StyledText(diffLineChunks(split.body)), + flexShrink: 0, + }), + ); + wrapper.add( + new TextRenderable(ctx, { + content: new StyledText(diffLineChunks([split.arrow])), + flexShrink: 0, + width: stringWidth(split.arrow.text), + onMouseDown: (event) => { + // The transcript scroll box drags on the same press; a toggle is not a + // scroll gesture, so the arrow keeps the event. + event.stopPropagation(); + onToggle(); + }, + }), + ); + return wrapper; +} + +/** + * Gutter + native table body for a structured (MCP result) row, under the head + * lines the row collapses to. The head and the table share one body column so + * the table stays inside the shell's gutter. + */ +function createStructuredRowRenderable( + ctx: CliRenderer, + row: StreamRow, + layout: RowLayout, + view: McpStructuredView, + head: readonly StyledBodyLine[] = [], + onToggle?: () => void, +): BoxRenderable { + const gutter = streamRowGutter(row, layout); + const wrapper = new BoxRenderable(ctx, { + flexDirection: "row", + width: "100%", + }); + wrapper.add(gutterNode(ctx, gutter)); + const body = new BoxRenderable(ctx, { flexDirection: "column", flexGrow: 1 }); + for (const line of head) { + body.add(bodyLineNode(ctx, line, onToggle)); + } + body.add( + new TextTableRenderable(ctx, { + content: viewToTableContent(view), + columnWidthMode: "content", + columnGap: 2, + showBorders: false, + wrapMode: "none", + flexGrow: 1, + }), + ); + wrapper.add(body); + return wrapper; +} + +/** + * Bare key the modal overlay claims for its expand/collapse hook. Deliberately + * not in SHELL_SHORTCUTS: it is live only while an overlay that supplied + * `onToggleExpand` is open, so it never shadows a prompt binding. + */ +export const OVERLAY_EXPAND_KEY = EXPAND_KEY; diff --git a/src/tui/slash-popup-gate.test.ts b/src/tui/slash-popup-gate.test.ts index b2430a846..39d8502ad 100644 --- a/src/tui/slash-popup-gate.test.ts +++ b/src/tui/slash-popup-gate.test.ts @@ -16,22 +16,19 @@ import type { PaletteCommand } from "./command-catalog"; import { openCommandSurface, type CommandSurfaceDeps } from "./command-surfaces"; import { wireGates } from "./gate-wire"; import { openAddProviderOverlay, openPermissionsOverlay } from "./overlays"; +import { createAppShell } from "./shell/index"; +import { isSlashPopupOpen, type AppShell } from "./shell/internals"; import { acceptOverlaySelection, closeInsetOverlay, closeReplaceableOverlay, - createAppShell, - cycleOverlaySelection, isOverlayHostIdle, - isSlashPopupOpen, - moveOverlaySelection, onOverlayClosed, - openHelpOverlay, openListOverlay, - openPalette, reserveOverlayHost, - type AppShell, -} from "./shell"; +} from "./shell/overlay-host"; +import { cycleOverlaySelection, moveOverlaySelection } from "./shell/overlay-list"; +import { openHelpOverlay, openPalette } from "./shell/palette"; const CATALOG: readonly PaletteCommand[] = [ { diff --git a/src/tui/smoke.ts b/src/tui/smoke.ts index fc03347ae..c5e506211 100644 --- a/src/tui/smoke.ts +++ b/src/tui/smoke.ts @@ -3,6 +3,7 @@ * Run: bun ./src/tui/smoke.ts */ import "@opentui/core"; -import { PLATFORM_VERSION } from "./index"; + +const PLATFORM_VERSION = "0.5.10" as const; console.log(`opentui-ok platform=${PLATFORM_VERSION}`); diff --git a/src/tui/steer-worker-invariant.test.ts b/src/tui/steer-worker-invariant.test.ts index e1630f9b0..c744793c9 100644 --- a/src/tui/steer-worker-invariant.test.ts +++ b/src/tui/steer-worker-invariant.test.ts @@ -7,7 +7,7 @@ */ import { describe, expect, test } from "bun:test"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; -import { createAppShell } from "./shell"; +import { createAppShell } from "./shell/index"; import { withTestRenderer } from "./harness"; import { badgeCount } from "./session-queue"; diff --git a/src/tui/stream-event-map.test.ts b/src/tui/stream-event-map.test.ts index d82369424..9538cc41e 100644 --- a/src/tui/stream-event-map.test.ts +++ b/src/tui/stream-event-map.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { suppressProviderFailurePresentation } from "./provider-failure-attempt.js"; +import { suppressProviderFailurePresentation } from "./provider/failure-attempt.js"; import { createStreamMapContext, mapProductionEvent, diff --git a/src/tui/stream-event-map.ts b/src/tui/stream-event-map.ts index c1c87d246..965cdaae5 100644 --- a/src/tui/stream-event-map.ts +++ b/src/tui/stream-event-map.ts @@ -14,7 +14,7 @@ import { normalizeInferenceErrorForTerminal, type InferenceErrorLike, } from "../inference-gateway-error.js"; -import { isProviderFailurePresentationSuppressed } from "./provider-failure-attempt.js"; +import { isProviderFailurePresentationSuppressed } from "./provider/failure-attempt.js"; import type { RunState } from "./session-queue.js"; /** Canonical inbound events the bridge understands (fixtures + mapped reactor). */ diff --git a/src/tui/submit-handler.test.ts b/src/tui/submit-handler.test.ts index 8fcd937a7..79efb1060 100644 --- a/src/tui/submit-handler.test.ts +++ b/src/tui/submit-handler.test.ts @@ -4,9 +4,9 @@ import { createSubmitHandler, IMAGE_ONLY_PROMPT, routeSubmission, - telemetryStartupNotice, userInboundMessage, -} from "./runner.js"; +} from "./runner/submit.js"; +import { telemetryStartupNotice } from "./runner/settings.js"; import type { PendingImageAttachment } from "./image-attachments.js"; import { TELEMETRY_NOTICE } from "../telemetry/index.js"; import { diff --git a/src/tui/system-clipboard.test.ts b/src/tui/system-clipboard.test.ts index 6fba9be98..2c64c74a5 100644 --- a/src/tui/system-clipboard.test.ts +++ b/src/tui/system-clipboard.test.ts @@ -1,44 +1,90 @@ import { describe, expect, test } from "bun:test"; -import { clipboardCommands, createSystemClipboard, osc52 } from "./system-clipboard.js"; +import type { ClipboardService, ClipboardWriteResult } from "@opentui/core"; +import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js"; -describe("system clipboard", () => { - test("darwin writes through pbcopy", async () => { - expect(clipboardCommands("darwin")).toEqual([["pbcopy"]]); - }); +import { createSystemClipboard } from "./system-clipboard.js"; + +const boundary = { + capabilities: null, + copyToClipboardOSC52: () => true, + clearClipboardOSC52: () => true, +}; + +function serviceWithResult(result: ClipboardWriteResult): ClipboardService { + return { + read: () => Promise.resolve({ status: "unsupported" }), + writeText: async () => result, + clear: () => + Promise.resolve({ + host: { status: "cleared" }, + terminal: { status: "not-attempted", capability: "unsupported" }, + }), + dispose: () => Promise.resolve(), + }; +} - test("linux tries wayland then X helpers in order", () => { - expect(clipboardCommands("linux").map((c) => c[0])).toEqual(["wl-copy", "xclip", "xsel"]); +describe("system clipboard", () => { + test("wires the renderer through the OpenTUI clipboard factories", async () => { + const seen: string[] = []; + await withMockedModuleDuring( + import.meta.resolve("@opentui/core"), + (real: typeof import("@opentui/core")) => ({ + ...real, + createHostClipboard: () => { + seen.push("host"); + return {} as ReturnType; + }, + createRendererClipboardAdapter: (renderer: unknown) => { + seen.push(renderer === boundary ? "terminal" : "terminal-other"); + return { + remote: false, + writeText: () => ({ status: "attempted", capability: "supported" }), + clear: () => ({ status: "attempted", capability: "supported" }), + }; + }, + createClipboard: () => { + seen.push("service"); + return serviceWithResult({ + host: { status: "written" }, + terminal: { status: "not-attempted", capability: "unknown" }, + }); + }, + }), + async () => { + const { createSystemClipboard: fresh } = await import("./system-clipboard.js"); + await fresh(boundary).writeText("hi"); + }, + ); + expect(seen).toEqual(["host", "terminal", "service"]); }); - test("osc52 carries base64 payload between ESC ] and BEL", () => { - const seq = osc52("hi"); - expect(seq).toBe(`]52;c;${btoa("hi")}`); + test("resolves when a host helper writes", async () => { + const service = serviceWithResult({ + host: { status: "written" }, + terminal: { status: "not-attempted", capability: "unknown" }, + }); + await expect( + createSystemClipboard(boundary, service).writeText("payload"), + ).resolves.toBeUndefined(); }); - test("stops at the first helper that succeeds", async () => { - const tried: string[] = []; - const escapes: string[] = []; - const clipboard = createSystemClipboard({ - platform: "linux", - spawn: async (argv) => { - tried.push(argv[0] as string); - return argv[0] === "xclip"; - }, - writeEscape: (seq) => escapes.push(seq), + test("resolves when only the terminal OSC 52 leg attempted", async () => { + const service = serviceWithResult({ + host: { status: "unsupported" }, + terminal: { status: "attempted", capability: "supported" }, }); - await clipboard.writeText("payload"); - expect(tried).toEqual(["wl-copy", "xclip"]); - expect(escapes).toEqual([]); + await expect( + createSystemClipboard(boundary, service).writeText("payload"), + ).resolves.toBeUndefined(); }); - test("falls back to OSC 52 when no helper works", async () => { - const escapes: string[] = []; - const clipboard = createSystemClipboard({ - platform: "linux", - spawn: async () => false, - writeEscape: (seq) => escapes.push(seq), + test("rejects when both legs failed so callers flash failure", async () => { + const service = serviceWithResult({ + host: { status: "failed", error: new Error("no helper") }, + terminal: { status: "local-failure", capability: "supported" }, }); - await clipboard.writeText("payload"); - expect(escapes).toEqual([osc52("payload")]); + await expect(createSystemClipboard(boundary, service).writeText("payload")).rejects.toThrow( + /host: failed, terminal: local-failure/, + ); }); }); diff --git a/src/tui/system-clipboard.ts b/src/tui/system-clipboard.ts index 49af8bb68..10f77472f 100644 --- a/src/tui/system-clipboard.ts +++ b/src/tui/system-clipboard.ts @@ -1,70 +1,38 @@ /** - * System clipboard port for app-owned copy paths. + * Product clipboard port over the @opentui/core clipboard service. * - * Used by drag-select auto-copy (OpenTUI selection on mouse-up), Alt+C copy - * mode, and related keyboard paths. Native terminal drag-select is still - * unavailable while DEC mouse reporting is on — Alt+M hands the mouse back - * when that is wanted. Native helpers are preferred; OSC 52 is the fallback - * for remote sessions where no helper binary exists. + * The shell's copy surfaces speak the fire-and-forget ClipboardPort contract + * (void or rejected promise), while OpenTUI reports a structured host/terminal + * result pair. Helper binaries are the host leg; the renderer's OSC 52 path is + * the remote-session fallback. A write only fails when both legs failed, so + * `writeClipboard` never flashes success for text nobody took. */ -import type { ClipboardPort } from "./copy-path.js"; - -export type SpawnClipboard = (argv: readonly string[], text: string) => Promise; - -/** Candidate write commands, most specific platform first. */ -export function clipboardCommands(platform: NodeJS.Platform): readonly (readonly string[])[] { - if (platform === "darwin") return [["pbcopy"]]; - if (platform === "win32") return [["clip"]]; - return [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]]; -} +import { + createClipboard, + createHostClipboard, + createRendererClipboardAdapter, + type ClipboardService, + type RendererClipboardBoundary, +} from "@opentui/core"; -/** OSC 52 clipboard-set sequence for `text`. */ -export function osc52(text: string): string { - return `]52;c;${Buffer.from(text, "utf8").toString("base64")}`; -} - -async function spawnWrite(argv: readonly string[], text: string): Promise { - try { - const proc = Bun.spawn([...argv], { - stdin: new TextEncoder().encode(text), - stdout: "ignore", - stderr: "ignore", - }); - return (await proc.exited) === 0; - } catch { - return false; - } -} - -export interface SystemClipboardOptions { - readonly platform?: NodeJS.Platform; - readonly spawn?: SpawnClipboard; - /** Where OSC 52 is written when no helper binary works. */ - readonly writeEscape?: (seq: string) => void; -} - -/** - * Clipboard port that writes through a platform helper, falling back to OSC 52. - * Every attempt is guarded: a missing helper must degrade, never throw into the - * key handler. - */ -export function createSystemClipboard(options?: SystemClipboardOptions): ClipboardPort { - const platform = options?.platform ?? process.platform; - const spawn = options?.spawn ?? spawnWrite; - const writeEscape = - options?.writeEscape ?? - ((seq: string) => { - process.stdout.write(seq); - }); - const commands = clipboardCommands(platform); +import type { ClipboardPort } from "./copy-path.js"; +export function createSystemClipboard( + renderer: RendererClipboardBoundary, + service: ClipboardService = createClipboard({ + host: createHostClipboard(), + terminal: createRendererClipboardAdapter(renderer), + }), +): ClipboardPort { return { writeText: async (text: string) => { - for (const argv of commands) { - if (await spawn(argv, text)) return; + const result = await service.writeText(text, { destination: "best-available" }); + if (result.host.status !== "written" && result.terminal.status !== "attempted") { + throw new Error( + `clipboard write failed (host: ${result.host.status}, terminal: ${result.terminal.status})`, + ); } - writeEscape(osc52(text)); }, }; } diff --git a/src/tui/teardown.test.ts b/src/tui/teardown.test.ts index 6b7c781ab..9b6957587 100644 --- a/src/tui/teardown.test.ts +++ b/src/tui/teardown.test.ts @@ -7,7 +7,8 @@ import { describe, expect, test } from "bun:test"; import { BoxRenderable, TextRenderable, type CliRenderer } from "@opentui/core"; import { withTestRenderer } from "./harness"; -import { appendStreamRow, createAppShell } from "./shell"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; import { destroySubtree } from "./teardown"; function descendants(node: BoxRenderable): readonly TextRenderable[] { diff --git a/src/tui/thinking-reveal.test.ts b/src/tui/thinking-reveal.test.ts index 52649b4cf..103cd51b2 100644 --- a/src/tui/thinking-reveal.test.ts +++ b/src/tui/thinking-reveal.test.ts @@ -10,7 +10,7 @@ import { describe, expect, test } from "bun:test"; import { advanceRevealChars, LIVE_THINKING_MAX_LINES, thinkingLivePreviewLines } from "./thinking"; import { withTestRenderer } from "./harness"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; -import { createAppShell } from "./shell"; +import { createAppShell } from "./shell/index"; function fakeMonitor(): { readonly monitor: { diff --git a/src/tui/tool-rows.test.ts b/src/tui/tool-rows.test.ts index a5708a6cb..45bc14393 100644 --- a/src/tui/tool-rows.test.ts +++ b/src/tui/tool-rows.test.ts @@ -7,7 +7,7 @@ import { describe, expect, test } from "bun:test"; import { toolCallRow } from "./diff"; import { withTestRenderer } from "./harness"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; -import { createAppShell } from "./shell"; +import { createAppShell } from "./shell/index"; import { isCollapsibleRow, paintStreamRow, @@ -297,8 +297,7 @@ describe("a live turn", () => { const bridge = attachSessionBridge(shell, createRecordingPort()); try { const ids = ["c1", "c2", "c3", "c4"]; - // Every call is dispatched before any answer lands, which is what an - // ordinary parallel batch looks like on the wire. + // Every call is dispatched before any answer lands (a parallel batch). bridge.play( ids.map((callId) => ({ type: "inference.tool_call.end", @@ -309,6 +308,7 @@ describe("a live turn", () => { }, })), ); + await h.renderOnce(); expect(shell.streamLog.length).toBe(1); expect(shell.streamLog[0]?.coalesced).toBe(true); expect(shell.streamLog[0]?.pending).toBe(true); diff --git a/src/tui/transcript-anchor.test.ts b/src/tui/transcript-anchor.test.ts index 5cbb9f47d..c1ad9aa68 100644 --- a/src/tui/transcript-anchor.test.ts +++ b/src/tui/transcript-anchor.test.ts @@ -6,7 +6,9 @@ */ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness"; -import { appendStreamRow, createAppShell, type AppShell } from "./shell"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import type { AppShell } from "./shell/internals"; /** Index of the first line whose trimmed content starts with `needle`. */ function lineIndex(frame: string, needle: string): number { diff --git a/src/tui/transcript-layout.test.ts b/src/tui/transcript-layout.test.ts index d880810cb..44f61d24f 100644 --- a/src/tui/transcript-layout.test.ts +++ b/src/tui/transcript-layout.test.ts @@ -5,7 +5,9 @@ import { describe, expect, test } from "bun:test"; import { resolveSideMargin } from "./geometry/margins"; import { withTestRenderer } from "./harness"; -import { appendStreamRow, createAppShell, type AppShell } from "./shell"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import type { AppShell } from "./shell/internals"; import type { StreamRow } from "./stream"; import { toolCallRow } from "./diff"; import { toolResultRow } from "./mcp-view"; diff --git a/src/tui/transcript-long-log-scroll.test.ts b/src/tui/transcript-long-log-scroll.test.ts index ce5ed0101..8e07002c3 100644 --- a/src/tui/transcript-long-log-scroll.test.ts +++ b/src/tui/transcript-long-log-scroll.test.ts @@ -5,7 +5,9 @@ */ import { describe, expect, test } from "bun:test"; import { withTestRenderer } from "./harness"; -import { appendStreamRow, createAppShell, replaceStreamRowAt, streamRowCount } from "./shell"; +import { appendStreamRow, replaceStreamRowAt } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import { streamRowCount } from "./shell/transcript"; import { MAX_RETAINED_STREAM_ROWS } from "./long-log"; async function settle(h: { renderOnce: () => Promise }): Promise { diff --git a/src/tui/transcript-panels.test.ts b/src/tui/transcript-panels.test.ts index b4ec5a2de..a3035b604 100644 --- a/src/tui/transcript-panels.test.ts +++ b/src/tui/transcript-panels.test.ts @@ -7,7 +7,8 @@ import { describe, expect, test } from "bun:test"; import { resolveContentWidth, resolveSideMargin } from "./geometry/margins"; import { withTestRenderer, type Harness } from "./harness"; -import { appendStreamRow, createAppShell } from "./shell"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; import { rowGroupGap, type StreamRow } from "./stream"; /** Markdown blocks highlight asynchronously; settle before capturing a frame. */ diff --git a/src/tui/turn-monitor.test.ts b/src/tui/turn-monitor.test.ts index 56a1a8d29..899798c76 100644 --- a/src/tui/turn-monitor.test.ts +++ b/src/tui/turn-monitor.test.ts @@ -6,7 +6,8 @@ import { describe, expect, test } from "bun:test"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge.js"; -import { createAppShell, noticeText } from "./shell.js"; +import { noticeText } from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; import { withTestRenderer } from "./harness.js"; import { RUNTIME_FLASH_MS } from "./runtime-notices.js"; import { STALL_NOTICE_MESSAGE, STALL_RECOVERY_MESSAGE } from "./stall-watchdog.js"; diff --git a/src/tui/wave6.test.ts b/src/tui/wave6.test.ts index 4df664300..354b95731 100644 --- a/src/tui/wave6.test.ts +++ b/src/tui/wave6.test.ts @@ -8,25 +8,26 @@ import { withTestRenderer } from "./harness"; import { MAX_RETAINED_STREAM_ROWS } from "./long-log"; import { openPermissionsOverlay } from "./overlays"; import { - acceptOverlaySelection, appendStreamRow, - closeInsetOverlay, - confirmCopySelection, - createAppShell, - enterCopyMode, - enterSubagentObserve, - moveOverlaySelection, - openInsetOverlay, - openPalette, replaceStreamRowAt, setChromeZones, - setEffortCycleHandler, setStatusFlash, shellFocusPrompt, - streamRowAt, - streamRowCount, toggleTasksPanel, -} from "./shell"; +} from "./shell/chrome"; +import { enterCopyMode } from "./shell/copy"; +import { createAppShell } from "./shell/index"; +import { setEffortCycleHandler } from "./shell/internals"; +import { enterSubagentObserve } from "./shell/observe"; +import { + acceptOverlaySelection, + closeInsetOverlay, + confirmCopySelection, + openInsetOverlay, +} from "./shell/overlay-host"; +import { moveOverlaySelection } from "./shell/overlay-list"; +import { openPalette } from "./shell/palette"; +import { streamRowAt, streamRowCount } from "./shell/transcript"; import { createRecordingClipboard } from "./copy-path"; import { RUNTIME_FLASH_MS } from "./runtime-notices"; import { stringWidth } from "./view/height"; diff --git a/src/tui/wave7.test.ts b/src/tui/wave7.test.ts index db658c6e5..bf488ac9c 100644 --- a/src/tui/wave7.test.ts +++ b/src/tui/wave7.test.ts @@ -10,21 +10,17 @@ import { residualListFromCatalog, type ObserveSession, } from "./residuals.js"; +import { appendStreamRow } from "./shell/chrome.js"; +import { createAppShell } from "./shell/index.js"; import { - acceptOverlaySelection, - appendStreamRow, clearShellOverlayHooks, - closeInsetOverlay, - createAppShell, - enterSubagentObserve, - leaveSubagentObserve, - moveOverlaySelection, - openHelpOverlay, - openMentionsOverlay, - openSettingsOverlay, setShellOverlayHooks, type OverlaySelection, -} from "./shell.js"; +} from "./shell/internals.js"; +import { enterSubagentObserve, leaveSubagentObserve } from "./shell/observe.js"; +import { acceptOverlaySelection, closeInsetOverlay } from "./shell/overlay-host.js"; +import { moveOverlaySelection } from "./shell/overlay-list.js"; +import { openHelpOverlay, openMentionsOverlay, openSettingsOverlay } from "./shell/palette.js"; const SETTINGS_TEST_ITEMS = ["Permissions", "Telemetry", "Close"] as const; diff --git a/src/tui/width-columns.test.ts b/src/tui/width-columns.test.ts index 0904f7a0d..8d9024b3c 100644 --- a/src/tui/width-columns.test.ts +++ b/src/tui/width-columns.test.ts @@ -22,7 +22,7 @@ import { wrapLanding } from "./landing.js"; import { lockupWidth } from "./lockup.js"; import type { RampPhase } from "./ramp.js"; import { formatPaletteRows } from "./command-catalog.js"; -import { composeDecisionBody, decisionChoiceRows, wrapWords } from "./overlay-body.js"; +import { composeDecisionBody, wrapWords } from "./overlay-body.js"; import { thinkingLivePreviewLines, thinkingSettledLine } from "./thinking.js"; const CJK = "検索結果を確認する"; @@ -99,20 +99,6 @@ describe("the decision body", () => { const body = composeDecisionBody(`run_shell ${CJK}\ngrep — ${CJK} → ${CJK}\n… more`, 36, 8); for (const row of body) expect(stringWidth(row.text)).toBeLessThanOrEqual(36); }); - - test("a choice label wraps by columns, not code units", () => { - const rows = decisionChoiceRows(`Allow ${CJK} always`, true, 20); - for (const row of rows) expect(stringWidth(row.text)).toBeLessThanOrEqual(20); - const joined = rows.map((r) => r.text).join(""); - expect(joined).not.toContain("..."); - expect(joined).not.toContain("…"); - }); - - test("a label that fits in columns is not truncated", () => { - const label = `Accept — ${AMBIGUOUS}`; - const [row] = decisionChoiceRows(label, false, 40); - expect(row?.text).toBe(` ${label}`); - }); }); describe("middleEllipsis", () => { diff --git a/tests/fixtures/crash-run/simulate-crash.ts b/tests/fixtures/crash-run/simulate-crash.ts index 2888b17f5..ac77fb29c 100644 --- a/tests/fixtures/crash-run/simulate-crash.ts +++ b/tests/fixtures/crash-run/simulate-crash.ts @@ -7,7 +7,7 @@ import { installCrashHandlers } from "../../../src/index.js"; import { setActiveRun, setTestWriteGate } from "../../../src/session/active-run.js"; import { sessionDir } from "../../../src/session/index.js"; import { finalizeRunState, saveState } from "../../../src/session/state.js"; -import { clearsActiveRun } from "../../../src/tui/runner.js"; +import { clearsActiveRun } from "../../../src/tui/runner/exit.js"; const cwd = process.cwd(); const sessionId = process.env["CRASH_TEST_SESSION_ID"]; diff --git a/tests/unit/tui/runner.test.ts b/tests/unit/tui/runner.test.ts index 31d9fe790..b440dd48f 100644 --- a/tests/unit/tui/runner.test.ts +++ b/tests/unit/tui/runner.test.ts @@ -4,12 +4,11 @@ import { AgentContextLockError, type Agent } from "@intx/agent"; import { agentRebuildFailure, closeAgentForRebuild, - createTUIEventEmitter, - getTUIRunSummaryStatus, - loadLocalSettingsWriteBase, resumeTranscriptLoadErrorBlock, - tuiSendFailureMessage, -} from "../../../src/tui/runner.js"; +} from "../../../src/tui/runner/exit.js"; +import { createTUIEventEmitter, getTUIRunSummaryStatus } from "../../../src/tui/runner/index.js"; +import { loadLocalSettingsWriteBase } from "../../../src/tui/runner/settings.js"; +import { tuiSendFailureMessage } from "../../../src/tui/runner/send-failure-message.js"; import { createSessionOperationQueue } from "../../../src/tui/session-operation-queue.js"; import { createRunSink } from "../../../src/session/run-sink.js";