diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 89f1e70d2..0198b3836 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -210,7 +210,7 @@ Profiles with `orchestrator: true` may themselves call `task` (one hop only): ne **Session records** (`src/subagent/session-store.ts`): each spawn is retained as an inspectable child session (id, profile, description, brief, status, tool activity, transcript entries). Child events land only in this store — not in the parent chat transcript. Live progress still uses the light `onProgress` channel for the status bar. Completed sessions are capped (`maxCompleted`) so a long chat does not grow without bound. -**Observe (OpenTUI)**: the command palette's **observe** action (`src/tui-opentui/palette.ts`) asks the host for a live session (`onObserveRequest` → `observeSessionFromSubAgents`, `src/tui-opentui/runner-host.ts`), which picks the newest running child, else the most recent session of any status. Entering observe swaps the transcript for that child's stream (live while running, historical when done) without stealing the parent reactor; child events are mapped to stream rows by `src/tui-opentui/observe-map.ts`. Esc leaves observe and restores the parent transcript. Parent Esc/stop and `/clear` still call `cancelAll` so live children close (`agent.close`) instead of continuing after the parent stops. +**Observe (OpenTUI)**: `shell.ts:enterSubagentObserve` swaps the transcript for a child's stream (live while running, historical when done) without stealing the parent reactor; child events are mapped to stream rows by `src/tui-opentui/observe-map.ts`. Esc leaves observe and restores the parent transcript. Parent Esc/stop and `/clear` still call `cancelAll` so live children close (`agent.close`) instead of continuing after the parent stops. The host-injection point that resolves a live session (`onObserveRequest` → `observeSessionFromSubAgents`, `src/tui-opentui/runner-host.ts`, picking the newest running child else the most recent session of any status) is triggered by Alt+O (`shell.ts:observeActiveSubagent`) — the command palette action that used to call it is gone along with `src/tui-opentui/palette.ts` itself, but the chord replaces it rather than dropping the feature. Data-only agent plugins (`src/plugins/data-only-agent.ts`) synthesize `agentPlugin.agents[]` from `agents/*.md` or flat `*.md` in the plugin directory, with optional co-located `skills/`. `loadPluginEntry` tries JS entrypoints first, then falls back to this layout (`/plugins` add-by-path supports filesystem completion via `listPathSuggestions`). diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 4bde87330..28252eb04 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -134,7 +134,7 @@ src/ runner-host.ts Runner-facing mount: catalogs, chrome, quit key list-modal.ts Shared list-picker overlay (runListModal) command-surfaces.ts Slash-command surface routing (openCommandSurface) - command-catalog.ts, model-catalog.ts, chrome-state.ts, palette.ts, + command-catalog.ts, model-catalog.ts, chrome-state.ts, provider-setup.ts Onboarding provider setup flow docs/ PRODUCT.md, ARCHITECTURE.md, IMPLEMENTATION.md, TUI.md, HOOKS.md, MCP.md, diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index c2f52ee3d..0f627085b 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -37,8 +37,8 @@ $ corbits "Add JWT auth to the API" A full-screen terminal interface: a pinned header (session title and workflow progress), a scrollable event log, modals for permission prompts and operator questions, and a chat input for follow-up turns. **Behavior spec** (OpenTUI is the shipping shell): `docs/TUI.md` — layout, -chrome budget, overlays, selectors, palette, prompt box, and scroll/mouse -behavior. +chrome budget, overlays, selectors, the `/` command list, prompt box, and +scroll/mouse behavior. ### Exec mode (non-TUI product path) diff --git a/docs/TUI.md b/docs/TUI.md index a86589a15..dd96f02d8 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -136,8 +136,8 @@ on any terminal, tall or short; the collapse order bounds what other zones are allowed to take from it once the transcript floor is at risk. The panel is toggleable independent of its live data: `toggleTasksPanel` -(bound to the `toggle_task` palette action) flips a hidden flag held on the -shell for its lifetime — in memory only, nothing written to storage — while +(bound to Alt+T) flips a hidden flag held on the shell for its lifetime — in +memory only, nothing written to storage — while the live task list keeps updating underneath it. Un-hiding shows the current list, not a stale snapshot from before the hide. Hidden or empty, the zone costs zero rows. @@ -200,7 +200,7 @@ same last-resort floor every other optional zone shares. ## How pop-ups should feel A blocking surface (permissions, an operator question, the model/provider -picker, settings, help, the command palette, …) shares one overlay host and +picker, settings, help, the `/` command list, …) shares one overlay host and one height path — there is no second modal stack with independent row accounting (`src/tui-opentui/geometry/resolve.ts`, `src/tui-opentui/shell.ts:openListOverlay`). Opening a second surface either @@ -238,7 +238,7 @@ rather than a background fill. ## How selectors should work Every list surface — permissions, the operator question, the model picker, -the command palette, resume/session-mode pickers, settings — shares one list +the `/` command list, resume/session-mode pickers, settings — shares one list viewport kit: shared windowing, keep-active-visible, and page/jump behavior. There is exactly one scroll lease at a time; keyboard paging and the mouse wheel both follow whichever surface currently holds it, so a modal open on @@ -250,28 +250,57 @@ every picker open — independent of the recents list, which only moves on an explicit pick and can go stale (`ProductHostConfig.activeModelId`'s doc comment and `annotateCurrent` in `src/tui-opentui/product-host.ts`). -The command palette specifically (`src/tui-opentui/palette.ts`, +The `/` command list specifically (`src/tui-opentui/command-catalog.ts`, `shell.ts:openPalette`/`repaintPalette`): width matches the prompt box — both are painted at the geometry resolver's shared `contentWidth` (`geometry/resolve.ts:assignRects`, `shell.ts:overlayRowWidth`). There is no leading marker column and no per-row kind column; the selected row is marked by text color only (`paintPaletteList` in `shell.ts`: "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"). The palette also paints -with no title rule — the filter row (`> query`) directly under the box -already shows what was typed, so a second header line would say nothing new +block would both be saying the same thing twice"). The list also paints with +no title rule — the filter row (`> query`) directly under the box already +shows what was typed, so a second header line would say nothing new (`repaintPalette`). ## Slash commands and pickers -`Ctrl+O` opens the command palette from anywhere in the shell (reclaimed from -the Ink-era tool-expand chord); `/` at an empty prompt opens the same -palette narrowed to registry slash commands. Every user-facing slash command -has a palette twin. Palette entries are either "residual" product actions -owned by the shell (open permissions, switch model, toggle a chrome zone, -copy, toggle mouse capture, help, insert a mention, observe a subagent) or -"command" entries backed by the live command registry -(`src/tui-opentui/palette.ts`). +`/` at an empty prompt opens the command list, narrowed by name prefix as +more is typed; Tab completes the name so arguments can be typed, Enter runs +it. Every entry is backed by the live command registry +(`src/tui-opentui/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 +`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` +via `openCommandSurface`'s `"help"` case, `command-surfaces.ts`); the `/` row +in `SHELL_SHORTCUTS` documents that in place of a dedicated `?` row. + +The running build version is chrome, not part of the landing composition: +`shell.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 +is real transcript content the row goes back to whatever needed it, and the +badge stops rendering. On a narrow or short terminal it hides +(`versionBadgeVisible`, thresholds `VERSION_BADGE_MIN_COLUMNS`/ +`VERSION_BADGE_MIN_ROWS` in `landing.ts`) before the prompt box or any other +actionable chrome would degrade for width/height reasons. + +This is not a free row, though, while it is showing: `terminalForGeometry` +subtracts it from the terminal size handed to the geometry resolver before +the resolver runs, so every height the resolver derives — including +`PROMPT_CAP_FRACTION * terminal.rows`, computed before `COLLAPSE_ORDER` ever +runs — sees one row fewer than the real terminal. The badge does not sit in +`COLLAPSE_ORDER` and is never given back under prompt-growth pressure the +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. The model/provider picker is provider-first (`src/tui-opentui/product-host.ts:groupModelsForPicker`/`openLevel`): recent diff --git a/src/tui-opentui/command-catalog.test.ts b/src/tui-opentui/command-catalog.test.ts index 31b2596fc..9a2a0d869 100644 --- a/src/tui-opentui/command-catalog.test.ts +++ b/src/tui-opentui/command-catalog.test.ts @@ -1,55 +1,12 @@ import { describe, expect, test } from "bun:test" import { - buildCommandCatalog, commandItemsFromRegistry, + filterPaletteCommands, + paletteLabels, } from "./command-catalog" -import { DEFAULT_PALETTE_COMMANDS, paletteDispatchOf } from "./palette" - -describe("buildCommandCatalog", () => { - test("maps listCommands-shaped entries to dispatch command names", () => { - const catalog = buildCommandCatalog([ - { name: "compact", description: "Compact history" }, - { name: "model", description: "Open model picker" }, - ]) - - const compact = catalog.find((c) => c.id === "compact") - expect(compact).toBeDefined() - expect(compact!.dispatch).toBe("command") - expect(compact!.label).toContain("/compact") - expect(compact!.label).toContain("Compact history") - expect(paletteDispatchOf(compact!)).toBe("command") - - const model = catalog.find((c) => c.id === "model") - expect(model?.dispatch).toBe("command") - }) - - test("includes residual openers alongside registry commands", () => { - const catalog = buildCommandCatalog([ - { name: "compact", description: "Compact history" }, - ]) - expect(catalog.some((c) => c.id === "permissions")).toBe(true) - expect(catalog.some((c) => c.id === "compact")).toBe(true) - expect(catalog.length).toBeGreaterThan(DEFAULT_PALETTE_COMMANDS.length) - }) - - test("preferRegistry drops residual when registry reuses id", () => { - const catalog = buildCommandCatalog([ - { name: "help", description: "Slash help" }, - ]) - const helps = catalog.filter((c) => c.id === "help") - expect(helps.length).toBe(1) - expect(helps[0]!.dispatch).toBe("command") - }) - - test("empty registry still yields residual catalog", () => { - const catalog = buildCommandCatalog([]) - expect(catalog.length).toBe(DEFAULT_PALETTE_COMMANDS.length) - expect(catalog.every((c) => c.dispatch === "residual")).toBe(true) - }) -}) describe("commandItemsFromRegistry", () => { - test("registry-only items all dispatch as command", () => { + test("maps listCommands-shaped entries to `/`-prefixed labels", () => { const items = commandItemsFromRegistry([ { name: "tasks", description: "Show work list" }, { name: "clear", description: "Clear screen" }, @@ -59,14 +16,45 @@ describe("commandItemsFromRegistry", () => { id: "tasks", label: "/tasks — Show work list", keywords: ["tasks", "slash", "command"], - dispatch: "command", }, { id: "clear", label: "/clear — Clear screen", keywords: ["clear", "slash", "command"], - dispatch: "command", }, ]) }) }) + +describe("filterPaletteCommands", () => { + const catalog = commandItemsFromRegistry([ + { name: "compact", description: "Compact history" }, + { name: "model", description: "Open model picker" }, + ]) + + test("empty query returns the full catalog", () => { + expect(filterPaletteCommands("", catalog)).toEqual(catalog) + }) + + test("matches by id, label, or keyword substring", () => { + expect(filterPaletteCommands("compact", catalog).map((c) => c.id)).toEqual([ + "compact", + ]) + expect(filterPaletteCommands("picker", catalog).map((c) => c.id)).toEqual([ + "model", + ]) + }) + + test("no match returns an empty list", () => { + expect(filterPaletteCommands("zzzz", catalog)).toEqual([]) + }) +}) + +describe("paletteLabels", () => { + test("returns just the display labels", () => { + const catalog = commandItemsFromRegistry([ + { name: "tasks", description: "Show work list" }, + ]) + expect(paletteLabels(catalog)).toEqual(["/tasks — Show work list"]) + }) +}) diff --git a/src/tui-opentui/command-catalog.ts b/src/tui-opentui/command-catalog.ts index d16000a67..484abc761 100644 --- a/src/tui-opentui/command-catalog.ts +++ b/src/tui-opentui/command-catalog.ts @@ -1,47 +1,78 @@ /** - * Registry → palette catalog bridge for OpenTUI production host. + * Registry → `/` command list catalog (pure). * * Pure: host injects `listCommands()` results (or fixtures). No registry import * here — avoids circular / heavy deps from `src/tui/commands`. * - * setPaletteCatalog(shell, buildCommandCatalog(listCommands())) + * setPaletteCatalog(shell, commandItemsFromRegistry(listCommands())) */ -import { - buildPaletteCatalog, - commandsToPaletteItems, - type BuildPaletteCatalogOpts, - type PaletteCommand, - type RegistryCommandSource, -} from "./palette.js" +import { sliceToWidth, stringWidth } from "../tui/view/height.js" -export type { PaletteCommand, RegistryCommandSource } +/** Minimal registry shape — matches `listCommands()` entries without importing them. */ +export type RegistryCommandSource = { + readonly name: string + readonly description: string +} -export type BuildCommandCatalogOpts = Omit +/** One entry in the `/` command list: registry command name + display label. */ +export type PaletteCommand = { + readonly id: string + readonly label: string + /** Optional keywords for name-prefix / substring filter. */ + readonly keywords?: readonly string[] +} + +/** Map registry command definitions to `/` list items. */ +export function commandItemsFromRegistry( + commands: readonly RegistryCommandSource[], +): PaletteCommand[] { + return commands.map((c) => ({ + id: c.name, + label: `/${c.name} — ${c.description}`, + keywords: [c.name, "slash", "command"], + })) +} /** - * Map `listCommands()`-shaped entries into a palette catalog for setPaletteCatalog. - * - * Includes residual product openers (permissions, model picker, …) plus registry - * slash commands with `dispatch: "command"` and `id` = command name. Registry - * names win over residual openers with the same id (preferRegistry default). + * Case-insensitive substring filter over label + keywords. + * Empty query returns the full catalog (stable order). */ -export function buildCommandCatalog( - commands: readonly RegistryCommandSource[], - opts?: BuildCommandCatalogOpts, +export function filterPaletteCommands( + query: string, + catalog: readonly PaletteCommand[], ): readonly PaletteCommand[] { - return buildPaletteCatalog({ - ...opts, - commands, + const q = query.trim().toLowerCase() + if (q.length === 0) return catalog + return catalog.filter((cmd) => { + if (cmd.label.toLowerCase().includes(q)) return true + if (cmd.id.toLowerCase().includes(q)) return true + return (cmd.keywords ?? []).some((k) => k.toLowerCase().includes(q)) }) } -/** - * Registry slash entries only (no residual openers). Each item has - * `dispatch: "command"` and `id` equal to the command name. - */ -export function commandItemsFromRegistry( - commands: readonly RegistryCommandSource[], -): PaletteCommand[] { - return commandsToPaletteItems(commands) +/** Labels for the shared list viewport. */ +export function paletteLabels( + commands: readonly PaletteCommand[], +): readonly string[] { + return commands.map((c) => c.label) +} + +function fitLabel(label: string, width: number): string { + if (width <= 0) return "" + const columns = stringWidth(label) + // padEnd counts code units, so a label carrying a wide glyph has to be padded + // by the column shortfall rather than to a code-unit length. + if (columns <= width) return label + " ".repeat(width - columns) + if (width === 1) return "…" + const cut = `${sliceToWidth(label, width - 1)}…` + return cut + " ".repeat(Math.max(0, width - stringWidth(cut))) +} + +/** Render labels to exactly `width` columns each, ellipsizing long ones. */ +export function formatPaletteRows( + labels: readonly string[], + width: number, +): readonly string[] { + return labels.map((label) => fitLabel(label, width)) } diff --git a/src/tui-opentui/command-display.ts b/src/tui-opentui/command-display.ts index 319ee1e12..4eef78c93 100644 --- a/src/tui-opentui/command-display.ts +++ b/src/tui-opentui/command-display.ts @@ -244,7 +244,7 @@ export type CollapsedSegment = { // collapsed segment always render as a single line. display: string; // The full text of each collapsed payload, in placeholder order, shown when - // the operator expands via Ctrl+O. + // the operator expands via Alt+E. payloads: CollapsedPayload[]; }; diff --git a/src/tui-opentui/demo.ts b/src/tui-opentui/demo.ts index 556cac591..96ca9d38a 100644 --- a/src/tui-opentui/demo.ts +++ b/src/tui-opentui/demo.ts @@ -7,7 +7,7 @@ * * Keys: * Enter=queue · Alt+Enter=steer · Ctrl+C=stop - * Ctrl+O=palette · Alt+C=copy + * Alt+C=copy * p=permissions · o=operator · m=model * s=settings · h=help · l=plugins · e=resume · n=mentions · v=observe * t/a=toggle task/agents chrome @@ -101,7 +101,7 @@ const bridge = attachSessionBridge(shell, port) appendStreamRow(shell, { role: "system", - text: "Wave 7 — residuals + observe (s/h/l/e/n/v · Ctrl+O palette)", + text: "Wave 7 — residuals + observe (s/h/l/e/n/v)", }) appendStreamRow(shell, { role: "system", @@ -331,5 +331,5 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => { }) console.log( - "OpenTUI Wave 7 demo — residuals s/h/l/e/n · observe v · Ctrl+O palette · q quit", + "OpenTUI Wave 7 demo — residuals s/h/l/e/n · observe v · q quit", ) diff --git a/src/tui-opentui/gate-wire.ts b/src/tui-opentui/gate-wire.ts index ee409cdfd..e6ce86bb9 100644 --- a/src/tui-opentui/gate-wire.ts +++ b/src/tui-opentui/gate-wire.ts @@ -37,8 +37,8 @@ export const PERMISSION_ONCE_ID = "__once__" as const /** * Expand/collapse chord for collapsed payloads. Scoped to the open permission * overlay rather than registered in SHELL_SHORTCUTS: the overlay is modal, so - * a bare letter is free there, and Ctrl+O (the Ink-era chord) is the command - * palette in this shell. Shared with the transcript's collapsed rows so the + * a bare letter is free there — nothing else in the shell claims it while + * this overlay is open. Shared with the transcript's collapsed rows so the * product has one expand idiom. */ export const PERMISSION_EXPAND_KEY = EXPAND_KEY diff --git a/src/tui-opentui/index.ts b/src/tui-opentui/index.ts index 3152f52b7..0dff4842d 100644 --- a/src/tui-opentui/index.ts +++ b/src/tui-opentui/index.ts @@ -20,7 +20,6 @@ export { export * from "./live-session-port" export * from "./overlays" export * from "./long-log" -export * from "./palette" export * from "./command-catalog" export * from "./model-catalog" export * from "./copy-path" diff --git a/src/tui-opentui/keybindings.test.ts b/src/tui-opentui/keybindings.test.ts index a1d70bb2e..90032c818 100644 --- a/src/tui-opentui/keybindings.test.ts +++ b/src/tui-opentui/keybindings.test.ts @@ -17,19 +17,21 @@ import { EventEmitter } from "node:events" import { describe, expect, test } from "bun:test" import { PROMPT_KEY_BINDINGS } from "./prompt-input.js" -import { SHELL_SHORTCUTS, shortcutForPaletteId } from "./keybindings.js" -import { isResidualActionId } from "./palette.js" -import { listCommands } from "../tui/commands/registry.js" -import { registerBuiltInCommands } from "../tui/commands/built-in.js" +import { SHELL_SHORTCUTS } from "./keybindings.js" import { createHarness, withTestRenderer, type Harness } from "./harness.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 { appendStreamRow, createAppShell, isSlashPopupOpen, + leaveSubagentObserve, + openHelpOverlay, setMentionSuggestionSource, setPaletteCatalog, + setPaletteOnObserveRequest, setPromptImageSource, setSentMessageHistory, setShellBridgeHooks, @@ -39,6 +41,7 @@ import { shellFocusPrompt, shellFocusTranscript, streamRowAt, + streamRowCount, submitPrompt, type AppShell, } from "./shell.js" @@ -271,15 +274,6 @@ const PROBES: Readonly { - press(h, chords[0]) - expect(shell.overlayKind).toBe("palette") - press(h, chords[0]) - expect(shell.overlayKind).toBeNull() - }, - }, "Alt+C": { group: "surfaces", probe: ({ h, shell, chords }) => { @@ -307,6 +301,44 @@ const PROBES: Readonly { + setChromeZones(shell, { task: [{ label: "a", status: "todo" }] }) + expect(shell.taskBox.visible).toBe(true) + press(h, chords[0]) + expect(shell.taskBox.visible).toBe(false) + press(h, chords[0]) + expect(shell.taskBox.visible).toBe(true) + }, + }, + "Alt+O": { + group: "surfaces", + probe: ({ h, shell, chords }) => { + // No session wired: an honest system row, not silence. + const before = streamRowCount(shell) + press(h, chords[0]) + expect(streamRowCount(shell)).toBe(before + 1) + expect(streamRowAt(shell, before)?.text).toBe( + "no subagent session to observe", + ) + expect(shell.observe).toBeNull() + + // Host wires a live session: the same chord enters it for real. + setPaletteOnObserveRequest(shell, () => ({ + sessionId: "live-1", + agentId: "explore", + description: "map callers", + lines: [{ role: "assistant", text: "child line" }], + })) + press(h, chords[0]) + expect(shell.observe?.sessionId).toBe("live-1") + // Leave the way Esc would, so later probes in this shared-shell + // sequence see the same prompt-focused state they'd get otherwise. + leaveSubagentObserve(shell) + setPaletteOnObserveRequest(shell, undefined) + }, + }, "Alt+E": { group: "surfaces", probe: ({ h, shell, chords }) => { @@ -336,28 +368,15 @@ const PROBES: Readonly { - press(h, "\x0f") // Ctrl+O opens something to close - expect(shell.overlayKind).toBe("palette") - press(h, chords[0]) - await escapeSettles() - expect(shell.overlayKind).toBeNull() - }, - }, - "?": { - group: "surfaces", - probe: ({ h, shell, chords }) => { + setPaletteCatalog(shell, [{ id: "cost", label: "cost" }]) shellFocusPrompt(shell) shell.prompt.value = "" + shell.prompt.cursorOffset = 0 + press(h, "/") // opens the / command popup, something Esc must close + expect(shell.overlayKind).toBe("palette") press(h, chords[0]) - // The condition, not just the chord: at the prompt it is a character. - expect(shell.overlayKind).toBeNull() - - shellFocusTranscript(shell) - press(h, chords[0]) - expect(shell.overlayKind).toBe("help") - press(h, chords[0]) + await escapeSettles() expect(shell.overlayKind).toBeNull() - shellFocusPrompt(shell) shell.prompt.value = "" }, }, @@ -386,7 +405,7 @@ const PROBES: Readonly { - setPaletteCatalog(shell, [{ id: "cost", label: "cost", dispatch: "command" }]) + setPaletteCatalog(shell, [{ id: "cost", label: "cost" }]) shellFocusPrompt(shell) shell.prompt.value = "note" @@ -687,25 +706,73 @@ describe("the runner host does not shadow the prompt bindings the catalog claims }) }) -describe("palette chords", () => { - test("every mapped entry id is a real palette action or registered command", () => { - registerBuiltInCommands() - const commands = new Set(listCommands().map((c) => c.name)) - for (const id of PALETTE_IDS) { - expect(isResidualActionId(id) || commands.has(id)).toBe(true) +describe("? no longer opens help", () => { + test("bare ? types a literal character instead of opening the shortcut list", async () => { + const harness = await createHarness({ width: 80, height: 24 }) + try { + const shell = createAppShell(harness.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + run: "idle", + }) + try { + shellFocusPrompt(shell) + shell.prompt.value = "" + harness.pressKey("?") + expect(shell.overlayKind).toBeNull() + expect(shell.prompt.value).toBe("?") + + shellFocusTranscript(shell) + harness.pressKey("?") + // No binding claims it with the transcript focused either — help has + // no chord left at all, only the /help command. + expect(shell.overlayKind).toBeNull() + } finally { + shell.dispose() + } + } finally { + harness.destroy() } }) +}) - test("every mapped chord is a chord the catalog proved", () => { - for (const id of PALETTE_IDS) { - expect(shortcutForPaletteId(id)).toBeString() +describe("help stays reachable as a command", () => { + test("/help still opens the shortcut list", async () => { + const notifications: string[] = [] + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + try { + expect(shell.overlayKind).toBeNull() + const opened = openCommandSurface(shell, "help", { + notify: (text) => notifications.push(text), + }) + expect(opened).toBe(true) + expect(shell.overlayKind).toBe("help") + } finally { + shell.dispose() + } + }) + }) + + test("openHelpOverlay (the /help handler) opens the same overlay the removed ? chord used to", async () => { + const harness = await createHarness({ width: 80, height: 24 }) + try { + const shell = createAppShell(harness.renderer, { + terminal: { columns: 80, rows: 24 }, + run: "idle", + }) + try { + openHelpOverlay(shell) + expect(shell.overlayKind).toBe("help") + } finally { + shell.dispose() + } + } finally { + harness.destroy() } }) }) -/** - * PALETTE_CHORDS is private, so the ids it maps are listed here. A new mapping - * without an entry here is caught by the palette test in wave7, which walks the - * painted rows. - */ -const PALETTE_IDS = ["help", "mentions", "copy_active", "toggle_mouse", "paste-image"] as const diff --git a/src/tui-opentui/keybindings.ts b/src/tui-opentui/keybindings.ts index a99e0c0c8..5082ef483 100644 --- a/src/tui-opentui/keybindings.ts +++ b/src/tui-opentui/keybindings.ts @@ -24,10 +24,11 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ { keys: "Alt+Enter", description: "steer at the next tool boundary; does nothing unless a run is busy" }, { keys: "Ctrl+C", description: "interrupt the run, or clear the prompt when idle; press twice to exit" }, { keys: "Ctrl+G", description: "cancel the most recently queued or steered message before it dispatches" }, - { keys: "Ctrl+O", description: "open the command palette; press again to close it" }, { keys: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it" }, { keys: "Alt+M", description: "release the mouse to the terminal for native drag-select and copy; on by default for wheel scroll and click-to-expand" }, { keys: "Alt+E", description: "expand or collapse every collapsible row (tool call, diff, skill, reasoning)" }, + { keys: "Alt+T", description: "show or hide the task list above the prompt" }, + { keys: "Alt+O", description: "observe a live subagent session; a system row says so when there is none" }, { keys: "Tab", description: "move focus between the prompt and the transcript" }, { keys: "Esc", description: "close the open overlay, or leave subagent observe" }, { keys: "Ctrl+B / Ctrl+F", description: "move the cursor back / forward one character" }, @@ -40,9 +41,8 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ { keys: "Ctrl+Y", description: "yank the last kill at the cursor" }, { keys: "Alt+Y", description: "replace the text just yanked with the next-older kill" }, { keys: "Ctrl+V / Ctrl+P", description: "attach an image from the clipboard to the next message" }, - { keys: "?", description: "with the transcript focused, open this shortcut list; press again to close it" }, { keys: "@", description: "at the start of a word, open file suggestions for the @mention being typed" }, - { keys: "/", description: "at an empty prompt, open the command list (Tab completes, Enter runs)" }, + { keys: "/", description: "at an empty prompt, open the command list (Tab completes, Enter runs); also lists /help" }, { keys: "Up / Down", description: "recall previously sent messages, from the prompt's first / last row" }, { keys: "Arrow keys", description: "move the cursor left / right / up / down in the prompt" }, { @@ -51,26 +51,3 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ "insert a newline instead of sending (Shift+Enter also works on terminals that report the modifier)", }, ] as const - -/** - * Palette entry id (residual action id or registry command name) → the chord - * that reaches the same surface without the palette. - */ -const PALETTE_CHORDS: Readonly> = { - help: "?", - mentions: "@", - copy_active: "Alt+C", - toggle_mouse: "Alt+M", - "paste-image": "Ctrl+V / Ctrl+P", -} - -/** - * Chord to advertise for a palette row, or undefined when the entry has none. - * Resolved against SHELL_SHORTCUTS so the palette can never print a binding the - * shell does not actually implement. - */ -export function shortcutForPaletteId(id: string): string | undefined { - const keys = PALETTE_CHORDS[id] - if (keys === undefined) return undefined - return SHELL_SHORTCUTS.some((s) => s.keys === keys) ? keys : undefined -} diff --git a/src/tui-opentui/landing.test.ts b/src/tui-opentui/landing.test.ts index 284ebea8c..a5f3430b8 100644 --- a/src/tui-opentui/landing.test.ts +++ b/src/tui-opentui/landing.test.ts @@ -13,6 +13,7 @@ import { createAppShell, noticeText, paintChrome, + setChromeZones, setPromptWorkspace, isLanding, paintLanding, @@ -29,6 +30,9 @@ import { landingSuggestionFor, resolveMarkGrid, splitLandingRows, + VERSION_BADGE_MIN_COLUMNS, + VERSION_BADGE_MIN_ROWS, + versionBadgeVisible, wrapLanding, } from "./landing" import { LOCKUP_WORDMARK } from "./lockup" @@ -106,7 +110,10 @@ describe("landing layout math", () => { expect(resolveMarkGrid(12, 96)).toBe(MARK_MID) expect(resolveMarkGrid(9, 96)).toBe(MARK_SMALL) // Narrow enough that the mark would crowd the hints: the hints win. - expect(resolveMarkGrid(20, 50)).toBe(MARK_SMALL) + // (The version moved off this hint block into the shell's own chrome — + // CL-5736 — so the block is narrower and a bit more room stays for the + // mark at this width than before.) + expect(resolveMarkGrid(20, 50)).toBe(MARK_MID) expect(resolveMarkGrid(20, 30)).toBeNull() expect(resolveMarkGrid(3, 96)).toBeNull() }) @@ -161,9 +168,20 @@ describe("landing screen", () => { descriptionColumns.add(row!.indexOf(hint.rest)) } expect(descriptionColumns.size).toBe(1) - // The version sits with the hints, and cannot drift from package.json. + // The version is chrome, not part of the hero: it never shares a row + // with a hint, and cannot drift from package.json. expect(LANDING_VERSION).toBe(`v${pkg.version}`) - expect(h.captureCharFrame()).toContain(LANDING_VERSION) + for (const hint of LANDING_HINTS) { + const row = painted.find((line) => line.includes(hint.rest)) + expect(row).not.toContain(LANDING_VERSION) + } + const versionRow = painted.findIndex((row) => row.includes(LANDING_VERSION)) + expect(versionRow).toBeGreaterThanOrEqual(0) + // Bottom-right: on the terminal's last content row, hugging the right + // edge rather than sitting under the hints. + expect(versionRow).toBeGreaterThanOrEqual(SIZE.height - 2) + const versionCol = painted[versionRow]!.lastIndexOf(LANDING_VERSION) + expect(versionCol + LANDING_VERSION.length).toBeGreaterThan(SIZE.width - 4) const noticeRow = painted.findIndex((row) => row.includes("telemetry")) expect(noticeRow).toBeGreaterThan(bottom) for (const item of LANDING_SUGGESTIONS) { @@ -306,7 +324,9 @@ describe("landing screen", () => { const ruleRow = painted.findIndex((row) => row.includes(LOCKUP_WORDMARK), ) - // The prompt box rests on the terminal's last row. + // Session-active: the version row only reserves space on the landing + // screen (see `relayout`), so once there is real transcript content + // the box is back on the terminal's very last row. expect(ruleRow).toBe(SIZE.height - 1) const row = painted[ruleRow]! // Left end of the rule, inside the shell gutter, costing no row. @@ -605,4 +625,153 @@ describe("landing screen", () => { } }, SIZE) }) + + test("the version is chrome, not the hero: it hides before actionable chrome does on a narrow terminal", async () => { + // Comfortably above the badge's own thresholds but below nothing else — + // proves the badge is what degrades, and degrades first. + const roomy = { width: VERSION_BADGE_MIN_COLUMNS + 20, height: VERSION_BADGE_MIN_ROWS + 8 } + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: roomy.width, rows: roomy.height }, + wireKeys: false, + run: "idle", + }) + try { + await settle(h) + expect(h.captureCharFrame()).toContain(LANDING_VERSION) + } finally { + shell.dispose() + } + }, + roomy, + ) + + // Just under the badge's column floor: the badge is gone, but the prompt + // field — genuinely actionable chrome — is still on screen. + const narrowColumns = { + width: VERSION_BADGE_MIN_COLUMNS - 1, + height: VERSION_BADGE_MIN_ROWS + 8, + } + expect(versionBadgeVisible(narrowColumns.width, narrowColumns.height)).toBe(false) + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: narrowColumns.width, rows: narrowColumns.height }, + wireKeys: false, + run: "idle", + }) + try { + await settle(h) + const frame = h.captureCharFrame() + expect(frame).not.toContain(LANDING_VERSION) + expect(frame).toContain("message") + } finally { + shell.dispose() + } + }, + narrowColumns, + ) + + // Just under the badge's row floor: same story, short rather than narrow. + const shortRows = { + width: VERSION_BADGE_MIN_COLUMNS + 20, + height: VERSION_BADGE_MIN_ROWS - 1, + } + expect(versionBadgeVisible(shortRows.width, shortRows.height)).toBe(false) + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: shortRows.width, rows: shortRows.height }, + wireKeys: false, + run: "idle", + }) + try { + await settle(h) + const frame = h.captureCharFrame() + expect(frame).not.toContain(LANDING_VERSION) + expect(frame).toContain("message") + } finally { + shell.dispose() + } + }, + shortRows, + ) + }) + + test("the task panel and the version badge both paint while landing is still mounted, without clipping the prompt", async () => { + // A resumed session can land with tasks already visible while the + // landing screen has not been torn down yet (no transcript content sent) + // — restored chrome and the version badge's reserved row both compete + // for the same short terminal at once. This is the regression case for + // that interaction (CL-5735/5736 review, blocker 4). + const size = { width: 100, height: 17 } + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: size.width, rows: size.height }, + wireKeys: false, + run: "idle", + }) + try { + setChromeZones(shell, { + task: [{ label: "wire the version badge", status: "doing" }], + }) + await settle(h) + + expect(isLanding(shell)).toBe(true) + expect(shell.taskBox.visible).toBe(true) + + const painted = rows(h) + // captureCharFrame's trailing newline yields one extra split entry. + expect(painted.length).toBe(size.height + 1) + // Nothing is clipped off past the terminal's own row count — the + // frame is exactly as tall as the terminal, not taller. + expect(painted.slice(size.height).every((row) => row === "")).toBe( + true, + ) + + const frame = painted.join("\n") + expect(frame).toContain("wire the version badge") + expect(frame).toContain(LANDING_VERSION) + // The prompt field itself is on screen, intact, not pushed off by + // the combination of the task row and the version row. + const promptRow = painted.findIndex((row) => row.includes("message")) + expect(promptRow).toBeGreaterThan(0) + const box = shell.layout.regions.prompt + expect(box).toBeDefined() + expect(box!.y + box!.height).toBeLessThanOrEqual(size.height) + } finally { + shell.dispose() + } + }, + size, + ) + }) + + test("the version never appears inside the hero block beside the mark/hints", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: SIZE.width, rows: SIZE.height }, + wireKeys: false, + run: "idle", + }) + try { + await settle(h) + const painted = rows(h) + const heroEnd = painted.findIndex((row) => /[┌╭]/.test(row)) + expect(heroEnd).toBeGreaterThan(0) + // Nothing above the box's own top border carries the version — the + // hero (mark + hint doors) is exactly the two lines, no third. + for (const row of painted.slice(0, heroEnd)) { + expect(row).not.toContain(LANDING_VERSION) + } + } finally { + shell.dispose() + } + }, + SIZE, + ) + }) }) diff --git a/src/tui-opentui/landing.ts b/src/tui-opentui/landing.ts index 21c61d86f..e58c03ff2 100644 --- a/src/tui-opentui/landing.ts +++ b/src/tui-opentui/landing.ts @@ -50,20 +50,39 @@ const MARK_GAP_ROWS = 1 /** Columns of air between the mark's right edge and the hint block. */ export const LANDING_HERO_GAP = 3 -/** The running build, read from `package.json` so it cannot drift from what shipped. */ +/** + * The running build, read from `package.json` so it cannot drift from what + * shipped. Rendered in the shell's persistent chrome (bottom-right of the + * terminal), not as part of this module's landing composition — see + * `versionBadgeVisible` and `shell.ts`'s `versionBadge`. + */ export const LANDING_VERSION = `v${pkg.version}` /** - * The two doors off the landing screen. Every other key lives behind one of - * them, so this list never grows. + * Minimum terminal size the version badge needs before it hides. 16 rows is + * above `IDLE_TRANSCRIPT_FLOOR` (12) — the only row floor real chrome is + * actually held to at rest — so the badge is gone well before the + * transcript itself would be squeezed. It is below `BOTTOM_MARGIN_MIN_ROWS` + * (24, in `geometry/margins.ts`); that constant does not currently mean + * anything in practice (`BOTTOM_MARGIN_ROWS` it gates is 0), so there is no + * real floor at 24 to be above yet, but if one is ever added there this + * threshold does not automatically clear it and should be revisited. + */ +export const VERSION_BADGE_MIN_COLUMNS = 60 +export const VERSION_BADGE_MIN_ROWS = 16 + +export function versionBadgeVisible(columns: number, rows: number): boolean { + return columns >= VERSION_BADGE_MIN_COLUMNS && rows >= VERSION_BADGE_MIN_ROWS +} + +/** + * The one door off the landing screen — `/help` (listed among the commands + * `/` opens) is the other, so this stays a single row rather than growing. */ export const LANDING_HINTS: readonly { readonly key: string readonly rest: string -}[] = [ - { key: "ctrl+o", rest: "for commands" }, - { key: "?", rest: "for shortcuts" }, -] +}[] = [{ key: "/", rest: "for commands" }] /** * Columns held for the key, so the descriptions beside them start on one @@ -78,13 +97,10 @@ export const LANDING_KEY_WIDTH = LANDING_HINTS.reduce( const LANDING_KEY_GAP = 2 /** Columns the hint block needs, its longest line deciding. */ -export const LANDING_HINT_WIDTH = Math.max( - LANDING_HINTS.reduce( - (widest, hint) => - Math.max(widest, LANDING_KEY_WIDTH + LANDING_KEY_GAP + hint.rest.length), - 0, - ), - LANDING_VERSION.length, +export const LANDING_HINT_WIDTH = LANDING_HINTS.reduce( + (widest, hint) => + Math.max(widest, LANDING_KEY_WIDTH + LANDING_KEY_GAP + hint.rest.length), + 0, ) /** Largest first: the landing takes the best-reading mark its zone can seat. */ @@ -366,24 +382,6 @@ function createHintBlock(ctx: CliRenderer): BoxRenderable { }), ) }) - // The build is a fact about what is running, not a third door. Flush against - // the two keys it read as one of them. - block.add( - new TextRenderable(ctx, { - id: "shell-landing-version-gap", - height: 1, - content: "", - fg: UI.ground, - }), - ) - block.add( - new TextRenderable(ctx, { - id: "shell-landing-version", - height: 1, - content: LANDING_VERSION, - fg: UI.textFaint, - }), - ) return block } @@ -393,9 +391,8 @@ function createHintBlock(ctx: CliRenderer): BoxRenderable { */ export function fitLandingMark(above: LandingAbove, grid: MarkGrid | null): void { above.grid = grid - // With no mark, the hero is exactly the hint block: the two keys, the blank - // row, and the version. - const rows = grid?.rows ?? LANDING_HINTS.length + 2 + // With no mark, the hero is exactly the hint block: one row per door. + const rows = grid?.rows ?? LANDING_HINTS.length above.hero.height = rows above.markColumn.visible = grid !== null above.markColumn.width = grid?.cols ?? 0 diff --git a/src/tui-opentui/observe-live.test.ts b/src/tui-opentui/observe-live.test.ts index 84668d529..22bd03ed4 100644 --- a/src/tui-opentui/observe-live.test.ts +++ b/src/tui-opentui/observe-live.test.ts @@ -19,8 +19,8 @@ import { appendStreamRow, createAppShell, enterSubagentObserve, + getPaletteOnObserveRequest, leaveSubagentObserve, - runPaletteAction, setPaletteOnObserveRequest, } from "./shell.js" @@ -351,8 +351,11 @@ describe("live subagent observe", () => { }) }) -describe("palette observe action asks the host for a live session", () => { - test("uses the host-supplied session instead of the fixture", async () => { +describe("observe request handler injection point", () => { + // No UI surface calls this anymore (the palette action that did is gone + // with Ctrl+O); the host-injection API itself stays available for a future + // trigger, so it is proven directly here rather than through a key chord. + test("host can resolve and use its own live session", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -367,7 +370,9 @@ describe("palette observe action asks the host for a live session", () => { }), ) - runPaletteAction(shell, "observe") + const session = getPaletteOnObserveRequest(shell)?.() + expect(session).not.toBeNull() + if (session) enterSubagentObserve(shell, session) expect(shell.observe?.sessionId).toBe("live-child-1") expect(shell.observe?.agentId).toBe("worker") @@ -382,7 +387,7 @@ describe("palette observe action asks the host for a live session", () => { ) }) - test("reports no session available instead of entering observe", async () => { + test("resolves to null when the host has nothing to offer", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -391,36 +396,7 @@ describe("palette observe action asks the host for a live session", () => { }) try { setPaletteOnObserveRequest(shell, () => null) - - runPaletteAction(shell, "observe") - - expect(shell.observe).toBeNull() - expect( - shell.streamLog.some((r) => - r.text.includes("no subagent session to observe"), - ), - ).toBe(true) - } finally { - shell.dispose() - } - }, - { width: 80, height: 24 }, - ) - }) - - test("stays out of observe with an honest message when no host handler is set", async () => { - await withTestRenderer( - async (h) => { - const shell = createAppShell(h.renderer, { - terminal: { columns: 80, rows: 24 }, - run: "idle", - }) - try { - runPaletteAction(shell, "observe") - expect(shell.observe).toBeNull() - expect( - shell.streamLog.some((r) => r.text === "no subagent session to observe"), - ).toBe(true) + expect(getPaletteOnObserveRequest(shell)?.()).toBeNull() } finally { shell.dispose() } diff --git a/src/tui-opentui/palette-paint.test.ts b/src/tui-opentui/palette-paint.test.ts index 03ba39c86..662169c84 100644 --- a/src/tui-opentui/palette-paint.test.ts +++ b/src/tui-opentui/palette-paint.test.ts @@ -1,14 +1,13 @@ /** - * Frame-level checks for the command palette's rows: the label, the - * right-aligned chord, and how the chord degrades at narrow widths. + * Frame-level checks for the `/` command list's rows: the label, and how it + * ellipsizes at narrow widths. */ import { describe, expect, test } from "bun:test" import type { KeyEvent } from "@opentui/core" import { withTestRenderer } from "./harness" -import { openModelPickerOverlay } from "./overlays" -import type { PaletteCommand } from "./palette" +import type { PaletteCommand } from "./command-catalog" import { createAppShell, handlePaletteFilterKey, @@ -17,6 +16,12 @@ import { type AppShell, } from "./shell" +const CATALOG: readonly PaletteCommand[] = [ + { id: "help", label: "/help — show keymap help", keywords: ["help"] }, + { id: "model", label: "/model — switch model / provider", keywords: ["model"] }, + { id: "mcp", label: "/mcp — manage MCP servers", keywords: ["mcp"] }, +] + async function paletteFrame(width: number): Promise { return withTestRenderer( async (h) => { @@ -25,7 +30,7 @@ async function paletteFrame(width: number): Promise { wireKeys: false, run: "idle", }) - openPalette(shell) + openPalette(shell, { catalog: CATALOG }) await h.renderOnce() return h .captureCharFrame() @@ -40,7 +45,7 @@ function rowFor(rows: readonly string[], label: string): string | undefined { return rows.find((r) => r.includes(label)) } -describe("command palette rows", () => { +describe("command list rows", () => { test("shows the filter prompt with no title rule above it", async () => { const rows = await paletteFrame(100) expect(rows.some((r) => r.startsWith("─ command palette ─"))).toBe(false) @@ -49,27 +54,16 @@ describe("command palette rows", () => { test("has no leading selection marker or kind column", async () => { const rows = await paletteFrame(100) - const help = rowFor(rows, "Show keymap help") + const help = rowFor(rows, "show keymap help") expect(help).toBeDefined() - expect(help).toMatch(/^\s*Show keymap help\s+\?$/) expect(help).not.toContain(">") expect(help).not.toContain("view") }) - test("keeps the right-aligned chord at 100 columns", async () => { - const rows = await paletteFrame(100) - const copy = rowFor(rows, "Copy active message / tool") - expect(copy?.endsWith("Alt+C")).toBe(true) - }) - - test("drops the chord at a narrow width, and the label always survives", async () => { - const rows = await paletteFrame(36) - const help = rowFor(rows, "Show keymap help") + test("ellipsizes a label that cannot fit a narrow width, never dropping it", async () => { + const rows = await paletteFrame(20) + const help = rowFor(rows, "help") expect(help).toBeDefined() - expect(help?.endsWith("?")).toBe(false) - - const copy = rowFor(rows, "Copy active") - expect(copy?.includes("Alt+C")).toBe(false) }) }) @@ -86,7 +80,7 @@ describe("palette filters as you type", () => { run: "idle", }) try { - openPalette(shell, { typeToFilter: true }) + openPalette(shell, { catalog: CATALOG, typeToFilter: true }) fn(shell) } finally { shell.dispose() @@ -121,9 +115,7 @@ describe("palette filters as you type", () => { press(shell, "o") press(shell, "d") expect(shell.paletteCommands.length).toBeLessThan(all) - expect(shell.paletteCommands.some((c) => c.id === "model_picker")).toBe( - true, - ) + expect(shell.paletteCommands.some((c) => c.id === "model")).toBe(true) expect(shell.overlayBodyLines[0]).toBe("> mod") }) }) @@ -131,13 +123,13 @@ describe("palette filters as you type", () => { test("backspace widens the list again", async () => { await withPalette((shell) => { press(shell, "m") - press(shell, "o") - press(shell, "d") const narrowed = shell.paletteCommands.length + press(shell, "c") + expect(shell.paletteCommands.length).toBeLessThanOrEqual(narrowed) expect(handlePaletteFilterKey(shell, BACKSPACE)).toBe(true) expect(handlePaletteFilterKey(shell, BACKSPACE)).toBe(true) - expect(shell.paletteCommands.length).toBeGreaterThan(narrowed) - expect(shell.overlayBodyLines[0]).toBe("> m") + expect(shell.paletteCommands.length).toBe(CATALOG.length) + expect(shell.overlayBodyLines[0]).toBe(">") }) }) @@ -165,7 +157,7 @@ describe("palette filters as you type", () => { }) }) - test("a query matching nothing leaves the palette open and empty", async () => { + test("a query matching nothing leaves the list open and empty", async () => { await withPalette((shell) => { for (const ch of "zzqq") press(shell, ch) expect(shell.paletteCommands).toEqual([]) @@ -173,30 +165,11 @@ describe("palette filters as you type", () => { expect(shell.overlayKind).toBe("palette") }) }) - - test("other overlays keep j/k navigation", async () => { - await withTestRenderer( - async (h) => { - const shell = createAppShell(h.renderer, { - terminal: { columns: 100, rows: 32 }, - wireKeys: false, - run: "idle", - }) - try { - openModelPickerOverlay(shell) - expect(press(shell, "j")).toBe(false) - } finally { - shell.dispose() - } - }, - { width: 100, height: 32 }, - ) - }) }) -describe("command palette width", () => { +describe("command list width", () => { // Both boxes are children of the same padded root; a width computed a - // second way for the floating palette drifts from the prompt box's "100%". + // second way for the floating list drifts from the prompt box's "100%". test("shares the prompt box's left/right edges while floating over landing", async () => { const rows = await withTestRenderer( async (h) => { @@ -205,7 +178,7 @@ describe("command palette width", () => { wireKeys: false, run: "idle", }) - openPalette(shell) + openPalette(shell, { catalog: CATALOG }) await h.renderOnce() return h.captureCharFrame().split("\n") }, @@ -220,7 +193,7 @@ describe("command palette width", () => { }) }) -describe("command palette selection colour", () => { +describe("command list selection colour", () => { test("marks the active row by text colour, not a filled background", async () => { await withTestRenderer( async (h) => { @@ -229,14 +202,14 @@ describe("command palette selection colour", () => { wireKeys: false, run: "idle", }) - openPalette(shell) + openPalette(shell, { catalog: CATALOG }) await h.renderOnce() const frame = h.captureSpans() const activeLine = frame.lines.find((line) => - line.spans.some((s) => s.text.includes("Open permissions")), + line.spans.some((s) => s.text.includes("/help")), ) const groundLine = frame.lines.find((line) => - line.spans.some((s) => s.text.includes("Ask operator question")), + line.spans.some((s) => s.text.includes("/model")), ) expect(activeLine).toBeDefined() expect(groundLine).toBeDefined() @@ -246,10 +219,10 @@ describe("command palette selection colour", () => { // (fg), not a filled band behind the row. expect(activeBg).toEqual(groundBg) const activeFg = activeLine!.spans.find((s) => - s.text.includes("Open permissions"), + s.text.includes("/help"), )!.fg const groundFg = groundLine!.spans.find((s) => - s.text.includes("Ask operator question"), + s.text.includes("/model"), )!.fg expect(activeFg).not.toEqual(groundFg) }, @@ -258,13 +231,12 @@ describe("command palette selection colour", () => { }) }) -describe("command palette height cap", () => { +describe("command list height cap", () => { const BIG_CATALOG: readonly PaletteCommand[] = Array.from( { length: 50 }, (_, i) => ({ id: `cmd_${String(i)}`, label: `Fake command number ${String(i)} with a longish label`, - dispatch: "command" as const, }), ) @@ -317,4 +289,3 @@ describe("command palette height cap", () => { ) }) }) - diff --git a/src/tui-opentui/palette.test.ts b/src/tui-opentui/palette.test.ts deleted file mode 100644 index 922d59dcb..000000000 --- a/src/tui-opentui/palette.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { shortcutForPaletteId } from "./keybindings" -import { - DEFAULT_PALETTE_COMMANDS, - buildPaletteCatalog, - filterPaletteCommands, - formatPaletteRows, - paletteDispatchOf, - paletteLabels, - paletteRowColumns, - paletteRowLayout, - type PaletteRowColumns, -} from "./palette" - -describe("filterPaletteCommands", () => { - test("empty query returns full catalog", () => { - const all = filterPaletteCommands("") - expect(all.length).toBe(DEFAULT_PALETTE_COMMANDS.length) - expect(all).toEqual([...DEFAULT_PALETTE_COMMANDS]) - }) - - test("matches label substring", () => { - const hits = filterPaletteCommands("model") - expect(hits.some((c) => c.id === "model_picker")).toBe(true) - expect( - hits.every( - (c) => - c.label.toLowerCase().includes("model") || - (c.keywords ?? []).some((k) => k.includes("model")) || - c.id.includes("model"), - ), - ).toBe(true) - }) - - test("matches keywords", () => { - const hits = filterPaletteCommands("yank") - expect(hits.some((c) => c.id === "copy_active")).toBe(true) - }) - - test("no matches empty array", () => { - expect(filterPaletteCommands("zzzz-nope")).toEqual([]) - }) -}) - -describe("paletteLabels", () => { - test("stable order labels", () => { - const labels = paletteLabels(DEFAULT_PALETTE_COMMANDS) - expect(labels[0]).toBe("Open permissions") - expect(labels.length).toBe(DEFAULT_PALETTE_COMMANDS.length) - }) -}) - -describe("buildPaletteCatalog", () => { - test("registry commands append as dispatch command", () => { - const catalog = buildPaletteCatalog({ - commands: [ - { name: "compact", description: "Compact history" }, - - { name: "help", description: "Show help" }, - ], - }) - const compact = catalog.find((c) => c.id === "compact") - expect(compact?.dispatch).toBe("command") - expect(compact?.label).toContain("compact") - }) - - test("preferRegistry drops residual when registry uses same id", () => { - const catalog = buildPaletteCatalog({ - commands: [{ name: "help", description: "Slash help" }], - preferRegistry: true, - }) - const helps = catalog.filter((c) => c.id === "help") - expect(helps.length).toBe(1) - expect(paletteDispatchOf(helps[0]!)).toBe("command") - }) - - test("filter works on registry-built catalog", () => { - const catalog = buildPaletteCatalog({ - commands: [{ name: "plugins", description: "Manage plugins" }], - }) - const hits = filterPaletteCommands("plug", catalog) - expect(hits.some((c) => c.id === "plugins")).toBe(true) - }) -}) - -describe("palette row columns", () => { - test("shortcuts come from the shell keybinding table", () => { - const help = DEFAULT_PALETTE_COMMANDS.find((c) => c.id === "help") - expect(paletteRowColumns(help!, shortcutForPaletteId).shortcut).toBe("?") - const toggle = DEFAULT_PALETTE_COMMANDS.find((c) => c.id === "toggle_task") - expect(paletteRowColumns(toggle!, shortcutForPaletteId).shortcut).toBe("") - }) -}) - -describe("formatPaletteRows", () => { - const ROWS: readonly PaletteRowColumns[] = [ - { label: "Show keymap help", shortcut: "?" }, - { label: "Copy active message / tool", shortcut: "Alt+C" }, - { label: "Resume prior session", shortcut: "" }, - ] - - test("renders the label and right-aligned shortcut at full width", () => { - const [help, copy] = formatPaletteRows(ROWS, 55) - expect(help).toHaveLength(55) - expect(help?.startsWith("Show keymap help")).toBe(true) - expect(help?.trimEnd().endsWith("?")).toBe(true) - expect(copy?.trimEnd().endsWith("Alt+C")).toBe(true) - }) - - test("every row is exactly the requested width", () => { - for (const width of [40, 48, 60, 100]) { - for (const row of formatPaletteRows(ROWS, width)) { - expect(row).toHaveLength(width) - } - } - }) - - test("the shortcut column drops as width narrows, and the label always survives", () => { - expect(paletteRowLayout(ROWS, 40)).toMatchObject({ showShortcut: true }) - expect(paletteRowLayout(ROWS, 30)).toMatchObject({ showShortcut: false }) - const rows = formatPaletteRows(ROWS, 30) - expect(rows[0]?.includes("?")).toBe(false) - expect(rows[0]?.trimEnd()).toBe("Show keymap help") - }) -}) diff --git a/src/tui-opentui/palette.ts b/src/tui-opentui/palette.ts deleted file mode 100644 index 78403cb88..000000000 --- a/src/tui-opentui/palette.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * Command palette — Amp-class discovery catalog + filter (pure). - * Ctrl+O opens (reclaimed from tool-expand); Esc restores prior focus. - * Shell owns paint / focus stack via openPalette. - * - * Catalog = residual product openers + optional registry-backed slash commands. - * Registry import is NOT hard-wired here (avoids circular / heavy deps); the host - * injects `listCommands()` results via `buildPaletteCatalog` / `openPalette` opts. - */ - -import { sliceToWidth, stringWidth } from "../tui/view/height.js" - -/** Residual product actions owned by the shell (open overlays / chrome toggles). */ -export type PaletteActionId = - | "permissions" - | "operator" - | "model_picker" - | "toggle_task" - | "toggle_agents" - | "copy_active" - | "toggle_mouse" - | "help" - | "mentions" - | "observe" - -const RESIDUAL_ACTION_IDS = new Set([ - "permissions", - "operator", - "model_picker", - "toggle_task", - "toggle_agents", - "copy_active", - "toggle_mouse", - "help", - "mentions", - "observe", -]) - -export function isResidualActionId(id: string): id is PaletteActionId { - return RESIDUAL_ACTION_IDS.has(id) -} - -/** - * How select dispatches: - * - residual — shell `runPaletteAction` (overlays / chrome) - * - command — injectable `onCommand(name)` for registry slash commands - */ -export type PaletteDispatch = "residual" | "command" - -export type PaletteCommand = { - /** Residual action id or registry command name. */ - readonly id: string - readonly label: string - /** Optional keywords for fuzzy-ish filter. */ - readonly keywords?: readonly string[] - /** - * Select dispatch target. Defaults to residual when id is a known residual - * action; registry-built items set `"command"` explicitly. - */ - readonly dispatch?: PaletteDispatch -} - -/** Minimal registry shape — matches `listCommands()` entries without importing them. */ -export type RegistryCommandSource = { - readonly name: string - readonly description: string -} - -/** Default Amp-class residual openers (product actions, not slash-only). */ -export const DEFAULT_PALETTE_COMMANDS: readonly PaletteCommand[] = [ - { - id: "permissions", - label: "Open permissions", - keywords: ["allow", "deny", "tool", "approve"], - dispatch: "residual", - }, - { - id: "operator", - label: "Ask operator question", - keywords: ["confirm", "choice", "prompt"], - dispatch: "residual", - }, - { - id: "model_picker", - label: "Switch model / provider", - keywords: ["model", "provider", "anthropic", "openai"], - dispatch: "residual", - }, - { - id: "toggle_task", - label: "Toggle task list panel", - keywords: ["task", "work", "chrome", "list", "panel"], - dispatch: "residual", - }, - { - id: "toggle_agents", - label: "Toggle agents strip", - keywords: ["agents", "strip", "workers"], - dispatch: "residual", - }, - { - id: "copy_active", - label: "Copy active message / tool", - keywords: ["copy", "clipboard", "yank"], - dispatch: "residual", - }, - { - id: "toggle_mouse", - label: "Toggle mouse capture (on by default; release it to drag-select)", - keywords: ["mouse", "select", "selection", "copy", "drag"], - dispatch: "residual", - }, - { - id: "help", - label: "Show keymap help", - keywords: ["keys", "bindings", "help"], - dispatch: "residual", - }, - { - id: "mentions", - label: "Insert file mention", - keywords: ["@", "path", "file", "mention"], - dispatch: "residual", - }, - { - id: "observe", - label: "Observe subagent session", - keywords: ["child", "worker", "observe", "agents"], - dispatch: "residual", - }, -] - -/** - * Map registry command definitions to palette items. - * Caller filters hidden via `listCommands()` (or fixture) before passing. - */ -export function commandsToPaletteItems( - commands: readonly RegistryCommandSource[], -): PaletteCommand[] { - return commands.map((c) => ({ - id: c.name, - label: `/${c.name} — ${c.description}`, - keywords: [c.name, "slash", "command"], - dispatch: "command" as const, - })) -} - -export type BuildPaletteCatalogOpts = { - /** Residual openers. Defaults to DEFAULT_PALETTE_COMMANDS. */ - readonly residuals?: readonly PaletteCommand[] - /** - * Registry-shaped commands (from `listCommands()` or test fixtures). - * Already filtered for hidden / availability by the source. - */ - readonly commands?: readonly RegistryCommandSource[] - /** - * When true (default), skip residual openers whose id matches a registry - * command name so slash entries win for discovery of real handlers. - * Residual-only product actions (toggle_*, copy_active, …) always remain. - */ - readonly preferRegistry?: boolean -} - -/** - * Build a palette catalog from residual openers + optional registry commands. - * Pure — no registry import; host injects `listCommands()` results. - */ -export function buildPaletteCatalog( - opts?: BuildPaletteCatalogOpts, -): readonly PaletteCommand[] { - const residuals = opts?.residuals ?? DEFAULT_PALETTE_COMMANDS - const commands = opts?.commands ?? [] - const preferRegistry = opts?.preferRegistry !== false - - const registryNames = new Set(commands.map((c) => c.name)) - const residualItems = - preferRegistry && registryNames.size > 0 - ? residuals.filter((r) => !registryNames.has(r.id)) - : [...residuals] - - const commandItems = commandsToPaletteItems(commands) - return [...residualItems, ...commandItems] -} - -/** - * Resolve dispatch for a palette item. - * Explicit `dispatch` wins; else residual if id is a known residual action. - */ -export function paletteDispatchOf(cmd: PaletteCommand): PaletteDispatch { - if (cmd.dispatch === "command" || cmd.dispatch === "residual") { - return cmd.dispatch - } - return isResidualActionId(cmd.id) ? "residual" : "command" -} - -/** - * Case-insensitive substring filter over label + keywords. - * Empty query returns the full catalog (stable order). - */ -export function filterPaletteCommands( - query: string, - catalog: readonly PaletteCommand[] = DEFAULT_PALETTE_COMMANDS, -): readonly PaletteCommand[] { - const q = query.trim().toLowerCase() - if (q.length === 0) return catalog - return catalog.filter((cmd) => { - if (cmd.label.toLowerCase().includes(q)) return true - if (cmd.id.toLowerCase().includes(q)) return true - return (cmd.keywords ?? []).some((k) => k.toLowerCase().includes(q)) - }) -} - -/** Labels for the shared list viewport. */ -export function paletteLabels( - commands: readonly PaletteCommand[], -): readonly string[] { - return commands.map((c) => c.label) -} - -/** One palette row before it is fitted to a width. */ -export type PaletteRowColumns = { - readonly label: string - /** Empty when the entry has no chord. */ - readonly shortcut: string -} - -export function paletteRowColumns( - cmd: PaletteCommand, - shortcutOf: (id: string) => string | undefined, -): PaletteRowColumns { - return { - label: cmd.label, - shortcut: shortcutOf(cmd.id) ?? "", - } -} - -/** Columns the label must keep before the shortcut column is dropped. */ -const PALETTE_LABEL_MIN = 28 -const PALETTE_COL_GAP = 2 - -export type PaletteRowLayout = { - readonly showShortcut: boolean - readonly shortcutWidth: number -} - -/** - * Whether the shortcut column survives at `width`. It is redundant — the row - * it labels is right there and can be selected instead — so it is the one - * thing dropped when space is tight; the label itself is never truncated - * away entirely. - */ -export function paletteRowLayout( - rows: readonly PaletteRowColumns[], - width: number, -): PaletteRowLayout { - const shortcutWidth = rows.reduce((n, r) => Math.max(n, stringWidth(r.shortcut)), 0) - const showShortcut = - shortcutWidth > 0 && width - shortcutWidth - PALETTE_COL_GAP >= PALETTE_LABEL_MIN - return { showShortcut, shortcutWidth } -} - -function fitLabel(label: string, width: number): string { - if (width <= 0) return "" - const columns = stringWidth(label) - // padEnd counts code units, so a label carrying a wide glyph has to be padded - // by the column shortfall rather than to a code-unit length. - if (columns <= width) return label + " ".repeat(width - columns) - if (width === 1) return "…" - // A wide glyph that will not fit the last column leaves the cut short, so the - // shortfall is padded back rather than shifting the chord column left. - const cut = `${sliceToWidth(label, width - 1)}…` - return cut + padTo(cut, width) -} - -/** Spaces needed to carry `text` out to `width` columns. */ -function padTo(text: string, width: number): string { - return " ".repeat(Math.max(0, width - stringWidth(text))) -} - -/** - * Render rows to exactly `width` columns: label, right-aligned chord. The - * shortcut column's width is shared across the batch so chords line up. - */ -export function formatPaletteRows( - rows: readonly PaletteRowColumns[], - width: number, -): readonly string[] { - const layout = paletteRowLayout(rows, width) - const tail = layout.showShortcut ? layout.shortcutWidth + PALETTE_COL_GAP : 0 - const labelWidth = Math.max(0, width - tail) - return rows.map((row) => { - const shortcut = layout.showShortcut - ? " ".repeat(PALETTE_COL_GAP) + padTo(row.shortcut, layout.shortcutWidth) + row.shortcut - : "" - return `${fitLabel(row.label, labelWidth)}${shortcut}` - }) -} diff --git a/src/tui-opentui/product-host.test.ts b/src/tui-opentui/product-host.test.ts index a90c95701..c5d6fd118 100644 --- a/src/tui-opentui/product-host.test.ts +++ b/src/tui-opentui/product-host.test.ts @@ -322,7 +322,11 @@ describe("provider-first model picker", () => { } async function mountPicker(overrides: Partial = {}) { - const harness = await createHarness({ width: 80, height: 24 }) + // One row taller than the usual fixture: on the landing screen (no + // session content yet, which this fixture never sends) the version badge + // reserves the terminal's last row, and this picker's row list needs + // every row of the 24-row case to fit every provider. + const harness = await createHarness({ width: 80, height: 25 }) const port = makeFakeSessionPort() const catalog = buildModelsFirstCatalog({ providers, diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index 39f3af339..07c144d4b 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -35,7 +35,7 @@ import { RUNTIME_FLASH_MS, type RuntimeNotice, } from "./runtime-notices.js" -import type { PaletteCommand } from "./palette.js" +import type { PaletteCommand } from "./command-catalog.js" import { appendObserveStreamRow, appendStreamRow, diff --git a/src/tui-opentui/prompt-box.test.ts b/src/tui-opentui/prompt-box.test.ts index 1bb5f9573..2f7acd266 100644 --- a/src/tui-opentui/prompt-box.test.ts +++ b/src/tui-opentui/prompt-box.test.ts @@ -88,7 +88,9 @@ describe("prompt box height", () => { }) test("stops growing at the cap fraction and scrolls instead", async () => { - await withShell({ columns: 80, rows: 40 }, async (shell, h) => { + // +1 row: this shell never sends a message, so it stays on the landing + // screen, where the version badge reserves the terminal's last row. + await withShell({ columns: 80, rows: 41 }, async (shell, h) => { const cap = Math.floor(40 * PROMPT_CAP_FRACTION) await compose(shell, h, lines(60)) expect(shell.layout.heights.prompt).toBe(cap) @@ -99,6 +101,23 @@ describe("prompt box height", () => { }) }) + test("the version badge costs the prompt cap a real row while it reserves one", async () => { + // The badge is not free chrome: `terminalForGeometry` subtracts its row + // from the terminal size handed to the resolver before PROMPT_CAP_FRACTION + // ever runs, so a landing-screen prompt caps one row lower than the same + // terminal would allow once real transcript content takes the row back. + // This is the regression test for that interaction (CL-5735/5736 review). + for (const totalRows of [18, 20, 23]) { + await withShell({ columns: 80, rows: totalRows }, async (shell, h) => { + const reducedCap = Math.floor((totalRows - 1) * PROMPT_CAP_FRACTION) + const naiveCap = Math.floor(totalRows * PROMPT_CAP_FRACTION) + expect(reducedCap).toBeLessThan(naiveCap) + await compose(shell, h, lines(80)) + expect(shell.layout.heights.prompt).toBe(reducedCap) + }) + } + }) + test("shrinks back toward the base box on a terminal too short for both", async () => { await withShell({ columns: 80, rows: 16 }, (shell) => { expect(shell.layout.heights.prompt).toBeLessThan(PROMPT_IDLE_ROWS) @@ -126,7 +145,9 @@ describe("prompt box height", () => { }) test("the box stays anchored at the foot of the terminal", async () => { - await withShell({ columns: 80, rows: 30 }, async (shell, h) => { + // +1 row: composing without sending stays on the landing screen, where + // the version badge reserves the terminal's last row. + await withShell({ columns: 80, rows: 31 }, async (shell, h) => { await compose(shell, h, lines(5)) const box = shell.layout.regions.prompt expect(box).toBeDefined() @@ -192,15 +213,10 @@ describe("Enter is still the send key", () => { }) describe("openers toggle their surface shut", () => { - test("Ctrl+O opens the palette and closes it", async () => { + test("Ctrl+O no longer opens anything (removed; use / or the help command)", async () => { await withShell({ columns: 80, rows: 30 }, (shell, h) => { - h.pressKey("o", { ctrl: true }) - expect(shell.overlayKind).toBe("palette") - expect(focusOwner(shell.focus)).toBe("palette") - h.pressKey("o", { ctrl: true }) expect(shell.overlayKind).toBeNull() - expect(shell.overlayList).toBeNull() expect(focusOwner(shell.focus)).not.toBe("palette") }) }) @@ -217,12 +233,9 @@ describe("openers toggle their surface shut", () => { }) }) - test("? opens the shortcut list and closes it", async () => { + test("? no longer opens the shortcut list (removed; use /help)", async () => { await withShell({ columns: 80, rows: 30 }, (shell, h) => { toggleShellFocus(shell) - h.pressKey("?") - expect(shell.overlayKind).toBe("help") - h.pressKey("?") expect(shell.overlayKind).toBeNull() }) diff --git a/src/tui-opentui/prompt-slash-exit.test.ts b/src/tui-opentui/prompt-slash-exit.test.ts index dd4ab6c3f..061561916 100644 --- a/src/tui-opentui/prompt-slash-exit.test.ts +++ b/src/tui-opentui/prompt-slash-exit.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from "bun:test" import { withTestRenderer } from "./harness" -import type { PaletteCommand } from "./palette" +import type { PaletteCommand } from "./command-catalog" import { CTRL_C_EXIT_WINDOW_MS, createAppShell, @@ -19,9 +19,9 @@ import { } from "./shell" const CATALOG: readonly PaletteCommand[] = [ - { id: "model", label: "/model — switch model", dispatch: "command" }, - { id: "mcp", label: "/mcp — manage MCP servers", dispatch: "command" }, - { id: "compact", label: "/compact — compact history", dispatch: "command" }, + { id: "model", label: "/model — switch model" }, + { id: "mcp", label: "/mcp — manage MCP servers" }, + { id: "compact", label: "/compact — compact history" }, ] type Ctx = { diff --git a/src/tui-opentui/runner-host.ts b/src/tui-opentui/runner-host.ts index 78ee83f9b..d1ff977cf 100644 --- a/src/tui-opentui/runner-host.ts +++ b/src/tui-opentui/runner-host.ts @@ -11,7 +11,7 @@ import type { EventEmitter } from "node:events" import type { CliRenderer, KeyEvent } from "@opentui/core" import type { SubAgentSession, SubAgentTranscriptEntry } from "../subagent/session-store.js" -import { buildCommandCatalog, type RegistryCommandSource } from "./command-catalog.js" +import { commandItemsFromRegistry, type RegistryCommandSource } from "./command-catalog.js" import { openCommandSurface, type CommandSurfaceDeps, @@ -263,7 +263,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise }, onModelSelect, describeModel, - commands: buildCommandCatalog(deps.commands), + commands: commandItemsFromRegistry(deps.commands), onCommand: deps.onCommand, chrome: chromeFromSession(deps.chrome()), onObserveRequest: () => observeSessionFromSubAgents(deps.subAgentSessions()), diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 4c9e1a8d4..9e87951c9 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -108,12 +108,14 @@ import { createLandingAbove, createLandingBelow, fitLandingMark, + LANDING_VERSION, landingBelowContent, landingSuggestionFor, paintLandingBelow, paintLandingMark, resolveMarkGrid, splitLandingRows, + versionBadgeVisible, type LandingAbove, type LandingBelowContent, } from "./landing.js" @@ -128,17 +130,12 @@ import { } from "./list-viewport.js" import { retentionOverflow } from "./long-log.js" import { - DEFAULT_PALETTE_COMMANDS, filterPaletteCommands, formatPaletteRows, - isResidualActionId, - paletteDispatchOf, paletteLabels, - paletteRowColumns, - type PaletteActionId, type PaletteCommand, -} from "./palette.js" -import { SHELL_SHORTCUTS, shortcutForPaletteId } from "./keybindings.js" +} from "./command-catalog.js" +import { SHELL_SHORTCUTS } from "./keybindings.js" import { destroySubtree } from "./teardown.js" import { filterMentionSuggestions, @@ -551,6 +548,13 @@ export type AppShell = { 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. @@ -761,6 +765,28 @@ function terminalOf( } } +/** + * The version row is real chrome, not a float — it holds its own reserved + * row at the foot of the shell rather than overlaying content that already + * fills every row (there is no other spare one; `BOTTOM_MARGIN_ROWS` is 0). + * + * 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, @@ -1308,10 +1334,10 @@ function overlayInteriorWidth(shell: AppShell): number { */ function paintPaletteList(shell: AppShell, list: ListViewportState): void { const interior = overlayInteriorWidth(shell) - const columns = shell.paletteCommands.map((cmd) => - paletteRowColumns(cmd, shortcutForPaletteId), + const lines = formatPaletteRows( + shell.paletteCommands.map((cmd) => cmd.label), + Math.max(4, interior - 1), ) - const lines = formatPaletteRows(columns, Math.max(4, interior - 1)) const slice = visibleSlice(list) for (let i = slice.start; i < slice.end; i++) { const line = lines[i] ?? "" @@ -1630,6 +1656,13 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { 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 @@ -1861,8 +1894,8 @@ type ShellInternals = { /** Fired once the shell has no overlay open, so queued gates can re-open. */ overlayClosedListeners: Set<() => void> /** - * Default palette catalog (static or lazy). Used when openPalette omits catalog. - * Residual DEFAULT_PALETTE_COMMANDS when unset. + * Registry-backed `/` command catalog (static or lazy), host-injected. Empty + * when unset. */ paletteCatalog: | readonly PaletteCommand[] @@ -1984,8 +2017,14 @@ export function relayout(shell: AppShell, opts?: RelayoutOpts): GeometryLayout { 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: terminalOf(shell.renderer, { columns, rows }), + terminal: versionReserved ? terminalForGeometry(terminal) : terminal, visibility, overlay: overlayMode === "closed" @@ -3406,17 +3445,17 @@ export function openInsetOverlay( }) } -/** Resolve the shell's default palette catalog (injected or residual-only). */ +/** 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 DEFAULT_PALETTE_COMMANDS + if (raw === null || raw === undefined) return [] return typeof raw === "function" ? raw() : raw } /** - * Replace the shell default palette catalog (host rebinds after registry load). - * Pass null to restore residual-only DEFAULT_PALETTE_COMMANDS. + * Replace the shell's `/` command catalog (host rebinds after registry load). + * Pass null to clear it. */ export function setPaletteCatalog( shell: AppShell, @@ -3430,9 +3469,8 @@ export function setPaletteCatalog( } /** - * Open Amp-class command palette (Ctrl+O). - * Chord reclaimed from tool-expand — document in interaction contract. - * Catalog: opts.catalog → shell default (injected registry build) → residuals. + * Open the `/` command list overlay. Catalog: opts.catalog when given, else + * the shell's registry-backed default (see `resolvePaletteCatalog`). */ export function openPalette( shell: AppShell, @@ -3450,8 +3488,8 @@ export function openPalette( bag.paletteFilter = { query: opts?.query ?? "", title, - // Slash and other callers pass a pre-narrowed catalog; a bare Ctrl+O open - // re-resolves the shell default so a registry loaded later is picked up. + // `/` 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, @@ -3625,15 +3663,9 @@ export function handleOverlayAnswerKey( * pressing them again inserts them rather than closing the popup. */ function toggledSurfaceFor(key: KeyEvent): PrimaryOverlayKind | null { - if (key.ctrl && !key.meta && !key.option && (key.name === "o" || key.name === "O")) { - return "palette" - } if ((key.meta || key.option) && !key.ctrl && (key.name === "c" || key.name === "C")) { return "copy" } - if (!key.ctrl && !key.meta && !key.option && key.sequence === "?") { - return "help" - } return null } @@ -4025,146 +4057,24 @@ function overlayKindWord(kind: PrimaryOverlayKind): string { } /** - * Dispatch a selected palette item after the palette has closed. - * - residual → `runPaletteAction` (overlays / chrome) - * - command → injectable `onCommand(name)` (registry slash path) + * 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 dispatch = paletteDispatchOf(cmd) - if (dispatch === "command") { - const onCommand = getPaletteOnCommand(shell) - if (onCommand) { - onCommand(cmd.id) - return - } - appendStreamRow(shell, { - role: "system", - text: `palette: /${cmd.id} (no onCommand handler)`, - }) - return - } - if (isResidualActionId(cmd.id)) { - runPaletteAction(shell, cmd.id) + const onCommand = getPaletteOnCommand(shell) + if (onCommand) { + onCommand(cmd.id) return } appendStreamRow(shell, { role: "system", - text: `palette: unknown residual ${cmd.id}`, + text: `palette: /${cmd.id} (no onCommand handler)`, }) } -/** Run a residual palette action after the palette has closed. */ -export function runPaletteAction( - shell: AppShell, - id: PaletteActionId, -): void { - switch (id) { - case "permissions": { - // Lazy import surface — open via openListOverlay to avoid overlays circular init. - openListOverlay(shell, { - kind: "permissions", - title: "permissions", - items: [ - "Allow once", - "Allow session", - "Always allow this tool", - "Deny", - ...Array.from({ length: 26 }, (_, i) => `Allow tool call #${i + 2}`), - ], - frameId: "permissions", - }) - return - } - case "operator": { - openListOverlay(shell, { - kind: "operator", - title: "operator", - body: - "The agent wants to run a destructive command that may modify your working tree.\n\nProceed with git reset --hard HEAD~1?", - items: [ - "Cancel", - "Allow this once", - "Allow for session", - "Deny and tell agent", - "Open diff first", - "Always ask", - "Skip remaining questions", - "Abort run", - ], - frameId: "operator-question", - }) - return - } - case "model_picker": { - openListOverlay(shell, { - kind: "model_picker", - title: "model / provider", - items: [ - "anthropic / claude-sonnet-4", - "anthropic / claude-opus-4", - "openai / gpt-4.1", - "openai / o3", - "google / gemini-2.5-pro", - "local / ollama", - ], - frameId: "model-picker", - }) - return - } - case "toggle_task": { - toggleTasksPanel(shell) - return - } - case "toggle_agents": { - const bag = internals.get(shell) - const on = (bag?.chrome.agents.length ?? 0) > 0 - setChromeZones(shell, { - agents: on - ? null - : [{ label: "explore: map callers", tail: "", stalled: false }], - }) - appendStreamRow(shell, { - role: "system", - text: on ? "agents strip off" : "agents strip on", - meta: "agents", - }) - return - } - case "copy_active": { - enterCopyMode(shell) - return - } - case "toggle_mouse": { - toggleMouseCapture(shell) - return - } - case "help": { - openHelpOverlay(shell) - return - } - case "mentions": { - void openAtMentionSuggestions(shell) - return - } - case "observe": { - const onObserveRequest = getPaletteOnObserveRequest(shell) - const session = onObserveRequest ? onObserveRequest() : null - if (session) enterSubagentObserve(shell, session) - else { - appendStreamRow(shell, { - role: "system", - text: "no subagent session to observe", - meta: "observe", - }) - } - return - } - } -} - export type ChromeZoneContent = { /** One row per task-panel line. Null/empty = hide the zone. */ readonly task?: readonly TaskPanelRow[] | null @@ -4565,6 +4475,27 @@ export function leaveSubagentObserve(shell: AppShell): void { 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 @@ -4797,13 +4728,6 @@ function slashPopupQuery(shell: AppShell): string | null { return /\s/.test(head) ? null : head } -/** Registry-backed slash entries only — residual openers stay on Ctrl+O. */ -function slashCatalog(shell: AppShell): readonly PaletteCommand[] { - return resolvePaletteCatalog(shell).filter( - (cmd) => paletteDispatchOf(cmd) === "command", - ) -} - export function closeSlashPopup(shell: AppShell): void { if (!slashPopups.has(shell)) return slashPopups.delete(shell) @@ -4824,7 +4748,7 @@ export function openSlashCommands(shell: AppShell): boolean { // 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 = slashCatalog(shell).filter((cmd) => + const matches = resolvePaletteCatalog(shell).filter((cmd) => cmd.id.toLowerCase().startsWith(q), ) if (matches.length === 0) { @@ -4986,7 +4910,7 @@ export function createAppShell( const terminal = terminalOf(renderer, options?.terminal) const layout = resolveGeometry({ - terminal, + terminal: terminalForGeometry(terminal), visibility, overlay: { mode: "closed" }, promptContentRows, @@ -5023,6 +4947,32 @@ export function createAppShell( 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", @@ -5183,6 +5133,7 @@ export function createAppShell( root.add(promptBox) root.add(landingBelow) root.add(bottomPad) + root.add(versionRow) if (mount) { renderer.root.add(root) @@ -5572,38 +5523,34 @@ export function createAppShell( } } - // Bare key, so it is live only while the transcript holds focus and can - // never shadow a `?` typed into the prompt. - if ( - !key.ctrl && - !key.meta && - !key.option && - key.sequence === "?" && - focusOwner(shell.focus) === "transcript" - ) { + if ((key.meta || key.option) && (key.name === "c" || key.name === "C") && !key.ctrl) { + // Alt+C: keyboard copy path (no mouse drag-select). key.preventDefault() - openHelpOverlay(shell) + enterCopyMode(shell) return } - if (key.ctrl && (key.name === "o" || key.name === "O")) { - // Ctrl+O reclaimed from tool-expand → command palette (Wave 6). + 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() - openPalette(shell, { typeToFilter: true }) + toggleMouseCapture(shell) return } - if ((key.meta || key.option) && (key.name === "c" || key.name === "C") && !key.ctrl) { - // Alt+C: keyboard copy path (no mouse drag-select). + 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() - enterCopyMode(shell) + toggleTasksPanel(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. + 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() - toggleMouseCapture(shell) + observeActiveSubagent(shell) return } @@ -5680,6 +5627,7 @@ export function createAppShell( root, topPad, bottomPad, + versionRow, taskBox, agentsBox, transcript, diff --git a/src/tui-opentui/wave6.test.ts b/src/tui-opentui/wave6.test.ts index 848d6dcd9..29829616b 100644 --- a/src/tui-opentui/wave6.test.ts +++ b/src/tui-opentui/wave6.test.ts @@ -19,7 +19,6 @@ import { openInsetOverlay, openPalette, replaceStreamRowAt, - runPaletteAction, setChromeZones, streamRowAt, streamRowCount, @@ -27,8 +26,15 @@ import { } from "./shell" import { createRecordingClipboard } from "./copy-path" import { stringWidth } from "../tui/view/height" +import type { PaletteCommand } from "./command-catalog" -describe("Wave 6: command palette", () => { +const CATALOG: readonly PaletteCommand[] = [ + { id: "compact", label: "/compact — compact history" }, + { id: "help", label: "/help — show keymap help" }, + { id: "model", label: "/model — switch model" }, +] + +describe("Wave 6: command list", () => { test("open → navigate → Esc restores prompt", async () => { await withTestRenderer( async (h) => { @@ -39,7 +45,7 @@ describe("Wave 6: command palette", () => { }) try { expect(focusOwner(shell.focus)).toBe("prompt") - openPalette(shell) + openPalette(shell, { catalog: CATALOG }) expect(shell.overlayKind).toBe("palette") expect(shell.overlayList).not.toBeNull() expect(shell.paletteCommands.length).toBeGreaterThan(0) @@ -50,15 +56,12 @@ describe("Wave 6: command palette", () => { await h.renderOnce() const frame = h.captureCharFrame() - // The palette drops its title rule row, so identify it on screen by + // The list drops its title rule row, so identify it on screen by // its filter prompt and first row rather than the word "palette". expect(frame).toMatch(/│\s*>\s*│/) - expect(frame).toContain("Open permissions") + expect(frame).toContain("/compact") // List labels live in overlayItems (frame may clip first row under tight height). - expect(shell.overlayItems[0]).toBe("Open permissions") - expect(shell.overlayItems.some((l) => l.includes("permissions"))).toBe( - true, - ) + expect(shell.overlayItems[0]).toBe(CATALOG[0]!.label) moveOverlaySelection(shell, 1) expect(shell.overlayList!.activeIndex).toBe(1) @@ -79,15 +82,17 @@ describe("Wave 6: command palette", () => { ) }) - test("accept action (help) opens help overlay", async () => { + test("accept action dispatches through onCommand", async () => { await withTestRenderer( async (h) => { + const dispatched: string[] = [] const shell = createAppShell(h.renderer, { terminal: { columns: 80, rows: 24 }, wireKeys: false, + onCommand: (name) => dispatched.push(name), }) try { - openPalette(shell) + openPalette(shell, { catalog: CATALOG }) const helpIdx = shell.paletteCommands.findIndex((c) => c.id === "help") expect(helpIdx).toBeGreaterThanOrEqual(0) for (let i = 0; i < helpIdx; i++) moveOverlaySelection(shell, 1) @@ -96,11 +101,7 @@ describe("Wave 6: command palette", () => { ) acceptOverlaySelection(shell) - // Help is a residual list surface — palette closes, help opens. - expect(shell.overlayKind).toBe("help") - expect(shell.overlayList).not.toBeNull() - expect(focusOwner(shell.focus)).toBe("overlay") - closeInsetOverlay(shell) + expect(dispatched).toEqual(["help"]) expect(shell.overlayList).toBeNull() expect(focusOwner(shell.focus)).toBe("prompt") } finally { @@ -111,7 +112,7 @@ describe("Wave 6: command palette", () => { ) }) - test("palette stacks over permissions; Esc restores permissions then prompt", async () => { + test("list stacks over permissions; Esc restores permissions then prompt", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -125,7 +126,7 @@ describe("Wave 6: command palette", () => { expect(shell.overlayKind).toBe("permissions") expect(focusOwner(shell.focus)).toBe("overlay") - openPalette(shell) + openPalette(shell, { catalog: CATALOG }) expect(shell.overlayKind).toBe("palette") expect(focusOwner(shell.focus)).toBe("palette") @@ -591,25 +592,6 @@ describe("CL-5731: task list panel", () => { ) }) - test("the palette 'toggle_task' action drives the same toggle", async () => { - await withTestRenderer( - async (h) => { - const shell = createAppShell(h.renderer, { - terminal: { columns: 80, rows: 24 }, - wireKeys: false, - }) - try { - setChromeZones(shell, { task: [{ label: "a", status: "todo" }] }) - expect(shell.taskBox.visible).toBe(true) - runPaletteAction(shell, "toggle_task") - expect(shell.taskBox.visible).toBe(false) - } finally { - shell.dispose() - } - }, - { width: 80, height: 24 }, - ) - }) }) describe("Wave 6: keyboard copy path", () => { diff --git a/src/tui-opentui/width-columns.test.ts b/src/tui-opentui/width-columns.test.ts index e06d4f515..eb6e5ec92 100644 --- a/src/tui-opentui/width-columns.test.ts +++ b/src/tui-opentui/width-columns.test.ts @@ -21,7 +21,7 @@ import { renderDiff } from "./diff.js" import { wrapLanding } from "./landing.js" import { lockupWidth } from "./lockup.js" import type { RampPhase } from "./ramp.js" -import { formatPaletteRows } from "./palette.js" +import { formatPaletteRows } from "./command-catalog.js" import { composeDecisionBody, decisionChoiceRows, wrapWords } from "./overlay-body.js" import { thinkingScrollLine, thinkingSettledLine } from "./thinking.js" @@ -172,21 +172,12 @@ describe("thinking rows", () => { describe("palette rows", () => { test("rows are exactly `width` columns wide with CJK labels", () => { - const rows = formatPaletteRows( - [ - { label: CJK, shortcut: "ctrl+o" }, - { label: "resume", shortcut: "?" }, - ], - 48, - ) + const rows = formatPaletteRows([CJK, "resume"], 48) for (const row of rows) expect(stringWidth(row)).toBe(48) }) test("a too-wide label is cut to columns", () => { - const [row] = formatPaletteRows( - [{ label: `${CJK}${CJK}`, shortcut: "" }], - 12, - ) + const [row] = formatPaletteRows([`${CJK}${CJK}`], 12) expect(stringWidth(row ?? "")).toBe(12) }) }) diff --git a/src/tui/tool-formatter.ts b/src/tui/tool-formatter.ts index 102ab3d4b..16a3fd40f 100644 --- a/src/tui/tool-formatter.ts +++ b/src/tui/tool-formatter.ts @@ -136,7 +136,7 @@ export function describeToolCall(toolName: string, rawArgs: string): ToolCallDes agentName !== undefined && agentName.length > 0 ? agentName[0]!.toUpperCase() + agentName.slice(1) : "Task"; - // Collapsed row uses the abbreviated description; Ctrl+O uses the full text. + // Collapsed row uses the abbreviated description; Alt+E expands to the full text. return { display, role: "accent", @@ -214,7 +214,7 @@ function abbreviate(value: string, max: number): string { /** * Render tool arguments as a human-readable "key: value" line rather than raw - * JSON. The full form keeps every pair on its own line for the Ctrl+O reveal. + * JSON. The full form keeps every pair on its own line for the Alt+E reveal. */ export function summarizeToolArgs(toolName: string, rawArgs: string): ToolArgSummary { const obj = tryParseObject(rawArgs); @@ -234,7 +234,7 @@ export function summarizeToolArgs(toolName: string, rawArgs: string): ToolArgSum } case "task": { // Spawns carry a large structured brief (prompt, intent, criteria). The - // transcript only needs the short description; Ctrl+O still shows the + // transcript only needs the short description; Alt+E still shows the // full description text, not every spawn field. const parsed = TaskArgSchema(obj); if (!(parsed instanceof type.errors)) { @@ -505,7 +505,7 @@ function webFetchSummary(raw: string): ToolResultSummary | null { /** * Collapse a tool result to a single human-readable preview line. The raw - * content is preserved in `full` for the Ctrl+O reveal. `isJSONDocument` is + * content is preserved in `full` for the Alt+E reveal. `isJSONDocument` is * true ONLY when the content is genuinely a JSON document the user would want * to read as JSON — never for tool envelopes or status strings. */