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 {