diff --git a/src/tui-opentui/keybindings.test.ts b/src/tui-opentui/keybindings.test.ts index efcc14569..75c8d536d 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,38 @@ 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"]) + + // 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("keep") + + 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.test.ts b/src/tui-opentui/session-queue.test.ts index 41239330d..cf78e2654 100644 --- a/src/tui-opentui/session-queue.test.ts +++ b/src/tui-opentui/session-queue.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { badgeCount, + cancelLast, clearInterruptFlash, createSessionQueue, drainOne, @@ -63,6 +64,34 @@ describe("session-queue", () => { 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/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..b94c74a0e 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,99 @@ describe("product skin: stream + queue + overlay", () => { ) }) + test("Ctrl+G cancels the last queued message and the screen shows it", 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, + cancelled: row.cancelled, + })) + expect(before).toEqual([ + { 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, + 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") + } 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() + } + }, + { 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..04b2701e2 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,49 @@ 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) { + // `cancelled` stays a flag, not a `text` rewrite — `paintStreamRow` + // owns turning it into the "[cancelled]" prefix, so `row.text` still + // holds what the operator actually typed for anything else that reads + // it (copy mode, a resumed transcript). + replaceStreamRowAt(shell, index, { ...row, meta: "cancelled", cancelled: true }) + } + } + paintChrome(shell) +} + /** Local interrupt mutation (no bridge re-entry). */ export function applyShellInterrupt(shell: AppShell): void { const had = badgeCount(shell.session) @@ -5308,6 +5349,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..a5473b7cf 100644 --- a/src/tui-opentui/stream.ts +++ b/src/tui-opentui/stream.ts @@ -80,6 +80,19 @@ 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 + /** + * 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 @@ -633,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 {