Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/tui-opentui/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ import {
setSentMessageHistory,
setShellBridgeHooks,
setShellExitHandler,
clearShellBridgeHooks,
setShellRunState,
shellFocusPrompt,
shellFocusTranscript,
streamRowAt,
submitPrompt,
type AppShell,
} from "./shell.js"

Expand Down Expand Up @@ -454,6 +456,38 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
},
},

"Ctrl+G": {
group: "session",
probe: async ({ h, shell, chords }) => {
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
Expand Down
1 change: 1 addition & 0 deletions src/tui-opentui/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
29 changes: 29 additions & 0 deletions src/tui-opentui/session-queue.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import {
badgeCount,
cancelLast,
clearInterruptFlash,
createSessionQueue,
drainOne,
Expand Down Expand Up @@ -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")
Expand Down
16 changes: 16 additions & 0 deletions src/tui-opentui/session-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
94 changes: 94 additions & 0 deletions src/tui-opentui/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { paintStreamRow } from "./stream"
import {
appendStreamRow,
appendTranscript,
applyShellCancelLast,
closeInsetOverlay,
createAppShell,
interruptShell,
Expand Down Expand Up @@ -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) => {
Expand Down
50 changes: 50 additions & 0 deletions src/tui-opentui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ import {
} from "./copy-path.js"
import {
badgeCount,
cancelLast,
clearInterruptFlash,
createSessionQueue,
enqueue,
Expand Down Expand Up @@ -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 →
Expand All @@ -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)
Expand Down Expand Up @@ -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) &&
Expand Down
16 changes: 15 additions & 1 deletion src/tui-opentui/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading