Skip to content

Commit 6af12df

Browse files
Merge queued-message cancellation
2 parents 2084430 + 906a207 commit 6af12df

7 files changed

Lines changed: 239 additions & 1 deletion

File tree

src/tui-opentui/keybindings.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ import {
3434
setSentMessageHistory,
3535
setShellBridgeHooks,
3636
setShellExitHandler,
37+
clearShellBridgeHooks,
3738
setShellRunState,
3839
shellFocusPrompt,
3940
shellFocusTranscript,
4041
streamRowAt,
42+
submitPrompt,
4143
type AppShell,
4244
} from "./shell.js"
4345

@@ -454,6 +456,38 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
454456
},
455457
},
456458

459+
"Ctrl+G": {
460+
group: "session",
461+
probe: async ({ h, shell, chords }) => {
462+
clearShellBridgeHooks(shell)
463+
setShellRunState(shell, "busy")
464+
shell.prompt.value = "keep"
465+
submitPrompt(shell, "queue")
466+
shell.prompt.value = "drop me"
467+
submitPrompt(shell, "queue")
468+
expect(shell.pendingQueue).toBe(2)
469+
470+
press(h, chords[0])
471+
472+
expect(shell.pendingQueue).toBe(1)
473+
expect(shell.session.items[0]!.text).toBe("keep")
474+
const rows = shell.streamLog.map((row) => row.meta)
475+
// The retracted message's row is rewritten, not left claiming "queue"
476+
// as though it will still dispatch (the bug that got the first attempt
477+
// at this pulled).
478+
expect(rows).toEqual(["queue", "cancelled"])
479+
480+
// The chord's whole job is what lands on screen, not the model alone —
481+
// assert on the rendered frame, not just streamLog.
482+
await h.renderOnce()
483+
const frame = h.captureCharFrame()
484+
expect(frame).toContain("[cancelled] drop me")
485+
expect(frame).toContain("keep")
486+
487+
setShellRunState(shell, "idle")
488+
},
489+
},
490+
457491
"Ctrl+D": {
458492
group: "host",
459493
// Probed on a mounted host rather than a bare shell: the row claims a

src/tui-opentui/keybindings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
2323
{ keys: "Enter", description: "queue the message mid-run (badge); send straight through when idle" },
2424
{ keys: "Alt+Enter", description: "steer at the next tool boundary; does nothing unless a run is busy" },
2525
{ keys: "Ctrl+C", description: "interrupt the run, or clear the prompt when idle; press twice to exit" },
26+
{ keys: "Ctrl+G", description: "cancel the most recently queued or steered message before it dispatches" },
2627
{ keys: "Ctrl+O", description: "open the command palette; press again to close it" },
2728
{ keys: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it" },
2829
{ 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" },

src/tui-opentui/session-queue.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from "bun:test"
22
import {
33
badgeCount,
4+
cancelLast,
45
clearInterruptFlash,
56
createSessionQueue,
67
drainOne,
@@ -63,6 +64,34 @@ describe("session-queue", () => {
6364
expect(s.interruptFlash).toBe(false)
6465
})
6566

67+
test("cancelLast retracts the newest queue item", () => {
68+
let s = createSessionQueue("busy")
69+
s = enqueue(s, "keep")
70+
s = enqueue(s, "drop")
71+
const { state, item } = cancelLast(s)
72+
expect(item?.text).toBe("drop")
73+
expect(badgeCount(state)).toBe(1)
74+
expect(state.items[0]!.text).toBe("keep")
75+
})
76+
77+
test("cancelLast retracts the newest steer item, same as queue", () => {
78+
let s = createSessionQueue("busy")
79+
s = enqueue(s, "queued")
80+
s = enqueueSteer(s, "steered")
81+
const { state, item } = cancelLast(s)
82+
expect(item?.kind).toBe("steer")
83+
expect(item?.text).toBe("steered")
84+
expect(badgeCount(state)).toBe(1)
85+
expect(state.items[0]!.kind).toBe("queue")
86+
})
87+
88+
test("cancelLast on an empty queue is a no-op", () => {
89+
const s = createSessionQueue("busy")
90+
const { state, item } = cancelLast(s)
91+
expect(item).toBeNull()
92+
expect(state).toBe(s)
93+
})
94+
6695
test("setRunState toggles busy/idle", () => {
6796
let s = createSessionQueue("idle")
6897
s = setRunState(s, "busy")

src/tui-opentui/session-queue.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,22 @@ export function clearInterruptFlash(
112112
return { ...state, interruptFlash: false }
113113
}
114114

115+
/**
116+
* Retract the most recently enqueued item, queue or steer alike. Last-only:
117+
* an operator who wants an earlier item gone has no path here (see
118+
* `applyShellCancelLast` for why that is the shipped scope, not an oversight).
119+
*/
120+
export function cancelLast(
121+
state: SessionQueueState,
122+
): { state: SessionQueueState; item: QueueItem | null } {
123+
const item = state.items[state.items.length - 1] ?? null
124+
if (item === null) return { state, item: null }
125+
return {
126+
state: { ...state, items: state.items.slice(0, -1) },
127+
item,
128+
}
129+
}
130+
115131
/** Drain order: steers first (FIFO within class), then queue (FIFO). */
116132
export function drainOrder(
117133
state: SessionQueueState,

src/tui-opentui/shell.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { paintStreamRow } from "./stream"
1515
import {
1616
appendStreamRow,
1717
appendTranscript,
18+
applyShellCancelLast,
1819
closeInsetOverlay,
1920
createAppShell,
2021
interruptShell,
@@ -478,6 +479,99 @@ describe("product skin: stream + queue + overlay", () => {
478479
)
479480
})
480481

482+
test("Ctrl+G cancels the last queued message and the screen shows it", async () => {
483+
await withTestRenderer(
484+
async (h) => {
485+
const shell = createAppShell(h.renderer, {
486+
terminal: { columns: 80, rows: 24 },
487+
wireKeys: false,
488+
run: "busy",
489+
})
490+
try {
491+
shell.prompt.value = "keep this one"
492+
submitPrompt(shell, "queue")
493+
shell.prompt.value = "oops wrong message"
494+
submitPrompt(shell, "queue")
495+
expect(shell.pendingQueue).toBe(2)
496+
497+
const before = shell.streamLog.map((row) => ({
498+
text: row.text,
499+
meta: row.meta,
500+
cancelled: row.cancelled,
501+
}))
502+
expect(before).toEqual([
503+
{ text: "keep this one", meta: "queue", cancelled: undefined },
504+
{ text: "oops wrong message", meta: "queue", cancelled: undefined },
505+
])
506+
await h.renderOnce()
507+
const frameBefore = h.captureCharFrame()
508+
expect(frameBefore).toContain("keep this one")
509+
expect(frameBefore).toContain("oops wrong message")
510+
expect(frameBefore).not.toContain("[cancelled]")
511+
512+
applyShellCancelLast(shell)
513+
514+
expect(shell.pendingQueue).toBe(1)
515+
expect(shell.session.items[0]!.text).toBe("keep this one")
516+
517+
const after = shell.streamLog.map((row) => ({
518+
text: row.text,
519+
meta: row.meta,
520+
cancelled: row.cancelled,
521+
}))
522+
// The stored text is untouched — the cancel is a flag the paint
523+
// layer reads, not a rewrite of what the operator typed.
524+
expect(after).toEqual([
525+
{ text: "keep this one", meta: "queue", cancelled: undefined },
526+
{ text: "oops wrong message", meta: "cancelled", cancelled: true },
527+
])
528+
529+
// The screen, not just the model, is asserted on: this is exactly
530+
// what the first attempt at this issue got wrong (the row read
531+
// back unchanged from streamLog while the model looked cancelled).
532+
await h.renderOnce()
533+
const frameAfter = h.captureCharFrame()
534+
expect(frameAfter).toContain("[cancelled] oops wrong message")
535+
expect(frameAfter).toContain("keep this one")
536+
} finally {
537+
shell.dispose()
538+
}
539+
},
540+
{ width: 80, height: 24 },
541+
)
542+
})
543+
544+
test("Ctrl+G cancels a steered message the same way", async () => {
545+
await withTestRenderer(
546+
async (h) => {
547+
const shell = createAppShell(h.renderer, {
548+
terminal: { columns: 80, rows: 24 },
549+
wireKeys: false,
550+
run: "busy",
551+
})
552+
try {
553+
shell.prompt.value = "steer me now"
554+
submitPrompt(shell, "steer")
555+
expect(shell.pendingQueue).toBe(1)
556+
expect(shell.session.items[0]!.kind).toBe("steer")
557+
558+
applyShellCancelLast(shell)
559+
560+
expect(shell.pendingQueue).toBe(0)
561+
expect(shell.session.items).toHaveLength(0)
562+
expect(shell.streamLog[0]?.cancelled).toBe(true)
563+
expect(shell.streamLog[0]?.meta).toBe("cancelled")
564+
565+
await h.renderOnce()
566+
expect(h.captureCharFrame()).toContain("[cancelled] steer me now")
567+
} finally {
568+
shell.dispose()
569+
}
570+
},
571+
{ width: 80, height: 24 },
572+
)
573+
})
574+
481575
test("inset overlay opens; Esc restores prompt focus", async () => {
482576
await withTestRenderer(
483577
async (h) => {

src/tui-opentui/shell.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ import {
148148
} from "./copy-path.js"
149149
import {
150150
badgeCount,
151+
cancelLast,
151152
clearInterruptFlash,
152153
createSessionQueue,
153154
enqueue,
@@ -2880,6 +2881,7 @@ export function submitPrompt(
28802881
kind === "steer"
28812882
? enqueueSteer(shell.session, t, undefined, attachments)
28822883
: enqueue(shell.session, t, "queue", undefined, attachments)
2884+
const queued = shell.session.items[shell.session.items.length - 1]
28832885
shell.prompt.value = ""
28842886
clearPendingAttachments(shell)
28852887
// Show the message itself, not the internal transition ("queue +1 →
@@ -2889,10 +2891,49 @@ export function submitPrompt(
28892891
role: "user",
28902892
text: userRowText(t, attachments),
28912893
meta: kind === "steer" ? "steer" : "queue",
2894+
...(queued !== undefined ? { queueItemId: queued.id } : {}),
28922895
})
28932896
paintChrome(shell)
28942897
}
28952898

2899+
/**
2900+
* Find the transcript row a still-pending queue/steer item echoed, so a
2901+
* cancel can retract it instead of leaving a message tagged "queue" that will
2902+
* never dispatch. Absolute index, matching `replaceStreamRowAt`.
2903+
*/
2904+
function findQueueRowIndex(shell: AppShell, queueItemId: string): number | undefined {
2905+
for (let local = shell.streamLog.length - 1; local >= 0; local--) {
2906+
if (shell.streamLog[local]?.queueItemId === queueItemId) {
2907+
return shell.streamLogBase + local
2908+
}
2909+
}
2910+
return undefined
2911+
}
2912+
2913+
/**
2914+
* Cancel the most recently queued or steered message (last-only: see
2915+
* `cancelLast`'s doc comment for why picking an earlier item is out of
2916+
* scope). Retracts it from the queue and rewrites its transcript row so the
2917+
* readout never shows a message tagged "queue"/"steer" that will not send.
2918+
*/
2919+
export function applyShellCancelLast(shell: AppShell): void {
2920+
const { state, item } = cancelLast(shell.session)
2921+
if (item === null) return
2922+
shell.session = state
2923+
const index = findQueueRowIndex(shell, item.id)
2924+
if (index !== undefined) {
2925+
const row = streamRowAt(shell, index)
2926+
if (row !== undefined) {
2927+
// `cancelled` stays a flag, not a `text` rewrite — `paintStreamRow`
2928+
// owns turning it into the "[cancelled]" prefix, so `row.text` still
2929+
// holds what the operator actually typed for anything else that reads
2930+
// it (copy mode, a resumed transcript).
2931+
replaceStreamRowAt(shell, index, { ...row, meta: "cancelled", cancelled: true })
2932+
}
2933+
}
2934+
paintChrome(shell)
2935+
}
2936+
28962937
/** Local interrupt mutation (no bridge re-entry). */
28972938
export function applyShellInterrupt(shell: AppShell): void {
28982939
const had = badgeCount(shell.session)
@@ -5308,6 +5349,15 @@ export function createAppShell(
53085349
return
53095350
}
53105351

5352+
if (key.ctrl && key.name === "g") {
5353+
// Readline/Emacs "abort" chord — unclaimed by both the textarea's
5354+
// default bindings and this shell's other chords, and already means
5355+
// "cancel the pending thing" to muscle memory, unlike Ctrl+X (cut).
5356+
key.preventDefault()
5357+
applyShellCancelLast(shell)
5358+
return
5359+
}
5360+
53115361
if (
53125362
(key.name === "return" || key.name === "enter") &&
53135363
(key.meta || key.option) &&

src/tui-opentui/stream.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ export type StreamRow = {
8080
* than one `task` call.
8181
*/
8282
readonly callId?: string
83+
/**
84+
* Id of the queue item this row echoes (see `SessionQueueState`). Lets a
85+
* cancel find the exact row a queued/steered message appended, rather than
86+
* guessing by position once other rows have interleaved.
87+
*/
88+
readonly queueItemId?: string
89+
/**
90+
* The queued/steered message this row echoed was cancelled before it
91+
* dispatched. Kept as a flag rather than baked into `text` so the stored
92+
* body stays what the operator actually typed — the paint layer alone
93+
* decides how a cancelled row reads.
94+
*/
95+
readonly cancelled?: boolean
8396
/**
8497
* Row standing for a run of repeated calls. Its subject stays the call the
8598
* run repeats (never a total across them, which would be a claim the
@@ -633,7 +646,8 @@ export function paintStreamRow(
633646
): PaintedStreamLine {
634647
const fg = rowFg(row)
635648
if (row.role === "user") {
636-
return { content: userBubbleLines(row.text, layout.width).join("\n"), fg }
649+
const text = row.cancelled === true ? `[cancelled] ${row.text}` : row.text
650+
return { content: userBubbleLines(text, layout.width).join("\n"), fg }
637651
}
638652
if (isThinkingRow(row)) {
639653
return {

0 commit comments

Comments
 (0)