From 1e5af67b260023919b42431c9e277cdbcb496cd7 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 18:34:33 -0700 Subject: [PATCH] Collapse queue/steer into one mid-run gesture, add stop-and-reinject, and stop discarding queued input on interrupt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain Enter and Alt+Enter both waited for a turn boundary before delivering, so an operator had two gestures with the same effect and no way to tell them apart. Alt+Enter now hard-stops the run and restarts from the typed message without waiting for a boundary; plain Enter always queues to steer at the next boundary, and the transcript row says so plainly ([will steer next] / [steering]) instead of leaving the operator to infer it from a badge count. Interrupting used to discard every queued and steered message ("interrupt — discarded N pending"). An operator who queued an instruction and then lost patience was destroying the thing they were trying to deliver. Interrupt no longer clears the queue; it reports what will steer the next run instead. Verified live: Shift+Enter does insert a newline, but only on a terminal that negotiates the kitty keyboard protocol (this app requests it); on a plain terminal Enter and Shift+Enter send the same bare \r, so the chord is silently a no-op there. Ctrl+Enter/Ctrl+J remain the newline chord that works everywhere, and the shortcut list's existing wording already reflects that condition rather than promising it unconditionally. Both interrupt paths close the underlying agent, which cascades an abort into any in-flight sub-agent dispatch (task-tool.ts forwards the parent's operation signal into the child's own controller) — redirecting the parent stops the fleet it dispatched too, not just its own turn. Documented in docs/TUI.md. --- docs/TUI.md | 62 ++++++++++++++++++++++---- src/tui-opentui/keybindings.test.ts | 37 +++++++++++++-- src/tui-opentui/keybindings.ts | 4 +- src/tui-opentui/runtime-bridge.test.ts | 23 +++++----- src/tui-opentui/runtime-bridge.ts | 34 +++++++++++--- src/tui-opentui/shell.test.ts | 4 ++ src/tui-opentui/shell.ts | 49 ++++++++++++++++---- src/tui-opentui/stream.ts | 16 ++++++- 8 files changed, 189 insertions(+), 40 deletions(-) diff --git a/docs/TUI.md b/docs/TUI.md index b956aaf83..bc82b73cb 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -430,10 +430,52 @@ The prompt is a genuine multi-line composing area built on OpenTUI's `TextareaRenderable` rather than its single-line `InputRenderable`, because the single-line widget is hard-wired to one row, no wrapping, and strips newlines (`src/tui-opentui/prompt-input.ts`). Enter sends; a literal newline -needs an explicit chord (Shift+Enter or Ctrl+Enter where the terminal reports -the modifier via the kitty keyboard protocol, Ctrl+J everywhere else, since a -plain terminal cannot report Shift+Enter at all). Alt+Enter is claimed by the -shell before the textarea ever sees it, as the mid-run "steer" action. +needs an explicit chord: Ctrl+Enter or Ctrl+J work on every terminal, and +Shift+Enter works too on a terminal that negotiates the kitty keyboard +protocol (this app requests it — `useKittyKeyboard` in `product-host.ts`) and +reports the modifier back. A plain terminal sends the same bare `\r` for +Enter and Shift+Enter, so on those Shift+Enter silently does nothing — driven +live, this is exactly what happens, not a hypothetical. Ctrl+Enter/Ctrl+J are +the chord to point an operator at when Shift+Enter doesn't respond. + +### Queue-and-steer vs. stop-and-reinject + +There used to be two gestures that both waited for a run to reach a turn +boundary before delivering — a bug in its own right, since an operator had no +way to tell them apart from the result. There are now two gestures with two +different effects: + +- **Enter, mid-run** — queues the message and delivers it at the next turn + boundary, where it steers the run. The queued row in the transcript says + `[will steer next]` while pending and `[steering]` once delivered, so the + operator sees what will happen to it, not just a badge count + (`submitPrompt`, `drainAtBoundary` in `runtime-bridge.ts`). +- **Alt+Enter, mid-run** — stops the run immediately, without waiting for a + boundary, and restarts from this message. A `stop — restarting from your + message` system row and a `[restarted here]` user row mark the cut. Idle, + or with an empty prompt, Alt+Enter does nothing — there is nothing to stop + or restart from. + +Interrupting (Ctrl+C) never discards a queued or steered message. It used to +— the transcript literally said `interrupt — discarded N pending`, and an +operator who queued an instruction and then lost patience destroyed the very +thing they were trying to deliver. It now reports `interrupt — N pending +kept`: the run stops, the queue survives, and those messages are handed over +at the interrupt itself (`doInterrupt` drains after `port.interrupt()`), not +left waiting on an idle event the stop may never produce (`interrupt` in +`session-queue.ts` no longer clears `items`). + +**Sub-agent lanes on redirect.** Both Ctrl+C and Alt+Enter interrupt by +closing the underlying agent (`runner.ts`'s `interrupt()` — "the only thing +that aborts the reactor mid-inference"). That close cascades: it aborts the +shared operation signal the `task` tool was given, which the tool forwards to +the child agent's own controller, so an in-flight sub-agent dispatch is +aborted along with the parent's turn and reports back as cancelled by the +operator rather than being left to finish silently detached +(`src/subagent/task-tool.ts`). Redirecting the parent — by either gesture — +is a decision to stop the fleet it dispatched too, not just the parent's own +turn; there is no path today to redirect the parent while leaving running +lanes alone. Up/Down are caret motion first inside a multi-line buffer. History recall only fires when the caret is already at the first or last wrapped row of the @@ -479,11 +521,13 @@ Ctrl+C interrupts a busy run (or clears a non-empty idle prompt); a second Ctrl+C within a 2-second window (`CTRL_C_EXIT_WINDOW_MS`) quits — this replaced an Ink-era yes/no exit-confirm modal with the same intent (an explicit second confirmation) without adding a modal (`handleCtrlC`, -`shell.ts`). The interrupt keeps whatever is sitting in the queue rather than -discarding it — the operator typed those messages meaning them delivered, not -meaning "cancel this run and also throw away what I typed"; the transcript -row says so (`"interrupt — N pending kept"`). Kept items are handed over at -the interrupt itself (`doInterrupt` in `runtime-bridge.ts` drains after +`shell.ts`). See "Queue-and-steer vs. stop-and-reinject" above for the two +mid-run gestures and what interrupting does to sub-agent lanes. The interrupt +keeps whatever is sitting in the queue rather than discarding it — the +operator typed those messages meaning them delivered, not meaning "cancel +this run and also throw away what I typed"; the transcript row says so +(`"interrupt — N pending kept"`). Kept items are handed over at the +interrupt itself (`doInterrupt` in `runtime-bridge.ts` drains after `port.interrupt()`), serialized behind the agent rebuild the stop starts — a stop does not reliably produce an idle event to drain against later. diff --git a/src/tui-opentui/keybindings.test.ts b/src/tui-opentui/keybindings.test.ts index 90032c818..9e18937ba 100644 --- a/src/tui-opentui/keybindings.test.ts +++ b/src/tui-opentui/keybindings.test.ts @@ -25,6 +25,7 @@ import { focusOwner } from "./focus/focus-state.js" import { setChromeZones } from "./shell.js" import { appendStreamRow, + applyShellInterrupt, createAppShell, isSlashPopupOpen, leaveSubagentObserve, @@ -43,6 +44,7 @@ import { streamRowAt, streamRowCount, submitPrompt, + truncateStreamRows, type AppShell, } from "./shell.js" @@ -265,7 +267,11 @@ const PROBES: Readonly { await h.renderOnce() expect(port.calls.some((c) => c.op === "enqueue")).toBe(true) const enq = port.calls.find((c) => c.op === "enqueue") + // Plain Enter mid-run always steers now — "queue and wait quietly" + // isn't a separate gesture from "queue to steer" anymore. expect(enq).toEqual({ op: "enqueue", text: "queued please", - kind: "queue", + kind: "steer", }) expect(badgeCount(shell.session)).toBe(1) expect(shell.pendingQueue).toBe(1) @@ -109,7 +111,7 @@ describe("attachSessionBridge", () => { ) }) - test("Alt+Enter mid-run hits port.enqueue steer", async () => { + test("Alt+Enter mid-run hard-stops and reinjects, not a boundary wait", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -121,15 +123,16 @@ describe("attachSessionBridge", () => { const bridge = attachSessionBridge(shell, port) try { // Direct bridge path (Alt+Enter chord is terminal-dependent in mock). - bridge.submit("steer now", "steer") + bridge.submit("stop now", "reinject") await h.renderOnce() - const enq = port.calls.find((c) => c.op === "enqueue") - expect(enq).toEqual({ - op: "enqueue", - text: "steer now", - kind: "steer", - }) - expect(badgeCount(shell.session)).toBe(1) + // No enqueue at all — this never waits for a boundary. It + // interrupts the live run, then sends straight through. + expect(port.calls.some((c) => c.op === "enqueue")).toBe(false) + expect(port.calls.map((c) => c.op)).toEqual(["interrupt", "sendImmediate"]) + const sent = port.calls.find((c) => c.op === "sendImmediate") + expect(sent).toEqual({ op: "sendImmediate", text: "stop now" }) + expect(shell.session.run).toBe("busy") + expect(badgeCount(shell.session)).toBe(0) } finally { bridge.dispose() shell.dispose() diff --git a/src/tui-opentui/runtime-bridge.ts b/src/tui-opentui/runtime-bridge.ts index dac8b47bc..1e25c7bf1 100644 --- a/src/tui-opentui/runtime-bridge.ts +++ b/src/tui-opentui/runtime-bridge.ts @@ -11,6 +11,7 @@ import { drainOne, enqueue, enqueueSteer, + interrupt, setRunState, type QueueItem, type QueueKind, @@ -162,7 +163,7 @@ export type SessionBridge = { /** Operator paths — shell keys go through the same logic via exclusive hooks. */ submit: ( text: string, - kind: "queue" | "steer" | "immediate", + kind: "queue" | "steer" | "immediate" | "reinject", attachments?: readonly PendingImageAttachment[], ) => void interrupt: () => void @@ -634,7 +635,9 @@ function drainAtBoundary(shell: AppShell, bag: BridgeBag): void { appendStreamRow(shell, { role: "user", text: userRowText(item.text, item.attachments ?? []), - meta: item.kind === "steer" ? "steer" : "queued", + // Distinct from the "steer" tag on the still-pending row above — this + // one is being handed to the run right now, not waiting for one. + meta: "steering", }) bag.pendingEchoes.push(item.text.trim()) bag.port.deliver(item) @@ -913,7 +916,7 @@ export function attachSessionBridge( const submit = ( text: string, - kind: "queue" | "steer" | "immediate", + kind: "queue" | "steer" | "immediate" | "reinject", attachments?: readonly PendingImageAttachment[], ): void => { if (bag.disposed) return @@ -921,8 +924,29 @@ export function attachSessionBridge( const attached = attachments ?? [] if (t.length === 0 && attached.length === 0) return - if (kind === "immediate" || shell.session.run === "idle") { - appendStreamRow(shell, { role: "user", text: userRowText(t, attached) }) + if (kind === "reinject") { + // Not a boundary wait: stop the run right now, then fall straight into + // the immediate-send branch below with this message as the opener. + if (shell.session.run !== "busy") return + closeOpenRow(shell, bag) + bag.pendingEchoes.length = 0 + shell.session = interrupt(shell.session) + appendStreamRow(shell, { + role: "system", + text: "stop — restarting from your message", + meta: "stop", + }) + bag.port.interrupt() + bag.lastSentMessage = "" + bag.turn = turnStateOnInterrupt(bag.turn, now()) + } + + if (kind === "immediate" || kind === "reinject" || shell.session.run === "idle") { + appendStreamRow(shell, { + role: "user", + text: userRowText(t, attached), + ...(kind === "reinject" ? { meta: "reinject" } : {}), + }) bag.pendingEchoes.push(t) bag.port.sendImmediate(t, attachments) shell.session = setRunState(shell.session, "busy") diff --git a/src/tui-opentui/shell.test.ts b/src/tui-opentui/shell.test.ts index caf120139..473725956 100644 --- a/src/tui-opentui/shell.test.ts +++ b/src/tui-opentui/shell.test.ts @@ -467,6 +467,10 @@ describe("product skin: stream + queue + overlay", () => { expect(shell.session.interruptFlash).toBe(true) expect(shell.session.run).toBe("idle") await h.renderOnce() + const interruptRow = shell.streamLog[shell.streamLog.length - 1] + expect(interruptRow?.text).toBe( + "interrupt — 2 pending kept", + ) const row = noticeRow(h.captureCharFrame()) expect(row).toContain("interrupt") } finally { diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index daf267b60..d3dc44ccf 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -222,7 +222,7 @@ export function clearShellExitHandler(shell: AppShell): void { export type ShellBridgeHooks = { onSubmit: ( text: string, - kind: "queue" | "steer" | "immediate", + kind: "queue" | "steer" | "immediate" | "reinject", attachments?: readonly PendingImageAttachment[], ) => void onInterrupt: () => void @@ -3068,15 +3068,24 @@ export function userRowText( return text.length === 0 ? `[${summary}]` : `${text}\n[${summary}]` } -/** Submit prompt as queue (busy) or immediate user send (idle). */ +/** + * Submit the prompt. Three kinds, three distinct gestures: + * - "queue": mid-run send — steers at the next turn boundary (badge). + * - "reinject": hard-stop the run right now and restart from this message, + * without waiting for a boundary. No-op when the run isn't busy, or the + * prompt is empty — there's nothing to stop or restart from. + * - Idle sends (either kind) go straight through immediately; "kind" only + * matters while a run is in flight. + */ export function submitPrompt( shell: AppShell, - kind: "queue" | "steer" = "queue", + kind: "queue" | "steer" | "reinject" = "queue", ): void { const text = shell.prompt.value const t = text.trim() const attachments = shell.pendingAttachments if (t.length === 0 && attachments.length === 0) return + if (kind === "reinject" && shell.session.run !== "busy") return // Shell/REPL muscle memory: a bare `exit` or `quit` quits rather than being // sent to the model. Attachments mean the operator meant it as a message. @@ -3094,12 +3103,30 @@ export function submitPrompt( if (hooks?.exclusive) { shell.prompt.value = "" clearPendingAttachments(shell) - const resolved: "queue" | "steer" | "immediate" = - shell.session.run === "idle" ? "immediate" : kind + const resolved: "queue" | "steer" | "immediate" | "reinject" = + kind === "reinject" ? "reinject" : shell.session.run === "idle" ? "immediate" : kind hooks.onSubmit(text, resolved, attachments) return } + if (kind === "reinject") { + shell.session = interrupt(shell.session) + shell.prompt.value = "" + clearPendingAttachments(shell) + appendStreamRow(shell, { + role: "system", + text: "stop — restarting from your message", + meta: "stop", + }) + appendStreamRow(shell, { + role: "user", + text: userRowText(t, attachments), + meta: "reinject", + }) + paintChrome(shell) + return + } + if (shell.session.run === "idle") { appendStreamRow(shell, { role: "user", text: t }) shell.prompt.value = "" @@ -5596,17 +5623,21 @@ export function createAppShell( (key.meta || key.option) && !key.ctrl ) { + // Alt+Enter: stop-and-reinject — the one gesture that doesn't wait for + // a boundary. Plain Enter (below) already covers "queue to steer at + // the next boundary", so this chord's whole job is skipping the wait. key.preventDefault() - if (shell.session.run === "busy") { - submitPrompt(shell, "steer") - } + submitPrompt(shell, "reinject") return } } const onEnter = (): void => { if (disposed || shell.overlayList) return - submitPrompt(shell, "queue") + // Every mid-run send steers — there is no longer a plain "queue and wait + // quietly" gesture distinct from it (that's what collapsed into Alt+Enter + // stop-and-reinject instead). Idle sends ignore "kind" entirely. + submitPrompt(shell, "steer") } // Per frame rather than per keystroke: the editor view's wrapped-line table is diff --git a/src/tui-opentui/stream.ts b/src/tui-opentui/stream.ts index a5473b7cf..8655a7f22 100644 --- a/src/tui-opentui/stream.ts +++ b/src/tui-opentui/stream.ts @@ -646,8 +646,20 @@ export function paintStreamRow( ): PaintedStreamLine { const fg = rowFg(row) if (row.role === "user") { - const text = row.cancelled === true ? `[cancelled] ${row.text}` : row.text - return { content: userBubbleLines(text, layout.width).join("\n"), fg } + // 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. + const prefix = + row.cancelled === true + ? "[cancelled] " + : row.meta === "steer" + ? "[will steer next] " + : row.meta === "steering" + ? "[steering] " + : row.meta === "reinject" + ? "[restarted here] " + : "" + return { content: userBubbleLines(`${prefix}${row.text}`, layout.width).join("\n"), fg } } if (isThinkingRow(row)) { return {