diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 1387030ee..d59ccc023 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -41,7 +41,7 @@ The evidence is in how the product fails today: the personas already produce exc 5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption. 6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why. 7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI). Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not TTYs. TTY exec still reads a single line from stdin. -8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` is parent-busy, so Enter is a queued steer, not a new turn. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop). +8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary Skywalker turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` or an awaiting `wait_agents` is parent-busy, so Enter is a queued steer, not a new turn. An in-flight TUI-primary `wait_agents` yields as a timeout when that steer is queued so occupancy can deliver it. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges, and held messages list in a pending column stacked on the prompt box — `↑`/`↓` select, `Enter` force-pushes one now, `Ctrl+X` drops it — instead of echoing labelled transcript rows; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop). 9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators collect through mailbox mail the same way. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored. ## User Experience diff --git a/docs/TUI.md b/docs/TUI.md index 5c6969cb9..27a04d152 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -628,27 +628,51 @@ Two mid-run gestures, two delivery times (CL-6290): - **Enter, mid-run** — soft steer: enqueues kind `"steer"` and delivers at the next **parent** `tool.boundary` (the parent tool finishing, not a child) via `Agent.deliver` into the live reactor, not a new `send`. A - long parent `run_shell` is parent-busy and holds steers. A queued steer - delivers at the next parent `tool.boundary` so occupancy can pick it up. - The transcript row says - `[will steer next]` while pending and - `[steering]` once delivered (`submitPrompt`, `drainSteersAtBoundary` in - `runtime-bridge.ts`). If the captured target agent is already closed when - delivery runs, the bridge restores the exact message (and attachments) to an - empty prompt, or FIFO-defers behind a draft the operator already typed — it - never auto-sends to a rebuilt successor. The transcript row is corrected to + long parent `run_shell` or an awaiting `wait_agents` is parent-busy and holds + steers. An in-flight TUI-primary `wait_agents` yields as a timeout when a + steer is queued so occupancy can pick it up. A queued steer delivers at the + next parent `tool.boundary` so occupancy can pick it up. Pending items list + in the pending column above the prompt, not the transcript; the transcript + only ever sees the item that actually delivers, as an ordinary user row + (`submitPrompt`, `drainSteersAtBoundary` in `runtime-bridge.ts`). If the + captured target agent is already closed when delivery runs, the bridge + restores the exact message (and attachments) to an empty prompt, or + FIFO-defers behind a draft the operator already typed — it never + auto-sends to a rebuilt successor. The delivered row is corrected to `[not delivered]` (or `[delivery uncertain]` for non-closed failures), and another explicit Enter is required before any new logical delivery. - **Alt+Enter, mid-run** — follow-up: enqueues kind `"queue"` and delivers only on **session-idle** (parent-idle and no live fleet lanes) as a `send`. - Does not interrupt or reinject. The transcript row says `[will follow up]` - while pending and `[following up]` once delivered. Idle, or with an empty + Does not interrupt or reinject. Idle, or with an empty prompt, Alt+Enter does nothing — there is nothing to wait for. (Internal `"reinject"` remains in the submit API for tests; no product chord wires it.) Closed-target recovery for follow-ups uses the same prompt-restore / draft- defer ownership as soft steer; `/clear`, `/new`, and dispose discard both in-flight deliveries and deferred recoveries with the old session. +Held items never touch the transcript. Both kinds list in the **pending +column** — a transient zone stacked directly on the prompt box, one row per +item (`› steer …` / `› follow-up …`, `▸` on the selected row), oldest items +folding into a leading `+N more` past four shown rows so the newest items — +nearest the prompt, first selected — stay visible, and a guidance row naming +its keys (`pending-column.ts`, painted by `syncPendingRows` in `chrome.ts`). +The transcript only ever sees the item that actually delivers, as an ordinary +user row — pending/delivery labels (`[will steer next]`, `[steering]`, +`[following up]`) are gone on purpose. + +While the column has items, `↑` at the prompt buffer's top edge selects the +newest held item and `↑`/`↓` walk the rows; `↓` past the last row hands the +key back to the prompt. On a selected row, **Enter** kills the item out of the +queue and force-pushes it — `onForceDeliver` drops it on the same +`port.deliver` hop a drain uses, so a steer still injects when the parent +cycle is live and otherwise sends immediately. **Ctrl+X** drops the selected +item outright. **Ctrl+G** pops the selected item back into an empty prompt +for editing (dropping it mid-compose); with no selection it pops the newest +held item instead. **Esc** ends the selection; any other composer key ends it +and falls through to normal handling, except `↑`/`↓`, which stay with the +column. While an overlay is open, keys go to the overlay and leave the +selection alone. + When `steer > 0` and a parent tool has been in flight ≥ `STEER_WAIT_NOTICE_MS` (3s), the notice row adds `waiting on ` (e.g. `waiting on run_shell`). Follow-up-only does not; a sub-threshold in-flight tool does not. Delivery is @@ -691,7 +715,9 @@ session exit still call `subAgentSessions.cancelAll` for an explicit session-wide cancel; that path is separate from interrupt and must stay off the soft-steer / follow-up gestures. -Up/Down are caret motion first inside a multi-line buffer. History recall +Up/Down are caret motion first inside a multi-line buffer — except while the +pending column is engaged, when ↑ at the buffer's top edge selects a held item +instead (see "Soft steer vs. follow-up"). History recall only fires when the caret is already at the first or last wrapped row of the buffer — i.e., has nowhere further to go (`promptCaretAtFirstRow`/`promptCaretAtLastRow` in `prompt-input.ts`, @@ -800,8 +826,9 @@ list they move the active selection. Only the mouse wheel and the modal's own page keys (PgUp/PgDn) move a scroll position, and only the surface holding the current scroll lease responds to them. -`Ctrl+G` (the Emacs/readline "abort" chord) cancels the most recently queued -mid-run message. `Tab` toggles focus between the prompt and the transcript. +`Ctrl+G` (the Emacs/readline "abort" chord) pops the most recently queued +mid-run message back into an empty prompt for editing, or drops it mid-compose. +`Tab` toggles focus between the prompt and the transcript. `Shift+Tab` cycles reasoning effort for the current model (wrapping the supported ladder) and flashes the new level; the prompt-border effort segment updates immediately. A model with no effort levels flashes instead diff --git a/src/tui/geometry.test.ts b/src/tui/geometry.test.ts index 2a4d56144..39ba8cf7a 100644 --- a/src/tui/geometry.test.ts +++ b/src/tui/geometry.test.ts @@ -31,6 +31,7 @@ describe("zone registry", () => { "progress", "progress_divider", "notice", + "pending", "prompt", "task", "agents", diff --git a/src/tui/geometry/resolve.ts b/src/tui/geometry/resolve.ts index b6733c49f..69a2e1310 100644 --- a/src/tui/geometry/resolve.ts +++ b/src/tui/geometry/resolve.ts @@ -43,6 +43,8 @@ export interface OverlayInput { export interface ZoneVisibility { /** Transient notice row on (default off). */ readonly notice?: boolean; + /** Pending queue column: exact row count requested (bounded by the zone max). */ + readonly pending?: boolean | number; /** Progress: false/omit = 0; true = 2; or explicit 1|2. */ readonly progress?: boolean | 1 | 2; /** Progress divider (0–1). Default on when progress is shown. */ @@ -154,6 +156,7 @@ export function desiredHeights(input: GeometryInput): MutableHeights { progress: clamp(progressRows, 0, ZONE_REGISTRY.progress.max), progress_divider: progressDivider, notice: vis.notice === true ? 1 : ZONE_REGISTRY.notice.idleDefault, + pending: clamp(boolOrRows(vis.pending, 1), 0, ZONE_REGISTRY.pending.max), prompt: promptRows, task: clamp(boolOrRows(vis.task, 1), 0, ZONE_REGISTRY.task.max), // The board asks for exactly the rows it will paint; the fraction is what diff --git a/src/tui/geometry/zones.ts b/src/tui/geometry/zones.ts index de70250bc..6c624a6f7 100644 --- a/src/tui/geometry/zones.ts +++ b/src/tui/geometry/zones.ts @@ -7,6 +7,7 @@ export const ZONE_IDS = [ "progress", "progress_divider", "notice", + "pending", "prompt", "task", "agents", @@ -69,6 +70,13 @@ export const FLEET_FLOOR_MIN_LANES = 2; */ export const TASKS_PANEL_MAX_VISIBLE = 5; +/** + * Queued steer/follow-up rows the pending column lists before folding into a + * trailing "+N more" row. The column is a glance at what will send, not a + * full editor for the queue — a deep stack is rarer than the room it costs. + */ +export const PENDING_MAX_VISIBLE = 4; + /** * Fixed-with-test budgets from the constitution table. * Residual zones (transcript, overlay_host) use min/max as floor/cap hints; @@ -86,6 +94,16 @@ export const ZONE_REGISTRY: Readonly> = { // Transient: rows only while the shell has state worth a row (queue depth, // latched interrupt, a flash, a live turn). Idle it is off. notice: { id: "notice", min: 0, max: 1, idleDefault: 0, alwaysOn: false }, + // Queued steer/follow-up messages stacked directly on the prompt box — + // one row per shown item, a leading "+N more" fold plus a key-guidance + // row, bounded by the zone max. + pending: { + id: "pending", + min: 0, + max: PENDING_MAX_VISIBLE + 2, + idleDefault: 0, + alwaysOn: false, + }, // Grows with what is being composed; the resolver caps it at PROMPT_CAP_FRACTION // and collapses it back toward min when the transcript would breach its floor. prompt: { @@ -202,6 +220,9 @@ export const COLLAPSE_ORDER = [ "progress", "progress_divider", "notice", + // Pending items are the operator's own queued words: cut last of the + // optionals, just ahead of prompt growth reclaim. + "pending", // prompt growth reclaimed next (handled specially; never below PROMPT_BASE_ROWS) "prompt", ] as const satisfies readonly ZoneId[]; @@ -222,6 +243,7 @@ export const PAINT_ORDER = [ "progress", "progress_divider", "notice", + "pending", "prompt", ] as const satisfies readonly ZoneId[]; diff --git a/src/tui/gutter-labels.test.ts b/src/tui/gutter-labels.test.ts index f8a98c9e0..c27ccb7b0 100644 --- a/src/tui/gutter-labels.test.ts +++ b/src/tui/gutter-labels.test.ts @@ -35,14 +35,13 @@ const OVERLAY_KIND_GUTTER = { const CHROME_LITERALS = ["error", "plan", "report", "stop", "observe"] as const; +// Queued items no longer store a meta — pending state lives in the column +// and delivery paints a plain operator row, so steer/queue/steering/ +// following-up are gone from the closed set on purpose. Cancelled items are +// dropped outright instead of marked, so cancelled is gone too. const STORED_META_LITERALS = [ "thinking", - "steer", - "queue", - "steering", - "following-up", "reinject", - "cancelled", "not-delivered", "delivery-uncertain", ]; diff --git a/src/tui/keybindings.test.ts b/src/tui/keybindings.test.ts index c86f862da..9ef8f772a 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -41,6 +41,7 @@ import { setShellExitHandler, setEffortCycleHandler, clearShellBridgeHooks, + shellInternals, type AppShell, } from "./shell/internals.js"; import { leaveSubagentObserve } from "./shell/observe.js"; @@ -499,6 +500,61 @@ const PROBES: Readonly< setShellRunState(shell, "idle"); }, }, + "Up / Down / Enter / Ctrl+X": { + group: "session", + probe: ({ h, shell, chords }) => { + // Queue items first via the local path — an exclusive onSubmit hook + // owns enqueueing, so installing it earlier would swallow these. + clearShellBridgeHooks(shell); + setShellRunState(shell, "busy"); + shell.prompt.value = "a"; + submitPrompt(shell, "queue"); + shell.prompt.value = "b"; + submitPrompt(shell, "queue"); + expect(shell.pendingQueue).toBe(2); + const pushed: string[] = []; + setShellBridgeHooks(shell, { + onSubmit: () => undefined, + onInterrupt: () => undefined, + onForceDeliver: (id) => { + pushed.push(id); + shell.session = { + ...shell.session, + items: shell.session.items.filter((i) => i.id !== id), + }; + }, + exclusive: true, + }); + + // ↑ at the buffer's top edge selects the newest held item; ↑ walks up. + press(h, chords[0]); + expect(shellInternals(shell)?.pendingSelId).toBe( + shell.session.items[1]?.id, + ); + press(h, chords[0]); + expect(shellInternals(shell)?.pendingSelId).toBe( + shell.session.items[0]?.id, + ); + // ↓ past the last row hands the key back to the prompt. + press(h, chords[1]); + press(h, chords[1]); + expect(shellInternals(shell)?.pendingSelId).toBeNull(); + // ^X drops the selected item outright. + press(h, chords[0]); + press(h, chords[3]); + expect(shell.session.items.map((i) => i.text)).toEqual(["a"]); + // Enter force-pushes the selected item through the bridge. + press(h, chords[0]); + press(h, chords[2]); + expect(pushed).toHaveLength(1); + expect(shell.pendingQueue).toBe(0); + + // Shared-shell convention: leave the queue and hooks as found. + shell.session = { ...shell.session, items: [] }; + clearShellBridgeHooks(shell); + setShellRunState(shell, "idle"); + }, + }, "Ctrl+C": { group: "session", probe: ({ h, shell, chords }) => { @@ -569,18 +625,18 @@ const PROBES: Readonly< expect(shell.pendingQueue).toBe(1); expect(defined(shell.session.items[0]).text).toBe("keep"); - const rows = shell.streamLog.map((row) => row.meta); - // The retracted message's row is rewritten, not left claiming "queue" - // as though it will still dispatch (the bug that got the first attempt - // at this pulled). - expect(rows).toEqual(["queue", "cancelled"]); + // Queued items live in the pending column, not the transcript — the + // retracted one comes back into the prompt as an editable draft. + expect(shell.streamLog).toHaveLength(0); + expect(shell.prompt.value).toBe("drop me"); // The chord's whole job is what lands on screen, not the model alone — // assert on the rendered frame, not just streamLog. await h.renderOnce(); const frame = h.captureCharFrame(); - expect(frame).toContain("[cancelled] drop me"); + expect(frame).toContain("drop me"); expect(frame).toContain("keep"); + expect(frame).not.toContain("cancelled"); setShellRunState(shell, "idle"); }, diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index 3366a8161..9a8f722d3 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -23,13 +23,18 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ { keys: "Enter", description: - "soft-steer at the next tool boundary while busy (badge); send straight through when idle", + "soft-steer at the next tool boundary while busy (held above the prompt); send straight through when idle", }, { keys: "Alt+Enter", description: "queue a follow-up delivered only when the run goes idle; does nothing unless a run is busy", }, + { + keys: "Up / Down / Enter / Ctrl+X", + description: + "on held items above the prompt: select, send now, drop (Esc backs out)", + }, { keys: "Ctrl+C", description: @@ -38,7 +43,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ { keys: "Ctrl+G", description: - "cancel the most recently queued or steered message before it dispatches", + "pop the most recently queued or steered message back into the prompt for editing", }, { keys: "Alt+C", diff --git a/src/tui/notice-line.test.ts b/src/tui/notice-line.test.ts index 4e53429e2..9fd5d1893 100644 --- a/src/tui/notice-line.test.ts +++ b/src/tui/notice-line.test.ts @@ -7,8 +7,6 @@ import { } from "./notice-line"; const state = (over: Partial = {}): NoticeState => ({ - steer: 0, - followUp: 0, waitingOn: null, interrupt: false, pinned: false, @@ -23,43 +21,29 @@ describe("composeNoticeLine", () => { }); test("default state segments stay off the row", () => { - const line = composeNoticeLine( - state({ steer: 0, followUp: 0, pinned: false }), - ); + const line = composeNoticeLine(state({ pinned: false })); expect(line).not.toContain("steer"); expect(line).not.toContain("follow-up"); expect(line).not.toContain("queue"); expect(line).not.toContain("pinned"); }); - test("steer and follow-up are distinct segments", () => { + test("pending counts are not segments — the column lists the items", () => { const line = composeNoticeLine( - state({ - steer: 2, - followUp: 1, - pinned: true, - interrupt: true, - attachments: 1, - }), + state({ pinned: true, interrupt: true, attachments: 1 }), ); - expect(line).toContain("steer 2"); - expect(line).toContain("follow-up 1"); - expect(line).not.toContain("queue 2"); expect(line).toContain("pinned"); expect(line).not.toContain("interrupt"); expect(line).toContain("1 image"); + expect(line).not.toContain("steer"); + expect(line).not.toContain("follow-up"); }); - test("waitingOn + steer names the in-flight command", () => { - const line = composeNoticeLine(state({ steer: 1, waitingOn: "run_shell" })); + test("waitingOn names the in-flight command", () => { + const line = composeNoticeLine(state({ waitingOn: "run_shell" })); expect(line).toContain("waiting on run_shell"); }); - test("follow-up only does not wait on a tool", () => { - const line = composeNoticeLine(state({ followUp: 1, waitingOn: null })); - expect(line).not.toContain("waiting on"); - }); - test("a flash is carried verbatim so paths keep their case", () => { expect(composeNoticeLine(state({ flash: "attached Screenshot.png" }))).toBe( "attached Screenshot.png", @@ -67,7 +51,7 @@ describe("composeNoticeLine", () => { }); test("no keys strip survives anywhere in the composition", () => { - const line = composeNoticeLine(state({ followUp: 1, interrupt: true })); + const line = composeNoticeLine(state({ attachments: 1, interrupt: true })); expect(line).not.toContain("commands"); expect(line).not.toContain("files"); expect(line).not.toContain("^C"); diff --git a/src/tui/notice-line.ts b/src/tui/notice-line.ts index 30b8551ca..3ba3cb127 100644 --- a/src/tui/notice-line.ts +++ b/src/tui/notice-line.ts @@ -4,7 +4,8 @@ * There is no permanent status strip: keys are discoverable from the landing * screen and the command palette, and the prompt box's border already carries * the model and the workspace. What is left is state that is only sometimes - * true — a queued message, a copy result, pinned scroll, attachments — + * true — a steer waiting on a tool, a copy result, pinned scroll, + * attachments — * and that gets a row only while it has something to say. When every segment * is at its default the row composes to the empty string and the shell hides * it, giving the row back to the transcript. @@ -27,10 +28,6 @@ const SEP = " "; export const STEER_WAIT_NOTICE_MS = 3_000; export interface NoticeState { - /** Soft-steer pending (Enter mid-run → drain at tool.boundary). */ - readonly steer: number; - /** Follow-up pending (Alt+Enter mid-run → drain only when idle). */ - readonly followUp: number; /** * Parent tool name to surface after `STEER_WAIT_NOTICE_MS`, or null. * Gated by `resolveWaitingOn`; this field only controls wording. @@ -61,8 +58,8 @@ export function resolveWaitingOn( export function composeNoticeLine(state: NoticeState): string { const segments: string[] = []; - if (state.steer > 0) segments.push(`steer ${state.steer}`); - if (state.followUp > 0) segments.push(`follow-up ${state.followUp}`); + // Pending counts earned their own surface: the column stacked on the prompt + // box lists the items themselves, so the row no longer says "steer 2". if (state.waitingOn) segments.push(`waiting on ${state.waitingOn}`); if (state.pinned) segments.push("pinned"); // "interrupt" is not a standing notice. Mid-run stop feedback is a system diff --git a/src/tui/pending-column.test.ts b/src/tui/pending-column.test.ts new file mode 100644 index 000000000..751c65cf5 --- /dev/null +++ b/src/tui/pending-column.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { + fitPendingRow, + pendingColumnHeight, + pendingColumnRows, + pendingWindowStart, +} from "./pending-column"; +import { PENDING_MAX_VISIBLE } from "./geometry/zones"; +import type { QueueItem } from "./session-queue"; + +let seq = 0; +function item(text: string, kind: QueueItem["kind"] = "queue"): QueueItem { + seq += 1; + return { id: `q${seq}`, text, kind, enqueuedAt: seq }; +} + +describe("pendingColumnRows", () => { + test("one row per item in enqueue order, tags by kind", () => { + const rows = pendingColumnRows([ + item("hold a", "steer"), + item("hold b", "queue"), + ]); + expect(rows.map((r) => [r.tag, r.text])).toEqual([ + ["steer", "hold a"], + ["follow-up", "hold b"], + ]); + expect(rows.map((r) => r.id)).toEqual(["q1", "q2"]); + }); + + test("deep queue folds the oldest into a leading +N more", () => { + const items = Array.from({ length: 7 }, (_, i) => item(`m${i}`)); + const rows = pendingColumnRows(items, PENDING_MAX_VISIBLE + 1); + expect(rows[0]).toEqual({ id: null, tag: null, text: "+3 more" }); + // The newest items stay on screen — they are the rows selection enters on. + expect(rows.slice(1).map((r) => r.text)).toEqual(["m3", "m4", "m5", "m6"]); + }); + + test("message text flattens to one line and folds image count in", () => { + const rows = pendingColumnRows([ + { + ...item("line one\nline two"), + attachments: [ + { + id: "img-a", + name: "a.png", + contentType: "image/png", + data: new Uint8Array([137, 80, 78, 71]), + contentHash: "hash-a", + }, + { + id: "img-b", + name: "b.png", + contentType: "image/png", + data: new Uint8Array([137, 80, 78, 71]), + contentHash: "hash-b", + }, + ], + }, + ]); + expect(rows[0]?.text).toBe("line one line two · +2 images"); + }); +}); + +describe("pendingWindowStart", () => { + test("everything shows under the budget; overflow hides the oldest", () => { + expect(pendingWindowStart(4, 5)).toBe(0); + expect(pendingWindowStart(5, 5)).toBe(0); + expect(pendingWindowStart(6, 5)).toBe(2); + expect(pendingWindowStart(10, 5)).toBe(6); + }); + + test("a zero grant folds every item into the header", () => { + expect(pendingWindowStart(3, 0)).toBe(3); + }); +}); + +describe("pendingColumnHeight", () => { + test("empty costs zero rows; items cost themselves plus the hint row", () => { + expect(pendingColumnHeight(0)).toBe(0); + expect(pendingColumnHeight(1)).toBe(2); + expect(pendingColumnHeight(99)).toBe(PENDING_MAX_VISIBLE + 2); + }); +}); + +describe("fitPendingRow", () => { + test("tag rows keep the tag and slice the text to fit", () => { + const fitted = fitPendingRow( + { id: "q1", tag: "steer", text: "a very long held message" }, + 24, + ); + expect(fitted.head).toBe(" › steer "); + expect(`${fitted.head}${fitted.text}`).toHaveLength(24); + }); + + test("fold header has no tag column", () => { + const fitted = fitPendingRow({ id: null, tag: null, text: "+2 more" }, 40); + expect(fitted.head).toBe(""); + expect(fitted.text.trim()).toBe("+2 more"); + }); + + test("the selected row swaps its marker so it reads without colour", () => { + const row = { id: "q1", tag: "steer" as const, text: "m" }; + expect(fitPendingRow(row, 40).head).toContain("›"); + expect(fitPendingRow(row, 40, true).head).toContain("▸"); + }); +}); diff --git a/src/tui/pending-column.ts b/src/tui/pending-column.ts new file mode 100644 index 000000000..13e2b0e23 --- /dev/null +++ b/src/tui/pending-column.ts @@ -0,0 +1,130 @@ +/** + * The pending column: queued steer/follow-up messages stacked directly above + * the prompt box, one dim row per item. + * + * Queued input used to echo into the transcript twice — once tagged + * "[will steer next]" at enqueue, again tagged "[steering]" at delivery — + * which made a held-back message louder than a sent one. The column keeps the + * same fact on screen (what is waiting, in what order) without spending + * transcript rows on it: items sit here while pending and land in the + * transcript as ordinary user rows only when they actually deliver. + * + * Pure: session-queue items in, row models out. Paint lives in + * shell/chrome.ts (`syncPendingRows`); the row budget lives in geometry + * (zone `pending`). + */ + +import { PENDING_MAX_VISIBLE } from "./geometry/zones.js"; +import { sliceToWidth, stringWidth } from "./view/height.js"; +import type { QueueItem } from "./session-queue.js"; + +/** Marker opening every pending row — a pointer at the prompt it sits on. */ +const ROW_MARK = "›"; + +/** Selected row's marker — a shape change, not only a colour, so the active + * row reads on terminals that paint every fg the same. */ +const SELECTED_MARK = "▸"; + +/** Widest kind tag; pads shorter ones so message text opens on one column. */ +const TAG_WIDTH = "follow-up".length; + +export interface PendingColumnRow { + /** Queue item id so selection can name a row; null on the "+N more" header. */ + readonly id: string | null; + /** Kind tag for a queued item; null on the "+N more" header. */ + readonly tag: "steer" | "follow-up" | null; + /** Single-line message text (whitespace-squashed), attachment count folded in. */ + readonly text: string; +} + +/** Key guidance painted as the column's last row while items are pending. */ +export const PENDING_COLUMN_HINT = + "↑↓ select · Enter send now · ^X drop · esc back"; + +/** Item text flattened onto one row, with its image count folded in. */ +function pendingText(item: QueueItem): string { + const label = item.text.replace(/\s+/g, " ").trim(); + const images = item.attachments?.length ?? 0; + const suffix = images > 0 ? `+${images} image${images === 1 ? "" : "s"}` : ""; + if (label.length === 0) return suffix; + return suffix.length > 0 ? `${label} · ${suffix}` : label; +} + +/** + * Index of the oldest item the column shows at a given item-row budget. A deep + * queue keeps the *newest* items visible — the newest is the row ↑ selects + * first and the one the operator most likely typed a beat ago — folding the + * older ones into a "+N more" header. Nav clamps against this floor so the + * selection can never point at a folded item. + */ +export function pendingWindowStart( + itemCount: number, + itemRows: number, +): number { + const limit = Math.max(0, Math.floor(itemRows)); + if (itemCount <= limit) return 0; + return Math.min(itemCount, Math.max(0, itemCount - limit + 1)); +} + +/** + * Rows the column paints: one per shown item in enqueue order, a deep queue + * folding its oldest items into a leading "+N more" (see + * `pendingWindowStart`). `maxRows` is the row budget geometry granted; the + * default is the zone's own ceiling, so a partial grant (collapse under + * pressure) still reserves a row for the fold instead of dropping items + * silently. + */ +export function pendingColumnRows( + items: readonly QueueItem[], + maxRows: number = PENDING_MAX_VISIBLE + 1, +): readonly PendingColumnRow[] { + const limit = Math.max(0, Math.floor(maxRows)); + const start = pendingWindowStart(items.length, limit); + const rows: PendingColumnRow[] = []; + const hidden = start; + if (hidden > 0) rows.push({ id: null, tag: null, text: `+${hidden} more` }); + for (const item of items.slice(start)) { + rows.push({ + id: item.id, + tag: item.kind === "steer" ? "steer" : "follow-up", + text: pendingText(item), + }); + } + return rows; +} + +/** + * Rows the zone asks geometry for — the items (folded at the ceiling) plus + * one guidance row while anything is pending. + */ +export function pendingColumnHeight(itemCount: number): number { + if (itemCount === 0) return 0; + return Math.min(itemCount, PENDING_MAX_VISIBLE + 1) + 1; +} + +/** Segments of one painted line, split so the tag can sit back from the text. */ +export interface FittedPendingRow { + readonly head: string; + readonly text: string; +} + +/** + * Fit one row to the zone's column budget. A pending message is a glance, not + * a document — a long one loses its tail to the slice rather than wrapping the + * column, and the kind tag is never what gives way. + */ +export function fitPendingRow( + row: PendingColumnRow, + maxWidth: number, + selected = false, +): FittedPendingRow { + if (row.tag === null) { + return { head: "", text: sliceToWidth(` ${row.text}`, maxWidth) }; + } + const mark = selected ? SELECTED_MARK : ROW_MARK; + const head = ` ${mark} ${row.tag.padEnd(TAG_WIDTH)} `; + return { + head, + text: sliceToWidth(row.text, Math.max(0, maxWidth - stringWidth(head))), + }; +} diff --git a/src/tui/queued-delivery-hop.test.ts b/src/tui/queued-delivery-hop.test.ts index 0bc728476..3b0af62c3 100644 --- a/src/tui/queued-delivery-hop.test.ts +++ b/src/tui/queued-delivery-hop.test.ts @@ -491,7 +491,14 @@ describe("closed-target recovery", () => { expect(calls).toHaveLength(1); expect(shell.prompt.value).toBe(""); - expect(drainedUserRow(shell).meta).toBe("steering"); + // The pending column carried the item until delivery, so the + // transcript row is a plain operator message — no [steering] + // label on purpose. + const row = drainedUserRow(shell); + expect(row.meta).toBeUndefined(); + expect(shell.streamLog.map((r) => r.text).join("\n")).toContain( + "steer the ship", + ); expect(recoveryNotices(shell)).toEqual([]); } finally { bridge.dispose(); diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 65f79836b..62c752101 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -13,6 +13,7 @@ import { import { DEFAULT_STALL_MS } from "./agent-progress"; import { appendStreamRow, paintChrome } from "./shell/chrome"; import { createAppShell } from "./shell/index"; +import { getShellBridgeHooks } from "./shell/internals"; import { streamRowCount } from "./shell/transcript"; import { STEER_WAIT_NOTICE_MS } from "./notice-line"; import { withTestRenderer } from "./harness"; @@ -158,7 +159,7 @@ describe("attachSessionBridge", () => { expect(badgeCount(shell.session)).toBe(1); expect(shell.pendingQueue).toBe(1); const frame = h.captureCharFrame(); - expect(frame).toMatch(/steer\s+1/); + expect(frame).toContain("steer queued please"); } finally { bridge.dispose(); shell.dispose(); @@ -194,8 +195,8 @@ describe("attachSessionBridge", () => { expect(shell.session.run).toBe("busy"); expect(badgeCount(shell.session)).toBe(1); const frame = h.captureCharFrame(); - expect(frame).toMatch(/follow-up\s+1/); - expect(frame).toContain("will follow up"); + expect(frame).toContain("follow-up follow up later"); + expect(frame).not.toContain("will follow up"); } finally { bridge.dispose(); shell.dispose(); @@ -343,7 +344,92 @@ describe("attachSessionBridge", () => { await h.renderOnce(); const frame = h.captureCharFrame(); expect(frame).toContain("steer now"); - expect(frame).toMatch(/follow-up\s+1/); + expect(frame).toContain("follow-up follow up"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("onForceDeliver removes the item and delivers it now", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + bridge.submit("steer now", "steer"); + bridge.submit("follow up", "queue"); + port.clear(); + const held = defined(shell.session.items[0], "queued item"); + getShellBridgeHooks(shell)?.onForceDeliver?.(held.id); + expect(shell.session.items.map((i) => i.text)).toEqual(["follow up"]); + expect(port.calls).toEqual([ + { + op: "deliver", + item: expect.objectContaining({ text: "steer now" }), + }, + ]); + await h.renderOnce(); + // Delivered rows are ordinary user rows — no pending/delivery label. + const frame = h.captureCharFrame(); + expect(frame).toContain("steer now"); + expect(frame).not.toContain("[steering]"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("a steer force-pushed mid-turn keeps its inject semantics", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const port = createRecordingPort(); + const liveAtDeliver: boolean[] = []; + // resolvePort captures handlers at attach time — wrap before it runs. + let bridge!: ReturnType; + const recorded = port.deliver; + port.deliver = (item) => { + liveAtDeliver.push(bridge.parentCycleLive); + recorded(item); + }; + bridge = attachSessionBridge(shell, port); + try { + // A live turn: submit moves the turn to isProcessing. + bridge.submit("start work", "immediate"); + expect(bridge.turn.isProcessing).toBe(true); + bridge.submit("steer now", "steer"); + bridge.submit("follow up", "queue"); + port.clear(); + const steer = defined( + shell.session.items.find((i) => i.kind === "steer"), + "steer item", + ); + const followUp = defined( + shell.session.items.find((i) => i.kind === "queue"), + "follow-up item", + ); + const hooks = getShellBridgeHooks(shell); + hooks?.onForceDeliver?.(steer.id); + hooks?.onForceDeliver?.(followUp.id); + // parentCycleLive latched for the steer's deliver call so it + // injects; the follow-up keeps the send path — it was never a steer. + expect(liveAtDeliver).toEqual([true, false]); } finally { bridge.dispose(); shell.dispose(); @@ -453,8 +539,9 @@ describe("attachSessionBridge", () => { ).toEqual(["late steer", "follow up"]); await h.renderOnce(); const frame = h.captureCharFrame(); - expect(frame).toContain("following up"); - expect(frame).toContain("steering"); + expect(frame).toContain("follow up"); + expect(frame).toContain("late steer"); + expect(frame).not.toContain("following up"); } finally { bridge.dispose(); shell.dispose(); @@ -1108,11 +1195,11 @@ describe("same-turn retry after inference.error", () => { (r) => `${r.meta ?? r.role}:${r.text}`, ); expect(rows.indexOf("thinking:planning")).toBeGreaterThan(-1); - expect(rows.indexOf("steering:steer this")).toBeGreaterThan( + expect(rows.indexOf("user:steer this")).toBeGreaterThan( rows.indexOf("thinking:planning"), ); expect(rows.indexOf("thinking:after steer")).toBeGreaterThan( - rows.indexOf("steering:steer this"), + rows.indexOf("user:steer this"), ); expect( shell.streamLog.filter((r) => r.meta === "thinking"), @@ -1211,6 +1298,62 @@ describe("same-turn retry after inference.error", () => { ); }); + test("a boundary-delivered steer row survives retry rollback", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }); + const port = createRecordingPort(); + const bridge = attachSessionBridge(shell, port); + try { + bridge.submit("start work", "immediate"); + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "tool.start", + data: { call: { id: "c1", name: "run_shell" } }, + }); + bridge.submit("steer now", "steer"); + // The parent tool finishes: the steer delivers at the boundary, + // painting its transcript row after the armed attempt mark. + bridge.handle({ type: "tool.boundary" }); + expect(port.calls.some((c) => c.op === "deliver")).toBe(true); + // Same-attempt failure: the retry rolls back to the attempt mark. + // The delivered row must survive — delivery already happened, so + // retracting it would show a transcript the runtime never saw. + bridge.handle({ + type: "inference.error", + data: { + error: { + category: "credential_failure", + message: "Forbidden", + statusCode: 403, + }, + }, + }); + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.text.delta", + data: { token: "recovered" }, + }); + bridge.handle({ type: "inference.done", data: {} }); + bridge.handle({ type: "reactor.done", data: {} }); + + const text = shell.streamLog.map((r) => r.text).join("\n"); + expect(errorRows(shell)).toEqual([]); + expect(text).toContain("recovered"); + expect(text).toContain("steer now"); + } finally { + bridge.dispose(); + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + test("reinject interrupt keeps the prompt and the classified error", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index c16f46b8b..6e7d9f2e5 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -7,6 +7,7 @@ */ import { + cancelItem, createSessionQueue, drainOne, enqueue, @@ -669,7 +670,15 @@ function settleDrainedDelivery( paintChrome(shell); } -function dispatchDrainedItem( +/** + * One queued item's delivery hop. The pending column carried the item until + * now; delivery is what earns the transcript row, painted as an ordinary + * operator message. The queueItemId lets the settle path mark this exact row + * not-delivered/uncertain and lets rollback keep a delivered row; the echo + * ledger keeps the inbound `message.received` from painting a second row + * when the runtime echoes the send back. + */ +function deliverQueuedItem( shell: AppShell, bag: BridgeBag, item: QueueItem, @@ -678,9 +687,6 @@ function dispatchDrainedItem( appendStreamRow(shell, { role: "user", text: userRowText(item.text, item.attachments ?? []), - // Distinct from the still-pending "steer"/"queue" tag — this row is - // being handed to the run right now. Follow-ups must not say steering. - meta: item.kind === "steer" ? "steering" : "following-up", queueItemId: item.id, }); bag.pendingEchoes.push(item.text.trim()); @@ -1123,14 +1129,19 @@ function syncToolElapsed(shell: AppShell, bag: BridgeBag, nowMs: number): void { } } +/** + * User rows the shell paints ahead of the runtime's own inbound copy: a + * reinject, which lands before the restarted run reports it, and a row the + * bridge delivered at a tool boundary (it carries its queueItemId). A + * delivered row stays — the delivery already happened, so retracting it + * would show a transcript the runtime never saw returned. Queued/steered + * items stay off the log while pending (the column above the prompt carries + * them), so there is nothing of theirs to preserve here. + */ function isLocallyQueuedUserRow(row: StreamRow): boolean { return ( row.role === "user" && - (row.meta === "queue" || - row.meta === "steer" || - row.meta === "steering" || - row.meta === "following-up" || - row.meta === "reinject") + (row.meta === "reinject" || row.queueItemId !== undefined) ); } @@ -1173,7 +1184,7 @@ function drainAtBoundary(shell: AppShell, bag: BridgeBag): void { const { state, item } = drainOne(shell.session); if (!item) break; shell.session = state; - dispatchDrainedItem(shell, bag, item); + deliverQueuedItem(shell, bag, item); } paintChrome(shell); } @@ -1188,7 +1199,7 @@ function drainSteersAtBoundary(shell: AppShell, bag: BridgeBag): void { const { state, item } = drainOne(shell.session, "steer"); if (!item) break; shell.session = state; - dispatchDrainedItem(shell, bag, item); + deliverQueuedItem(shell, bag, item); } paintChrome(shell); } @@ -1706,18 +1717,11 @@ export function attachSessionBridge( kind === "steer" ? enqueueSteer(shell.session, t, undefined, attachments) : enqueue(shell.session, t, "queue", undefined, attachments); - const queued = shell.session.items[shell.session.items.length - 1]; bag.port.enqueue(t, kind); if (kind === "steer") bag.waitYieldWake?.(); - // Show the message itself, not the internal transition ("queue +1 → - // pending N") — the notice row already carries the depth once, in plain - // language, so this row's job is making the pending item identifiable. - appendStreamRow(shell, { - role: "user", - text: userRowText(t, attached), - meta: kind === "steer" ? "steer" : "queue", - ...(queued !== undefined ? { queueItemId: queued.id } : {}), - }); + // No transcript echo while pending: the item lists in the column stacked + // on the prompt box and lands in the transcript as an ordinary user row + // when it actually delivers. paintChrome(shell); restoreNextPromptRecovery(shell, bag); }; @@ -1906,6 +1910,24 @@ export function attachSessionBridge( onInterrupt: () => { doInterrupt(); }, + // Enter on a selected column row: the item leaves the queue and lands on + // the same port.deliver hop a boundary/idle drain would use. A steer + // pushed while a turn is still in flight keeps its steer semantics — + // liveSteerInject routes it to deliverSteer so it injects into the + // running cycle instead of becoming the next user message. + onForceDeliver: (itemId) => { + const { state, item } = cancelItem(shell.session, itemId); + if (item === null) return; + shell.session = state; + const inject = item.kind === "steer" && bag.turn.isProcessing; + if (inject) bag.liveSteerInject = true; + try { + deliverQueuedItem(shell, bag, item); + } finally { + if (inject) bag.liveSteerInject = false; + } + paintChrome(shell); + }, exclusive: true, }); diff --git a/src/tui/session-queue.ts b/src/tui/session-queue.ts index 4ba24fa0f..d9ea0fe15 100644 --- a/src/tui/session-queue.ts +++ b/src/tui/session-queue.ts @@ -150,6 +150,26 @@ export function cancelLast(state: SessionQueueState): { }; } +/** + * Retract a specific item by id — the pending column's per-row drop, where the + * operator picked exactly which held message to kill rather than the newest. + */ +export function cancelItem( + state: SessionQueueState, + id: string, +): { state: SessionQueueState; item: QueueItem | null } { + const index = state.items.findIndex((item) => item.id === id); + const item = state.items[index] ?? null; + if (item === null) return { state, item: null }; + return { + state: { + ...state, + items: [...state.items.slice(0, index), ...state.items.slice(index + 1)], + }, + item, + }; +} + /** Drain order: steers first (FIFO within class), then queue (FIFO). */ export function drainOrder(state: SessionQueueState): readonly QueueItem[] { const steers = state.items.filter((i) => i.kind === "steer"); diff --git a/src/tui/shell.test.ts b/src/tui/shell.test.ts index 503afb139..39ed36585 100644 --- a/src/tui/shell.test.ts +++ b/src/tui/shell.test.ts @@ -18,7 +18,12 @@ import { toggleShellFocus, } from "./shell/chrome"; import { createAppShell } from "./shell/index"; -import { isTranscriptFollowing, stickyMode } from "./shell/internals"; +import { + isTranscriptFollowing, + setShellBridgeHooks, + shellInternals, + stickyMode, +} from "./shell/internals"; import { closeInsetOverlay, openInsetOverlay } from "./shell/overlay-host"; import { applyShellCancelLast, @@ -284,7 +289,7 @@ describe("createAppShell", () => { }); }); - test("pending queue badge paints in status", async () => { + test("pending queue lists in the column above the prompt", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -295,8 +300,213 @@ describe("createAppShell", () => { setPendingQueue(shell, 3); expect(shell.pendingQueue).toBe(3); await h.renderOnce(); - // setPendingQueue pads with kind "queue" → follow-up badge. - expect(h.captureCharFrame()).toContain("follow-up 3"); + const frame = h.captureCharFrame(); + // setPendingQueue pads with kind "queue" → follow-up rows. + expect(frame).toContain("follow-up pad-1"); + expect(frame).toContain("follow-up pad-3"); + expect(shell.streamLog).toHaveLength(0); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("↑/↓ walk the column; the selected row paints ▸", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + }); + try { + setPendingQueue(shell, 3); + await h.renderOnce(); + // ↑ at the buffer's top edge selects the newest held item. + h.mockInput.pressKey("\x1b[A"); + await h.renderOnce(); + expect(shellInternals(shell)?.pendingSelId).toBe( + shell.session.items[2]?.id, + ); + expect(h.captureCharFrame()).toContain("▸ follow-up pad-3"); + // ↑ walks up the column. + h.mockInput.pressKey("\x1b[A"); + await h.renderOnce(); + expect(h.captureCharFrame()).toContain("▸ follow-up pad-2"); + // ↓ past the last row hands the key back to the prompt. + h.mockInput.pressKey("\x1b[B"); + h.mockInput.pressKey("\x1b[B"); + await h.renderOnce(); + expect(shellInternals(shell)?.pendingSelId).toBeNull(); + expect(h.captureCharFrame()).not.toContain("▸"); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("Ctrl+X drops the selected row, sliding the selection", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + }); + try { + setPendingQueue(shell, 2); + await h.renderOnce(); + h.mockInput.pressKey("\x1b[A"); + await h.renderOnce(); + h.pressKey("x", { ctrl: true }); + await h.renderOnce(); + expect(shell.session.items.map((i) => i.text)).toEqual(["pad-1"]); + // Selection slid onto the row that filled the freed slot. + expect(shellInternals(shell)?.pendingSelId).toBe( + shell.session.items[0]?.id, + ); + expect(h.captureCharFrame()).not.toContain("pad-2"); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("Enter on a selected row force-delivers through the bridge hook", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + }); + const pushed: string[] = []; + setShellBridgeHooks(shell, { + exclusive: true, + onSubmit: () => undefined, + onInterrupt: () => undefined, + onForceDeliver: (id) => pushed.push(id), + }); + try { + setPendingQueue(shell, 2); + await h.renderOnce(); + const oldest = defined(shell.session.items[0]).id; + h.mockInput.pressKey("\x1b[A"); + h.mockInput.pressKey("\x1b[A"); + await h.renderOnce(); + h.pressKey("Enter"); + await h.renderOnce(); + expect(pushed).toEqual([oldest]); + expect(shellInternals(shell)?.pendingSelId).toBeNull(); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("Enter without a deliver hook pops the row back for editing", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + }); + try { + setPendingQueue(shell, 1); + await h.renderOnce(); + h.mockInput.pressKey("\x1b[A"); + await h.renderOnce(); + h.pressKey("Enter"); + await h.renderOnce(); + expect(shell.session.items).toHaveLength(0); + expect(shell.prompt.value).toBe("pad-1"); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("Esc ends the selection and leaves the queue alone", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + }); + try { + setPendingQueue(shell, 2); + await h.renderOnce(); + h.mockInput.pressKey("\x1b[A"); + await h.renderOnce(); + // ESC needs disambiguation delay on the mock stdin path. + h.pressKey("Escape"); + await new Promise((r) => setTimeout(r, 60)); + await h.renderOnce(); + expect(shellInternals(shell)?.pendingSelId).toBeNull(); + expect(shell.session.items).toHaveLength(2); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("Ctrl+G pops the selected row, not the newest", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + }); + try { + setPendingQueue(shell, 2); + await h.renderOnce(); + // ↑ selects the newest; ↑ again walks up to the older row. + h.mockInput.pressKey("\x1b[A"); + h.mockInput.pressKey("\x1b[A"); + await h.renderOnce(); + expect(shellInternals(shell)?.pendingSelId).toBe( + shell.session.items[0]?.id, + ); + h.pressKey("g", { ctrl: true }); + await h.renderOnce(); + expect(shell.session.items.map((i) => i.text)).toEqual(["pad-2"]); + expect(shell.prompt.value).toBe("pad-1"); + expect(shellInternals(shell)?.pendingSelId).toBeNull(); + } finally { + shell.dispose(); + } + }, + { width: 80, height: 24 }, + ); + }); + + test("a prompt paste ends the selection instead of editing under it", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: true, + }); + try { + setPendingQueue(shell, 1); + await h.renderOnce(); + h.mockInput.pressKey("\x1b[A"); + await h.renderOnce(); + expect(shellInternals(shell)?.pendingSelId).not.toBeNull(); + await h.mockInput.pasteBracketedText("pasted"); + await h.renderOnce(); + expect(shellInternals(shell)?.pendingSelId).toBeNull(); + expect(shell.prompt.value).toBe("pasted"); + expect(shell.session.items).toHaveLength(1); } finally { shell.dispose(); } @@ -416,7 +626,9 @@ describe("product skin: stream + queue + overlay", () => { expect(defined(shell.session.items[0]).kind).toBe("queue"); expect(shell.prompt.value).toBe(""); await h.renderOnce(); - expect(h.captureCharFrame()).toContain("follow-up 1"); + const frame = h.captureCharFrame(); + expect(frame).toContain("follow-up queue me"); + expect(shell.streamLog).toHaveLength(0); } finally { shell.dispose(); } @@ -441,8 +653,8 @@ describe("product skin: stream + queue + overlay", () => { await h.renderOnce(); await h.renderOnce(); const frame = h.captureCharFrame(); - expect(frame).toContain("will steer next"); - expect(frame).toContain("steer 1"); + expect(frame).toContain("steer steer me"); + expect(frame).not.toContain("will steer next"); expect(frame).not.toContain("follow-up"); } finally { shell.dispose(); @@ -483,7 +695,7 @@ describe("product skin: stream + queue + overlay", () => { ); }); - test("Ctrl+G cancels the last queued message and the screen shows it", async () => { + test("Ctrl+G pops the last queued message back into the prompt", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -497,46 +709,26 @@ describe("product skin: stream + queue + overlay", () => { shell.prompt.value = "oops wrong message"; submitPrompt(shell, "queue"); expect(shell.pendingQueue).toBe(2); - - const before = shell.streamLog.map((row) => ({ - text: row.text, - meta: row.meta, - cancelled: row.cancelled, - })); - expect(before).toEqual([ - { text: "keep this one", meta: "queue", cancelled: undefined }, - { text: "oops wrong message", meta: "queue", cancelled: undefined }, - ]); + // Queued items live in the column — the transcript stays empty. + expect(shell.streamLog).toHaveLength(0); await h.renderOnce(); const frameBefore = h.captureCharFrame(); expect(frameBefore).toContain("keep this one"); expect(frameBefore).toContain("oops wrong message"); - expect(frameBefore).not.toContain("[cancelled]"); applyShellCancelLast(shell); expect(shell.pendingQueue).toBe(1); expect(defined(shell.session.items[0]).text).toBe("keep this one"); + // The popped item comes back as an editable draft; nothing lands + // in the transcript as a cancellation marker. + expect(shell.prompt.value).toBe("oops wrong message"); + expect(shell.streamLog).toHaveLength(0); - const after = shell.streamLog.map((row) => ({ - text: row.text, - meta: row.meta, - cancelled: row.cancelled, - })); - // The stored text is untouched — the cancel is a flag the paint - // layer reads, not a rewrite of what the operator typed. - expect(after).toEqual([ - { text: "keep this one", meta: "queue", cancelled: undefined }, - { text: "oops wrong message", meta: "cancelled", cancelled: true }, - ]); - - // The screen, not just the model, is asserted on: this is exactly - // what the first attempt at this issue got wrong (the row read - // back unchanged from streamLog while the model looked cancelled). await h.renderOnce(); const frameAfter = h.captureCharFrame(); - expect(frameAfter).toContain("[cancelled] oops wrong message"); expect(frameAfter).toContain("keep this one"); + expect(frameAfter).toContain("oops wrong message"); } finally { shell.dispose(); } @@ -545,7 +737,7 @@ describe("product skin: stream + queue + overlay", () => { ); }); - test("Ctrl+G cancels a steered message the same way", async () => { + test("Ctrl+G pops a steered message the same way", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -563,11 +755,11 @@ describe("product skin: stream + queue + overlay", () => { expect(shell.pendingQueue).toBe(0); expect(shell.session.items).toHaveLength(0); - expect(shell.streamLog[0]?.cancelled).toBe(true); - expect(shell.streamLog[0]?.meta).toBe("cancelled"); + expect(shell.prompt.value).toBe("steer me now"); + expect(shell.streamLog).toHaveLength(0); await h.renderOnce(); - expect(h.captureCharFrame()).toContain("[cancelled] steer me now"); + expect(h.captureCharFrame()).not.toContain("cancelled"); } finally { shell.dispose(); } diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index 3409c9b17..acb3ed9b6 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -20,6 +20,12 @@ import { sliceTailToWidth, sliceToWidth, stringWidth } from "../view/height.js"; import { promptRowCount } from "../prompt-input.js"; import { promptBoxRows } from "../prompt-rows.js"; import { composeNoticeLine, resolveWaitingOn } from "../notice-line.js"; +import { + fitPendingRow, + PENDING_COLUMN_HINT, + pendingColumnHeight, + pendingColumnRows, +} from "../pending-column.js"; import { lockupCells, lockupText, @@ -61,7 +67,6 @@ import { destroySubtree } from "../teardown.js"; import { badgeCount, enqueue, - queueCount, setRunState, steerCount, type RunState, @@ -116,8 +121,6 @@ function syncPending(shell: AppShell): void { /** The transient row's text for the current state ("" when it has nothing to say). */ export function noticeText(shell: AppShell): string { return composeNoticeLine({ - steer: steerCount(shell.session), - followUp: queueCount(shell.session), waitingOn: resolveWaitingOn( steerCount(shell.session), shell.inFlightTool, @@ -157,10 +160,12 @@ export function setPluginNeedsAttention(shell: AppShell, needs: boolean): void { * Every input the chrome compose paths read, as one comparable key. A missed * input here means stale chrome, so this list is exhaustive: * - * - notice row: the composed `noticeText` output (folds in steer/follow-up - * queue counts, the in-flight tool and its start time, `lockupNowMs` as the - * waiting-on clock, the interrupt flash, transcript pin state, the status - * flash, and pending attachment count) + * - notice row: the composed `noticeText` output (folds in the in-flight + * tool and its start time, `lockupNowMs` as the waiting-on clock, the + * interrupt flash, transcript pin state, the status flash, and pending + * attachment count) + * - pending column: the session queue's items as composed row text, plus the + * selected item id — nav repaints the highlight without touching row text * - border geometry: `layout.contentWidth` * - top rule: MCP-needs-auth presence, plugin-needs-attention, `modelLabel` * - bottom rule: workspace cwd and branch, `homedir()` (label compression) @@ -175,6 +180,10 @@ function chromeComposeKey(shell: AppShell, notice: string): string { const meter = shell.costContext; return [ notice, + pendingColumnRows(shell.session.items) + .map((row) => `${row.tag ?? ""}:${row.text}`) + .join("\u0001"), + shellInternals(shell)?.pendingSelId ?? "", shell.layout.contentWidth, shell.mcpNeedsAuth.length > 0 ? "1" : "0", shell.pluginNeedsAttention ? "1" : "0", @@ -229,9 +238,83 @@ export function paintChrome( shell.notice.content = new StyledText([ fgChunk(UI.textDim)(notice.length > 0 ? ` ${notice}` : ""), ]); + syncPendingRows(shell); paintPromptBorder(shell); syncLandingSuggestions(shell); syncNoticeRow(shell, notice); + syncPendingColumn(shell); +} + +/** + * Rebuild pendingBox's row children when the column's painted content moved — + * the items themselves, the fold, the granted height, or the column budget. + * Runs inside the compose-key gate, so an unchanged queue costs one signature + * compare rather than a row rebuild. + */ +function syncPendingRows(shell: AppShell): void { + const bag = shellInternals(shell); + const granted = Math.max(0, shell.layout.heights.pending); + // The last granted row is the key-guidance line; items fill what is left. + const rows = pendingColumnRows(shell.session.items, Math.max(0, granted - 1)); + // A drained or cancelled item cannot stay selected. + if ( + bag !== undefined && + bag.pendingSelId !== null && + !shell.session.items.some((item) => item.id === bag.pendingSelId) + ) { + bag.pendingSelId = null; + } + const selId = bag?.pendingSelId ?? null; + const key = + `${shell.layout.contentWidth} ${granted} ${selId ?? ""}` + + rows.map((row) => `${row.tag ?? ""}:${row.text}`).join("\u0001"); + if (paintedPendingKey.get(shell) === key) return; + paintedPendingKey.set(shell, key); + for (const child of [...shell.pendingBox.getChildren()]) { + shell.pendingBox.remove(child); + destroySubtree(child); + } + for (const row of rows) { + const selected = row.id !== null && row.id === selId; + const fitted = fitPendingRow(row, shell.layout.contentWidth, selected); + shell.pendingBox.add( + new TextRenderable(shell.renderer as CliRenderer, { + content: new StyledText([ + fgChunk(selected ? UI.text : UI.textFaint)(fitted.head), + fgChunk( + row.tag === null ? UI.textFaint : selected ? UI.text : UI.textDim, + )(fitted.text), + ]), + }), + ); + } + if (rows.length > 0 && granted > rows.length) { + shell.pendingBox.add( + new TextRenderable(shell.renderer as CliRenderer, { + content: new StyledText([ + fgChunk(UI.textFaint)( + ` ${sliceToWidth(PENDING_COLUMN_HINT, shell.layout.contentWidth)}`, + ), + ]), + }), + ); + } +} + +/** Signature of what the pending column last painted, per shell. */ +const paintedPendingKey = new WeakMap(); + +/** + * Give the pending column rows only while the queue has items to list, and + * take them back the moment it does not — same transient contract as the + * notice row. + */ +function syncPendingColumn(shell: AppShell): void { + const bag = shellInternals(shell); + if (bag === undefined) return; + const wanted = pendingColumnHeight(shell.session.items.length); + if ((bag.visibility.pending ?? 0) === wanted) return; + relayout(shell, { visibility: { ...bag.visibility, pending: wanted } }); } /** @@ -783,6 +866,10 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { shell.notice.height = noticeH > 0 ? noticeH : 1; shell.notice.visible = noticeH > 0; + const pendingH = Math.max(0, h.pending); + shell.pendingBox.height = pendingH > 0 ? pendingH : 1; + shell.pendingBox.visible = pendingH > 0; + const promptH = Math.max(0, h.prompt); shell.promptBox.height = promptH > 0 ? promptH : 1; shell.promptBox.visible = promptH > 0; @@ -805,8 +892,8 @@ export function applyLayout(shell: AppShell, layout: GeometryLayout): void { // Rows the flow spends before the prompt box — where a floated host's bottom // edge has to land, since the landing's box sits mid-screen rather than at // the foot and covering it would hide the thing the operator types into. - // Stack: topPad, transcript, agents, task, then prompt (notice omitted — - // same as before; it is transient chrome between task and prompt). + // Stack: topPad, transcript, agents, task, then prompt (notice and the + // pending column omitted — both are transient chrome above the prompt). const promptTop = padH + transcriptBody + agentsH + taskH; const hostH = floating ? Math.min(overlayH, Math.max(1, promptTop)) diff --git a/src/tui/shell/index.ts b/src/tui/shell/index.ts index 1db159279..4dfecb6c8 100644 --- a/src/tui/shell/index.ts +++ b/src/tui/shell/index.ts @@ -247,6 +247,18 @@ export function createAppShell( visible: layout.heights.notice > 0, }); + // The queued steer/follow-up column — same transient pattern as the notice + // row: it holds rows only while the session queue has items to list. + const pendingBox = new BoxRenderable(ctx, { + id: "shell-pending", + width: "100%", + height: Math.max(1, layout.heights.pending), + flexShrink: 0, + flexDirection: "column", + backgroundColor: UI.ground, + visible: layout.heights.pending > 0, + }); + const promptBox = new BoxRenderable(ctx, { id: "shell-prompt-region", width: "100%", @@ -307,6 +319,7 @@ export function createAppShell( root.add(agentsBox); root.add(taskBox); root.add(notice); + root.add(pendingBox); root.add(promptBox); root.add(landingBelow); root.add(bottomPad); @@ -412,6 +425,7 @@ export function createAppShell( promptTopRule, promptBottomRule, notice, + pendingBox, layout, focus: createFocusState(), session, @@ -525,6 +539,7 @@ export function createAppShell( // shell's lifetime. Live task data still lands in tasksRaw while hidden, // so the first toggle shows current data rather than a stale snapshot. tasksPanelHidden: true, + pendingSelId: null, }); // The landing's snow needs a frame source that keeps running while the // turn monitor is deliberately quiet (idle, no session yet). A plain timer diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index 775948fff..04ca94784 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -69,6 +69,12 @@ export interface ShellBridgeHooks { attachments?: readonly PendingImageAttachment[], ) => void; onInterrupt: () => void; + /** + * Enter on a selected pending item: kill it out of the queue and push it + * through `port.deliver` immediately, skipping its boundary/idle wait. + * Absent on hosts that cannot deliver — the prompt falls back to editing. + */ + onForceDeliver?: (itemId: string) => void; exclusive: boolean; } @@ -387,6 +393,12 @@ export interface AppShell { readonly promptBottomRule: TextRenderable; /** Transient state row above the prompt box (hidden when it has nothing to say). */ readonly notice: TextRenderable; + /** + * Queued steer/follow-up items stacked on the prompt box — one row each + * while pending. Hidden when the session queue is empty; geometry owns its + * row budget (zone `pending`). + */ + readonly pendingBox: BoxRenderable; /** Latest geometry resolution (updated on resize / relayout). */ layout: GeometryLayout; /** Focus tree + scroll lease (updated by shell helpers). */ @@ -808,6 +820,12 @@ interface ShellInternals { }; /** Operator toggle for the task panel; in-memory, held for the life of the shell. */ tasksPanelHidden: boolean; + /** + * Pending-column selection, by queue item id (stable across drains/cancels + * of other items). Null while the prompt holds the keys — selection is + * entered with ↑ and left with ↓, Esc, or any other claimed key. + */ + pendingSelId: string | null; } export const internals = new WeakMap(); diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index d41e266f3..e6c4a3077 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -36,10 +36,16 @@ import { toggleTasksPanel, } from "./chrome.js"; import { + applyPendingCancelSelected, + applyPendingDrop, + applyPendingForcePush, + applyPendingNav, applyShellCancelLast, attachClipboardImage, clearPendingAttachments, + clearPendingSelection, interruptShell, + pendingSelectionActive, submitPrompt, } from "./prompt.js"; import { @@ -256,10 +262,18 @@ export function createShellKeyHandlers( const bag = shellInternals(shell); if (bag?.inputSuspended === true) return; sawBracketedPaste = true; - if (shell.overlayList !== null && bag?.primaryBindings.onPaste) { - event.preventDefault(); - bag.primaryBindings.onPaste(new TextDecoder().decode(event.bytes)); + if (shell.overlayList !== null) { + if (bag?.primaryBindings.onPaste) { + event.preventDefault(); + bag.primaryBindings.onPaste(new TextDecoder().decode(event.bytes)); + } + return; } + // A paste into the prompt is composer input like any other key: it ends + // a pending-column selection instead of editing under it. The prompt + // textarea still consumes the event itself, so this only drops the + // selection and falls through. + clearPendingSelection(shell); }; const onKey = (key: KeyEvent): void => { @@ -289,6 +303,12 @@ export function createShellKeyHandlers( leaveSubagentObserve(shell); return; } + // A pending-column selection is the shallowest dismiss: Esc backs out + // of it before touching transcript focus. + if (clearPendingSelection(shell)) { + key.preventDefault(); + return; + } // Transcript browse (entered with Tab) is the remaining poppable frame: // Esc hands typing back to the prompt. if (canPopFocus(shell.focus)) { @@ -481,6 +501,37 @@ export function createShellKeyHandlers( } } + // A pending-column selection owns Enter (kill the held item and send it + // now), ^X (drop it) and ^G (pop it back for editing) outright; every + // other key just ends the selection and falls through to its normal + // handling. ↑/↓ are exempt — they stay with the column and are claimed + // by the nav block below. + if (pendingSelectionActive(shell)) { + if ( + (keyName === "return" || keyName === "kpenter") && + !key.ctrl && + !key.meta && + !key.option + ) { + key.preventDefault(); + applyPendingForcePush(shell); + return; + } + if (key.ctrl && !key.meta && !key.option && keyName === "x") { + key.preventDefault(); + applyPendingDrop(shell); + return; + } + if (key.ctrl && !key.meta && !key.option && keyName === "g") { + key.preventDefault(); + applyPendingCancelSelected(shell); + return; + } + if (keyName !== "up" && keyName !== "down") { + clearPendingSelection(shell); + } + } + const isCtrlKillYank = key.ctrl && !key.meta && @@ -651,6 +702,12 @@ export function createShellKeyHandlers( (key.name === "up" || key.name === "down") && focusOwner(shell.focus) === "prompt" ) { + // A live pending column takes ↑/↓ first: ↑ at the buffer's top edge + // selects the newest held item, ↓ past the last row hands the key back. + if (applyPendingNav(shell, key.name === "up" ? -1 : 1)) { + key.preventDefault(); + return; + } // Multi-row prompt: Up/Down are caret motion first. Recall only fires at // the buffer's edges, which is where a shell history is conventionally // reachable and where the caret has nowhere left to go. diff --git a/src/tui/shell/prompt.ts b/src/tui/shell/prompt.ts index b8974af6a..2daa6a8e0 100644 --- a/src/tui/shell/prompt.ts +++ b/src/tui/shell/prompt.ts @@ -21,8 +21,11 @@ import { } from "../prompt-recognition.js"; import { RUNTIME_FLASH_MS } from "../runtime-notices.js"; import { composeCostContextMeter, meterEquals } from "../prompt-border.js"; +import { pendingWindowStart } from "../pending-column.js"; +import { promptCaretAtFirstRow } from "../prompt-input.js"; import { badgeCount, + cancelItem, cancelLast, clearInterruptFlash, enqueue, @@ -40,12 +43,10 @@ import { shellPromptImageSource, shellRecognitionSource, } from "./internals.js"; -import { streamRowAt } from "./transcript.js"; import { appendStreamRow, paintChrome, paintPromptBorder, - replaceStreamRowAt, setStatusFlash, } from "./chrome.js"; @@ -355,63 +356,169 @@ export function submitPrompt( kind === "steer" ? enqueueSteer(shell.session, t, undefined, attachments) : enqueue(shell.session, t, "queue", undefined, attachments); - const queued = shell.session.items[shell.session.items.length - 1]; shell.prompt.value = ""; clearPendingAttachments(shell); - // Show the message itself, not the internal transition ("queue +1 → - // pending N") — the notice row already carries the depth once, in plain - // language, so this row's job is making the pending item identifiable. - appendStreamRow(shell, { - role: "user", - text: userRowText(t, attachments), - meta: kind === "steer" ? "steer" : "queue", - ...(queued !== undefined ? { queueItemId: queued.id } : {}), - }); + // No transcript echo while pending: the item lives in the column stacked on + // the prompt box and lands in the transcript as an ordinary user row when + // it actually delivers. paintChrome(shell); } /** - * Find the transcript row a still-pending queue/steer item echoed, so a - * cancel can retract it instead of leaving a message tagged "queue" that will - * never dispatch. Absolute index, matching `replaceStreamRowAt`. + * Pop the most recently queued or steered message back into the composer + * (last-only: see `cancelLast`'s doc comment for why picking an earlier item + * is out of scope). With an empty prompt the item's text and attachments come + * back for editing and resend; mid-compose the item is simply dropped, since + * merging it into an in-progress draft would send two messages as one. */ -function findQueueRowIndex( - shell: AppShell, - queueItemId: string, -): number | undefined { - for (let local = shell.streamLog.length - 1; local >= 0; local--) { - if (shell.streamLog[local]?.queueItemId === queueItemId) { - return shell.streamLogBase + local; +export function applyShellCancelLast(shell: AppShell): void { + const { state, item } = cancelLast(shell.session); + if (item === null) return; + shell.session = state; + if (shell.prompt.value.length === 0) { + shell.prompt.value = item.text; + shell.prompt.cursorOffset = item.text.length; + if (item.attachments !== undefined && item.attachments.length > 0) { + shell.pendingAttachments = [ + ...shell.pendingAttachments, + ...item.attachments, + ]; } } - return undefined; + paintChrome(shell); +} + +/** Index of the selected pending item, or -1 when nothing is selected. */ +function pendingSelIndex(shell: AppShell): number { + const selId = shellInternals(shell)?.pendingSelId; + if (selId === undefined || selId === null) return -1; + return shell.session.items.findIndex((item) => item.id === selId); +} + +/** True while the pending column, not the prompt, owns the keys. */ +export function pendingSelectionActive(shell: AppShell): boolean { + return pendingSelIndex(shell) >= 0; +} + +/** End pending selection (if any). Returns whether a selection was dropped. */ +export function clearPendingSelection(shell: AppShell): boolean { + const bag = shellInternals(shell); + if (bag === undefined || bag.pendingSelId === null) return false; + bag.pendingSelId = null; + paintChrome(shell); + return true; } /** - * Cancel the most recently queued or steered message (last-only: see - * `cancelLast`'s doc comment for why picking an earlier item is out of - * scope). Retracts it from the queue and rewrites its transcript row so the - * readout never shows a message tagged "queue"/"steer" that will not send. + * ↑/↓ on the pending column. ↑ from the prompt's top edge selects the newest + * held item (the row nearest the box); ↑/↓ walk the column; ↓ past the last + * row hands the key back to the prompt's own motion. The selection only ever + * lands on rows the column actually paints — a folded-away item can't be + * selected. Returns whether the key was claimed. */ -export function applyShellCancelLast(shell: AppShell): void { - const { state, item } = cancelLast(shell.session); - if (item === null) return; +export function applyPendingNav(shell: AppShell, delta: -1 | 1): boolean { + const bag = shellInternals(shell); + if (bag === undefined) return false; + const items = shell.session.items; + const itemRows = Math.max(0, shell.layout.heights.pending - 1); + const floor = pendingWindowStart(items.length, itemRows); + const sel = pendingSelIndex(shell); + if (delta === -1) { + // Nothing visible to land on: the column folded or has no grant. + if (items.length === 0 || items.length - 1 < floor) return false; + // Entering the column only happens from the buffer's top row, so ↑ inside + // a multi-row draft still moves the caret rather than stealing focus. + if (sel === -1 && !promptCaretAtFirstRow(shell.prompt)) return false; + const next = sel === -1 ? items.length - 1 : Math.max(floor, sel - 1); + bag.pendingSelId = items[next]?.id ?? null; + paintChrome(shell); + return true; + } + if (sel === -1) return false; + bag.pendingSelId = + sel >= items.length - 1 ? null : (items[sel + 1]?.id ?? null); + paintChrome(shell); + return true; +} + +/** + * No-runtime path for leaving the queue through the selected row: kill the + * selected item out of the queue. Same contract as applyShellCancelLast — + * an empty prompt gets the item back for editing; mid-draft it's dropped + * rather than merged, since merging would send two messages as one. + */ +function popSelectedToPrompt(shell: AppShell): void { + const bag = shellInternals(shell); + const sel = pendingSelIndex(shell); + if (bag === undefined || sel < 0) return; + const id = shell.session.items[sel]?.id; + if (id === undefined) return; + const { state, item: popped } = cancelItem(shell.session, id); + if (popped === null) return; shell.session = state; - const index = findQueueRowIndex(shell, item.id); - if (index !== undefined) { - const row = streamRowAt(shell, index); - if (row !== undefined) { - // `cancelled` stays a flag, not a `text` rewrite — `paintStreamRow` - // owns turning it into the "[cancelled]" prefix, so `row.text` still - // holds what the operator actually typed for anything else that reads - // it (copy mode, a resumed transcript). - replaceStreamRowAt(shell, index, { - ...row, - meta: "cancelled", - cancelled: true, - }); - } + bag.pendingSelId = null; + if (shell.prompt.value.length === 0) { + shell.prompt.value = popped.text; + shell.prompt.cursorOffset = popped.text.length; + shell.pendingAttachments = [ + ...shell.pendingAttachments, + ...(popped.attachments ?? []), + ]; + } + paintChrome(shell); +} + +/** + * Enter on a selected pending item: kill it out of the queue and force-push — + * deliver it now through the runtime, skipping its boundary/idle wait. With + * no runtime attached there is nothing to deliver to, so it falls back to + * the cancel contract: back into an empty prompt for editing, dropped + * mid-draft rather than merged. + */ +export function applyPendingForcePush(shell: AppShell): void { + const bag = shellInternals(shell); + const sel = pendingSelIndex(shell); + if (bag === undefined || sel < 0) return; + const item = shell.session.items[sel]; + if (item === undefined) return; + const hooks = getShellBridgeHooks(shell); + if (hooks?.exclusive === true && hooks.onForceDeliver !== undefined) { + bag.pendingSelId = null; + hooks.onForceDeliver(item.id); + // The hook owns the delivery repaint; this pass covers stubs that don't. + paintChrome(shell); + return; } + popSelectedToPrompt(shell); +} + +/** + * Ctrl+G on a selected pending item: cancel that row, not the newest — the + * operator pointed at it. Without hooks this is the same pop-to-prompt as + * the force-push fallback; with a runtime attached it still only cancels, + * never delivers. + */ +export function applyPendingCancelSelected(shell: AppShell): void { + popSelectedToPrompt(shell); +} + +/** + * ^X on a selected pending item: kill it outright, keeping the selection on + * whatever slides into the freed slot so a second ^X walks the list down + * without re-entering the column. + */ +export function applyPendingDrop(shell: AppShell): void { + const bag = shellInternals(shell); + const sel = pendingSelIndex(shell); + if (bag === undefined || sel < 0) return; + const id = shell.session.items[sel]?.id; + if (id === undefined) return; + const { state, item } = cancelItem(shell.session, id); + if (item === null) return; + shell.session = state; + bag.pendingSelId = + shell.session.items[Math.min(sel, shell.session.items.length - 1)]?.id ?? + null; paintChrome(shell); } diff --git a/src/tui/stream.test.ts b/src/tui/stream.test.ts index 375fc0f53..f1f251f87 100644 --- a/src/tui/stream.test.ts +++ b/src/tui/stream.test.ts @@ -57,22 +57,13 @@ describe("stream paint", () => { } }); - test("steer / follow-up prefixes distinguish pending vs delivered", () => { - expect(userBody({ role: "user", text: "a", meta: "steer" })[0]).toContain( - "[will steer next] a", - ); - expect(userBody({ role: "user", text: "b", meta: "queue" })[0]).toContain( - "[will follow up] b", - ); - expect( - userBody({ role: "user", text: "c", meta: "steering" })[0], - ).toContain("[steering] c"); - expect( - userBody({ role: "user", text: "d", meta: "following-up" })[0], - ).toContain("[following up] d"); - expect( - userBody({ role: "user", text: "e", meta: "following-up" })[0], - ).not.toContain("steering"); + test("queued-item meta paints as a plain operator row — no delivery prefixes", () => { + // Pending state lives in the column above the prompt; a row that reaches + // the transcript has already delivered and reads as an ordinary message. + for (const meta of ["steer", "queue", "steering", "following-up"]) { + expect(userBody({ role: "user", text: "a", meta })[0]).toContain(" a"); + expect(userBody({ role: "user", text: "a", meta })[0]).not.toContain("["); + } }); test("delivery settlement prefixes keep the original row text", () => { diff --git a/src/tui/stream.ts b/src/tui/stream.ts index 9de0d0ca2..8b153e260 100644 --- a/src/tui/stream.ts +++ b/src/tui/stream.ts @@ -775,9 +775,11 @@ export function paintStreamRow( ): PaintedStreamLine { const fg = rowFg(row); if (row.role === "user") { - // A queued/steered/reinjected message looks identical to a plain sent - // one otherwise — the operator needs to see, on the row itself, what - // will happen to it, not just infer it from a badge count elsewhere. + // A queued/steered message looks identical to a plain sent one otherwise, + // so only settlement state paints onto the row itself: delivery failures + // and cancels (pending lives in the column above the prompt, and a + // delivered row is an ordinary operator row). The row text is untouched + // so copy/resume still sees the original body. const prefix = row.cancelled === true ? "[cancelled] " @@ -785,17 +787,9 @@ export function paintStreamRow( ? "[not delivered] " : row.deliveryStatus === "uncertain" ? "[delivery uncertain] " - : row.meta === "steer" - ? "[will steer next] " - : row.meta === "queue" - ? "[will follow up] " - : row.meta === "steering" - ? "[steering] " - : row.meta === "following-up" - ? "[following up] " - : row.meta === "reinject" - ? "[restarted here] " - : ""; + : row.meta === "reinject" + ? "[restarted here] " + : ""; return { content: userBubbleLines(`${prefix}${row.text}`, layout.width).join("\n"), fg,