From fc8b452b8d9d1328c1d4845d86271992fab9440a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 00:22:25 -0700 Subject: [PATCH 1/2] Let the operator cancel the most recently queued message Ctrl+G retracts the newest queue or steer item and rewrites its transcript row rather than leaving it tagged as still pending, so the readout cannot show a cancelled message as one that will still dispatch. Chose Ctrl+G because it is unclaimed by both the textarea's own bindings and this shell's other chords, and readline/Emacs users already read it as "abort", unlike Ctrl+X (cut, readline prefix). Cancellation targets the last item only. Selecting an earlier item would need a picker overlay; last-only is an honest minimum until an operator actually asks for more. --- src/tui-opentui/keybindings.test.ts | 27 +++++++++++++++ src/tui-opentui/keybindings.ts | 1 + src/tui-opentui/session-queue.ts | 16 +++++++++ src/tui-opentui/shell.test.ts | 42 ++++++++++++++++++++++ src/tui-opentui/shell.ts | 54 +++++++++++++++++++++++++++++ src/tui-opentui/stream.ts | 6 ++++ 6 files changed, 146 insertions(+) diff --git a/src/tui-opentui/keybindings.test.ts b/src/tui-opentui/keybindings.test.ts index efcc14569..d0a888914 100644 --- a/src/tui-opentui/keybindings.test.ts +++ b/src/tui-opentui/keybindings.test.ts @@ -34,10 +34,12 @@ import { setSentMessageHistory, setShellBridgeHooks, setShellExitHandler, + clearShellBridgeHooks, setShellRunState, shellFocusPrompt, shellFocusTranscript, streamRowAt, + submitPrompt, type AppShell, } from "./shell.js" @@ -454,6 +456,31 @@ const PROBES: Readonly { + clearShellBridgeHooks(shell) + setShellRunState(shell, "busy") + shell.prompt.value = "keep" + submitPrompt(shell, "queue") + shell.prompt.value = "drop me" + submitPrompt(shell, "queue") + expect(shell.pendingQueue).toBe(2) + + press(h, chords[0]) + + expect(shell.pendingQueue).toBe(1) + expect(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"]) + + setShellRunState(shell, "idle") + }, + }, + "Ctrl+D": { group: "host", // Probed on a mounted host rather than a bare shell: the row claims a diff --git a/src/tui-opentui/keybindings.ts b/src/tui-opentui/keybindings.ts index 4d7f1f7c0..a99e0c0c8 100644 --- a/src/tui-opentui/keybindings.ts +++ b/src/tui-opentui/keybindings.ts @@ -23,6 +23,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ { keys: "Enter", description: "queue the message mid-run (badge); send straight through when idle" }, { 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" }, diff --git a/src/tui-opentui/session-queue.ts b/src/tui-opentui/session-queue.ts index d2c4ceb0d..b90f6a1c4 100644 --- a/src/tui-opentui/session-queue.ts +++ b/src/tui-opentui/session-queue.ts @@ -112,6 +112,22 @@ export function clearInterruptFlash( return { ...state, interruptFlash: false } } +/** + * Retract the most recently enqueued item, queue or steer alike. Last-only: + * an operator who wants an earlier item gone has no path here (see + * `applyShellCancelLast` for why that is the shipped scope, not an oversight). + */ +export function cancelLast( + state: SessionQueueState, +): { state: SessionQueueState; item: QueueItem | null } { + const item = state.items[state.items.length - 1] ?? null + if (item === null) return { state, item: null } + return { + state: { ...state, items: state.items.slice(0, -1) }, + item, + } +} + /** Drain order: steers first (FIFO within class), then queue (FIFO). */ export function drainOrder( state: SessionQueueState, diff --git a/src/tui-opentui/shell.test.ts b/src/tui-opentui/shell.test.ts index fdaf226c0..416021730 100644 --- a/src/tui-opentui/shell.test.ts +++ b/src/tui-opentui/shell.test.ts @@ -15,6 +15,7 @@ import { paintStreamRow } from "./stream" import { appendStreamRow, appendTranscript, + applyShellCancelLast, closeInsetOverlay, createAppShell, interruptShell, @@ -478,6 +479,47 @@ describe("product skin: stream + queue + overlay", () => { ) }) + test("Ctrl+G cancels the last queued message and rewrites its row", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + try { + shell.prompt.value = "keep this one" + submitPrompt(shell, "queue") + 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 })) + expect(before).toEqual([ + { text: "keep this one", meta: "queue" }, + { text: "oops wrong message", meta: "queue" }, + ]) + + applyShellCancelLast(shell) + + expect(shell.pendingQueue).toBe(1) + expect(shell.session.items[0]!.text).toBe("keep this one") + + const after = shell.streamLog.map((row) => ({ text: row.text, meta: row.meta })) + // The cancelled row is rewritten, not left claiming "queue" — the + // first attempt's bug this test exists to catch. + expect(after).toEqual([ + { text: "keep this one", meta: "queue" }, + { text: "[cancelled] oops wrong message", meta: "cancelled" }, + ]) + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + test("inset overlay opens; Esc restores prompt focus", async () => { await withTestRenderer( async (h) => { diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 6449ccc79..b0f295c06 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -148,6 +148,7 @@ import { } from "./copy-path.js" import { badgeCount, + cancelLast, clearInterruptFlash, createSessionQueue, enqueue, @@ -2880,6 +2881,7 @@ 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 → @@ -2889,10 +2891,53 @@ export function submitPrompt( role: "user", text: userRowText(t, attachments), meta: kind === "steer" ? "steer" : "queue", + ...(queued !== undefined ? { queueItemId: queued.id } : {}), }) paintChrome(shell) } +/** + * Find the transcript row a still-pending queue/steer item echoed, so a + * cancel can retract it instead of leaving a message tagged "queue" that will + * never dispatch. Absolute index, matching `replaceStreamRowAt`. + */ +function findQueueRowIndex(shell: AppShell, queueItemId: string): number | undefined { + for (let local = shell.streamLog.length - 1; local >= 0; local--) { + if (shell.streamLog[local]?.queueItemId === queueItemId) { + return shell.streamLogBase + local + } + } + return undefined +} + +/** + * Cancel the most recently queued or steered message (last-only: see + * `cancelLast`'s doc comment for why picking an earlier item is out of + * scope). Retracts it from the queue and rewrites its transcript row so the + * readout never shows a message tagged "queue"/"steer" that will not send. + */ +export function applyShellCancelLast(shell: AppShell): void { + const { state, item } = cancelLast(shell.session) + if (item === null) return + shell.session = state + const index = findQueueRowIndex(shell, item.id) + if (index !== undefined) { + const row = streamRowAt(shell, index) + if (row !== undefined) { + // The bar-and-bubble paint for a user row shows only `text`, not + // `meta` (see `paintStreamRow`) — copy mode reads `text` too — so the + // word has to land in the body itself or the visible transcript still + // reads as a message that will dispatch. + replaceStreamRowAt(shell, index, { + ...row, + text: `[cancelled] ${row.text}`, + meta: "cancelled", + }) + } + } + paintChrome(shell) +} + /** Local interrupt mutation (no bridge re-entry). */ export function applyShellInterrupt(shell: AppShell): void { const had = badgeCount(shell.session) @@ -5308,6 +5353,15 @@ export function createAppShell( return } + if (key.ctrl && key.name === "g") { + // Readline/Emacs "abort" chord — unclaimed by both the textarea's + // default bindings and this shell's other chords, and already means + // "cancel the pending thing" to muscle memory, unlike Ctrl+X (cut). + key.preventDefault() + applyShellCancelLast(shell) + return + } + if ( (key.name === "return" || key.name === "enter") && (key.meta || key.option) && diff --git a/src/tui-opentui/stream.ts b/src/tui-opentui/stream.ts index 4d0ffac94..9580d757a 100644 --- a/src/tui-opentui/stream.ts +++ b/src/tui-opentui/stream.ts @@ -80,6 +80,12 @@ export type StreamRow = { * than one `task` call. */ readonly callId?: string + /** + * Id of the queue item this row echoes (see `SessionQueueState`). Lets a + * cancel find the exact row a queued/steered message appended, rather than + * guessing by position once other rows have interleaved. + */ + readonly queueItemId?: string /** * Row standing for a run of repeated calls. Its subject stays the call the * run repeats (never a total across them, which would be a claim the From 906a207a812acd00b1090ceb0c92d1c69c9e8565 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 00:38:45 -0700 Subject: [PATCH 2/2] Keep cancellation a flag, not a text rewrite, and prove it on screen paintStreamRow now owns turning a cancelled user row into the "[cancelled]" prefix, reading a new cancelled flag on StreamRow. row.text stays exactly what the operator typed, so copy-mode and anything else reading it back are unaffected by the cancel. Regression tests now assert on captureCharFrame() before and after a cancel, not only on shell.streamLog, and cover a steer-kind cancel alongside queue in session-queue.test.ts, shell.test.ts and keybindings.test.ts. --- src/tui-opentui/keybindings.test.ts | 9 +++- src/tui-opentui/session-queue.test.ts | 29 +++++++++++ src/tui-opentui/shell.test.ts | 70 +++++++++++++++++++++++---- src/tui-opentui/shell.ts | 14 ++---- src/tui-opentui/stream.ts | 10 +++- 5 files changed, 112 insertions(+), 20 deletions(-) diff --git a/src/tui-opentui/keybindings.test.ts b/src/tui-opentui/keybindings.test.ts index d0a888914..75c8d536d 100644 --- a/src/tui-opentui/keybindings.test.ts +++ b/src/tui-opentui/keybindings.test.ts @@ -458,7 +458,7 @@ const PROBES: Readonly { + probe: async ({ h, shell, chords }) => { clearShellBridgeHooks(shell) setShellRunState(shell, "busy") shell.prompt.value = "keep" @@ -477,6 +477,13 @@ const PROBES: Readonly { expect(s.interruptFlash).toBe(false) }) + test("cancelLast retracts the newest queue item", () => { + let s = createSessionQueue("busy") + s = enqueue(s, "keep") + s = enqueue(s, "drop") + const { state, item } = cancelLast(s) + expect(item?.text).toBe("drop") + expect(badgeCount(state)).toBe(1) + expect(state.items[0]!.text).toBe("keep") + }) + + test("cancelLast retracts the newest steer item, same as queue", () => { + let s = createSessionQueue("busy") + s = enqueue(s, "queued") + s = enqueueSteer(s, "steered") + const { state, item } = cancelLast(s) + expect(item?.kind).toBe("steer") + expect(item?.text).toBe("steered") + expect(badgeCount(state)).toBe(1) + expect(state.items[0]!.kind).toBe("queue") + }) + + test("cancelLast on an empty queue is a no-op", () => { + const s = createSessionQueue("busy") + const { state, item } = cancelLast(s) + expect(item).toBeNull() + expect(state).toBe(s) + }) + test("setRunState toggles busy/idle", () => { let s = createSessionQueue("idle") s = setRunState(s, "busy") diff --git a/src/tui-opentui/shell.test.ts b/src/tui-opentui/shell.test.ts index 416021730..b94c74a0e 100644 --- a/src/tui-opentui/shell.test.ts +++ b/src/tui-opentui/shell.test.ts @@ -479,7 +479,7 @@ describe("product skin: stream + queue + overlay", () => { ) }) - test("Ctrl+G cancels the last queued message and rewrites its row", async () => { + test("Ctrl+G cancels the last queued message and the screen shows it", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -494,24 +494,76 @@ describe("product skin: stream + queue + overlay", () => { submitPrompt(shell, "queue") expect(shell.pendingQueue).toBe(2) - const before = shell.streamLog.map((row) => ({ text: row.text, meta: row.meta })) + const before = shell.streamLog.map((row) => ({ + text: row.text, + meta: row.meta, + cancelled: row.cancelled, + })) expect(before).toEqual([ - { text: "keep this one", meta: "queue" }, - { text: "oops wrong message", meta: "queue" }, + { text: "keep this one", meta: "queue", cancelled: undefined }, + { text: "oops wrong message", meta: "queue", cancelled: undefined }, ]) + 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(shell.session.items[0]!.text).toBe("keep this one") - const after = shell.streamLog.map((row) => ({ text: row.text, meta: row.meta })) - // The cancelled row is rewritten, not left claiming "queue" — the - // first attempt's bug this test exists to catch. + 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" }, - { text: "[cancelled] oops wrong message", meta: "cancelled" }, + { 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") + } finally { + shell.dispose() + } + }, + { width: 80, height: 24 }, + ) + }) + + test("Ctrl+G cancels a steered message the same way", async () => { + await withTestRenderer( + async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }) + try { + shell.prompt.value = "steer me now" + submitPrompt(shell, "steer") + expect(shell.pendingQueue).toBe(1) + expect(shell.session.items[0]!.kind).toBe("steer") + + applyShellCancelLast(shell) + + 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") + + await h.renderOnce() + expect(h.captureCharFrame()).toContain("[cancelled] steer me now") } finally { shell.dispose() } diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index b0f295c06..04b2701e2 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -2924,15 +2924,11 @@ export function applyShellCancelLast(shell: AppShell): void { if (index !== undefined) { const row = streamRowAt(shell, index) if (row !== undefined) { - // The bar-and-bubble paint for a user row shows only `text`, not - // `meta` (see `paintStreamRow`) — copy mode reads `text` too — so the - // word has to land in the body itself or the visible transcript still - // reads as a message that will dispatch. - replaceStreamRowAt(shell, index, { - ...row, - text: `[cancelled] ${row.text}`, - meta: "cancelled", - }) + // `cancelled` stays a flag, not a `text` rewrite — `paintStreamRow` + // owns turning it into the "[cancelled]" prefix, so `row.text` still + // holds what the operator actually typed for anything else that reads + // it (copy mode, a resumed transcript). + replaceStreamRowAt(shell, index, { ...row, meta: "cancelled", cancelled: true }) } } paintChrome(shell) diff --git a/src/tui-opentui/stream.ts b/src/tui-opentui/stream.ts index 9580d757a..a5473b7cf 100644 --- a/src/tui-opentui/stream.ts +++ b/src/tui-opentui/stream.ts @@ -86,6 +86,13 @@ export type StreamRow = { * guessing by position once other rows have interleaved. */ readonly queueItemId?: string + /** + * The queued/steered message this row echoed was cancelled before it + * dispatched. Kept as a flag rather than baked into `text` so the stored + * body stays what the operator actually typed — the paint layer alone + * decides how a cancelled row reads. + */ + readonly cancelled?: boolean /** * Row standing for a run of repeated calls. Its subject stays the call the * run repeats (never a total across them, which would be a claim the @@ -639,7 +646,8 @@ export function paintStreamRow( ): PaintedStreamLine { const fg = rowFg(row) if (row.role === "user") { - return { content: userBubbleLines(row.text, layout.width).join("\n"), fg } + const text = row.cancelled === true ? `[cancelled] ${row.text}` : row.text + return { content: userBubbleLines(text, layout.width).join("\n"), fg } } if (isThinkingRow(row)) { return {