Skip to content

Commit c75f4d6

Browse files
committed
Collapse queue/steer into one mid-run gesture, add stop-and-reinject, and stop discarding queued input on interrupt
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.
1 parent f6309be commit c75f4d6

10 files changed

Lines changed: 207 additions & 49 deletions

File tree

docs/TUI.md

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -331,10 +331,51 @@ The prompt is a genuine multi-line composing area built on OpenTUI's
331331
`TextareaRenderable` rather than its single-line `InputRenderable`, because
332332
the single-line widget is hard-wired to one row, no wrapping, and strips
333333
newlines (`src/tui-opentui/prompt-input.ts`). Enter sends; a literal newline
334-
needs an explicit chord (Shift+Enter or Ctrl+Enter where the terminal reports
335-
the modifier via the kitty keyboard protocol, Ctrl+J everywhere else, since a
336-
plain terminal cannot report Shift+Enter at all). Alt+Enter is claimed by the
337-
shell before the textarea ever sees it, as the mid-run "steer" action.
334+
needs an explicit chord: Ctrl+Enter or Ctrl+J work on every terminal, and
335+
Shift+Enter works too on a terminal that negotiates the kitty keyboard
336+
protocol (this app requests it — `useKittyKeyboard` in `product-host.ts`) and
337+
reports the modifier back. A plain terminal sends the same bare `\r` for
338+
Enter and Shift+Enter, so on those Shift+Enter silently does nothing — driven
339+
live, this is exactly what happens, not a hypothetical. Ctrl+Enter/Ctrl+J are
340+
the chord to point an operator at when Shift+Enter doesn't respond.
341+
342+
### Queue-and-steer vs. stop-and-reinject
343+
344+
There used to be two gestures that both waited for a run to reach a turn
345+
boundary before delivering — a bug in its own right, since an operator had no
346+
way to tell them apart from the result. There are now two gestures with two
347+
different effects:
348+
349+
- **Enter, mid-run** — queues the message and delivers it at the next turn
350+
boundary, where it steers the run. The queued row in the transcript says
351+
`[will steer next]` while pending and `[steering]` once delivered, so the
352+
operator sees what will happen to it, not just a badge count
353+
(`submitPrompt`, `drainAtBoundary` in `runtime-bridge.ts`).
354+
- **Alt+Enter, mid-run** — stops the run immediately, without waiting for a
355+
boundary, and restarts from this message. A `stop — restarting from your
356+
message` system row and a `[restarted here]` user row mark the cut. Idle,
357+
or with an empty prompt, Alt+Enter does nothing — there is nothing to stop
358+
or restart from.
359+
360+
Interrupting (Ctrl+C) never discards a queued or steered message. It used to
361+
— the transcript literally said `interrupt — discarded N pending`, and an
362+
operator who queued an instruction and then lost patience destroyed the very
363+
thing they were trying to deliver. It now reports `interrupt — N queued
364+
message(s) will steer the next run`: the run stops, the queue survives, and
365+
those messages steer whatever run starts next (`interrupt` in
366+
`session-queue.ts` no longer clears `items`).
367+
368+
**Sub-agent lanes on redirect.** Both Ctrl+C and Alt+Enter interrupt by
369+
closing the underlying agent (`runner.ts`'s `interrupt()` — "the only thing
370+
that aborts the reactor mid-inference"). That close cascades: it aborts the
371+
shared operation signal the `task` tool was given, which the tool forwards to
372+
the child agent's own controller, so an in-flight sub-agent dispatch is
373+
aborted along with the parent's turn and reports back as cancelled by the
374+
operator rather than being left to finish silently detached
375+
(`src/subagent/task-tool.ts`). Redirecting the parent — by either gesture —
376+
is a decision to stop the fleet it dispatched too, not just the parent's own
377+
turn; there is no path today to redirect the parent while leaving running
378+
lanes alone.
338379

339380
Up/Down are caret motion first inside a multi-line buffer. History recall
340381
only fires when the caret is already at the first or last wrapped row of the
@@ -380,7 +421,8 @@ Ctrl+C interrupts a busy run (or clears a non-empty idle prompt); a second
380421
Ctrl+C within a 2-second window (`CTRL_C_EXIT_WINDOW_MS`) quits — this
381422
replaced an Ink-era yes/no exit-confirm modal with the same intent (an
382423
explicit second confirmation) without adding a modal (`handleCtrlC`,
383-
`shell.ts`).
424+
`shell.ts`). See "Queue-and-steer vs. stop-and-reinject" above for what
425+
interrupting does and does not discard, and what it does to sub-agent lanes.
384426

385427
## Overflows, scrolling, and key macros
386428

src/tui-opentui/keybindings.test.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { focusOwner } from "./focus/focus-state.js"
2525
import { setChromeZones } from "./shell.js"
2626
import {
2727
appendStreamRow,
28+
applyShellInterrupt,
2829
createAppShell,
2930
isSlashPopupOpen,
3031
leaveSubagentObserve,
@@ -43,6 +44,7 @@ import {
4344
streamRowAt,
4445
streamRowCount,
4546
submitPrompt,
47+
truncateStreamRows,
4648
type AppShell,
4749
} from "./shell.js"
4850

@@ -265,7 +267,11 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
265267
press(h, chord)
266268
expect(shell.prompt.value).toBe("line\n")
267269
}
268-
// The parenthetical in the row's description, held to the same standard.
270+
// The parenthetical in the row's description, held to the same
271+
// standard. Plain terminals can't report Shift on Enter (bare \r
272+
// either way — confirmed live, not just assumed), but a terminal that
273+
// negotiates the kitty keyboard protocol — which this app requests —
274+
// can, and the widget is built to honor it when it does.
269275
expect(PROMPT_KEY_BINDINGS).toContainEqual({
270276
name: "return",
271277
shift: true,
@@ -440,13 +446,18 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
440446
setShellRunState(shell, "idle")
441447
shell.prompt.value = "not yet"
442448
press(h, chords[0])
443-
// The stated condition: idle, Alt+Enter does nothing at all.
449+
// The stated condition: idle, Alt+Enter does nothing — there's no run
450+
// to stop and nothing to restart from a boundary that isn't coming.
444451
expect(sent).toEqual([])
445452
expect(shell.prompt.value).toBe("not yet")
446453

454+
// Busy: a distinct gesture from plain Enter (queue-and-steer at the
455+
// next boundary) — this one is "reinject", resolved by the bridge to
456+
// stop the run right now and restart from this message.
447457
setShellRunState(shell, "busy")
448458
press(h, chords[0])
449-
expect(sent).toEqual([{ text: "not yet", kind: "steer" }])
459+
expect(sent).toEqual([{ text: "not yet", kind: "reinject" }])
460+
expect(shell.prompt.value).toBe("")
450461
setShellRunState(shell, "idle")
451462
},
452463
},
@@ -472,6 +483,26 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
472483
press(h, chords[0])
473484
expect(exited).toBe(1)
474485
setShellRunState(shell, "idle")
486+
487+
// Bridge-less local interrupt: what an operator sees when a message
488+
// was queued and they lose patience — it must report the message
489+
// will still steer, never that it was discarded.
490+
clearShellBridgeHooks(shell)
491+
setShellRunState(shell, "busy")
492+
const rowsBefore = streamRowCount(shell)
493+
shell.prompt.value = "keep me"
494+
submitPrompt(shell, "queue")
495+
applyShellInterrupt(shell)
496+
expect(shell.pendingQueue).toBe(1)
497+
expect(shell.session.items[0]!.text).toBe("keep me")
498+
const notice = shell.streamLog[shell.streamLog.length - 1]
499+
expect(notice?.text).toBe("interrupt — 1 queued message will steer the next run")
500+
expect(notice?.text).not.toContain("discarded")
501+
// Other probes in this group share one shell — leave both the queue
502+
// and the transcript as this probe found them.
503+
shell.session = { ...shell.session, items: [] }
504+
truncateStreamRows(shell, rowsBefore)
505+
setShellRunState(shell, "idle")
475506
},
476507
},
477508

src/tui-opentui/keybindings.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ export type ShellShortcut = {
2020
}
2121

2222
export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [
23-
{ keys: "Enter", description: "queue the message mid-run (badge); send straight through when idle" },
24-
{ keys: "Alt+Enter", description: "steer at the next tool boundary; does nothing unless a run is busy" },
23+
{ keys: "Enter", description: "queue the message to steer at the next turn boundary (badge); send straight through when idle" },
24+
{ keys: "Alt+Enter", description: "stop the run right now and restart from this message, without waiting for a 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" },
2626
{ keys: "Ctrl+G", description: "cancel the most recently queued or steered message before it dispatches" },
2727
{ keys: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it" },

src/tui-opentui/runtime-bridge.test.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,12 @@ describe("attachSessionBridge", () => {
9191
await h.renderOnce()
9292
expect(port.calls.some((c) => c.op === "enqueue")).toBe(true)
9393
const enq = port.calls.find((c) => c.op === "enqueue")
94+
// Plain Enter mid-run always steers now — "queue and wait quietly"
95+
// isn't a separate gesture from "queue to steer" anymore.
9496
expect(enq).toEqual({
9597
op: "enqueue",
9698
text: "queued please",
97-
kind: "queue",
99+
kind: "steer",
98100
})
99101
expect(badgeCount(shell.session)).toBe(1)
100102
expect(shell.pendingQueue).toBe(1)
@@ -109,7 +111,7 @@ describe("attachSessionBridge", () => {
109111
)
110112
})
111113

112-
test("Alt+Enter mid-run hits port.enqueue steer", async () => {
114+
test("Alt+Enter mid-run hard-stops and reinjects, not a boundary wait", async () => {
113115
await withTestRenderer(
114116
async (h) => {
115117
const shell = createAppShell(h.renderer, {
@@ -121,15 +123,16 @@ describe("attachSessionBridge", () => {
121123
const bridge = attachSessionBridge(shell, port)
122124
try {
123125
// Direct bridge path (Alt+Enter chord is terminal-dependent in mock).
124-
bridge.submit("steer now", "steer")
126+
bridge.submit("stop now", "reinject")
125127
await h.renderOnce()
126-
const enq = port.calls.find((c) => c.op === "enqueue")
127-
expect(enq).toEqual({
128-
op: "enqueue",
129-
text: "steer now",
130-
kind: "steer",
131-
})
132-
expect(badgeCount(shell.session)).toBe(1)
128+
// No enqueue at all — this never waits for a boundary. It
129+
// interrupts the live run, then sends straight through.
130+
expect(port.calls.some((c) => c.op === "enqueue")).toBe(false)
131+
expect(port.calls.map((c) => c.op)).toEqual(["interrupt", "sendImmediate"])
132+
const sent = port.calls.find((c) => c.op === "sendImmediate")
133+
expect(sent).toEqual({ op: "sendImmediate", text: "stop now" })
134+
expect(shell.session.run).toBe("busy")
135+
expect(badgeCount(shell.session)).toBe(0)
133136
} finally {
134137
bridge.dispose()
135138
shell.dispose()
@@ -139,7 +142,7 @@ describe("attachSessionBridge", () => {
139142
)
140143
})
141144

142-
test("Ctrl+C hits port.interrupt and clears pending", async () => {
145+
test("Ctrl+C hits port.interrupt and preserves pending to steer next", async () => {
143146
await withTestRenderer(
144147
async (h) => {
145148
const shell = createAppShell(h.renderer, {
@@ -157,7 +160,9 @@ describe("attachSessionBridge", () => {
157160
h.pressKey("c", { ctrl: true })
158161
await h.renderOnce()
159162
expect(port.calls.some((c) => c.op === "interrupt")).toBe(true)
160-
expect(badgeCount(shell.session)).toBe(0)
163+
// The fix this test now guards: interrupting must never destroy
164+
// what was queued — it survives to steer whatever run comes next.
165+
expect(badgeCount(shell.session)).toBe(2)
161166
expect(shell.session.interruptFlash).toBe(true)
162167
expect(shell.session.run).toBe("idle")
163168
} finally {

src/tui-opentui/runtime-bridge.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
drainOne,
1212
enqueue,
1313
enqueueSteer,
14+
interrupt,
1415
setRunState,
1516
type QueueItem,
1617
type QueueKind,
@@ -158,7 +159,7 @@ export type SessionBridge = {
158159
/** Operator paths — shell keys go through the same logic via exclusive hooks. */
159160
submit: (
160161
text: string,
161-
kind: "queue" | "steer" | "immediate",
162+
kind: "queue" | "steer" | "immediate" | "reinject",
162163
attachments?: readonly PendingImageAttachment[],
163164
) => void
164165
interrupt: () => void
@@ -624,7 +625,9 @@ function drainAtBoundary(shell: AppShell, bag: BridgeBag): void {
624625
appendStreamRow(shell, {
625626
role: "user",
626627
text: userRowText(item.text, item.attachments ?? []),
627-
meta: item.kind === "steer" ? "steer" : "queued",
628+
// Distinct from the "steer" tag on the still-pending row above — this
629+
// one is being handed to the run right now, not waiting for one.
630+
meta: "steering",
628631
})
629632
bag.pendingEchoes.push(item.text.trim())
630633
bag.port.deliver(item)
@@ -901,16 +904,37 @@ export function attachSessionBridge(
901904

902905
const submit = (
903906
text: string,
904-
kind: "queue" | "steer" | "immediate",
907+
kind: "queue" | "steer" | "immediate" | "reinject",
905908
attachments?: readonly PendingImageAttachment[],
906909
): void => {
907910
if (bag.disposed) return
908911
const t = text.trim()
909912
const attached = attachments ?? []
910913
if (t.length === 0 && attached.length === 0) return
911914

912-
if (kind === "immediate" || shell.session.run === "idle") {
913-
appendStreamRow(shell, { role: "user", text: userRowText(t, attached) })
915+
if (kind === "reinject") {
916+
// Not a boundary wait: stop the run right now, then fall straight into
917+
// the immediate-send branch below with this message as the opener.
918+
if (shell.session.run !== "busy") return
919+
closeOpenRow(shell, bag)
920+
bag.pendingEchoes.length = 0
921+
shell.session = interrupt(shell.session)
922+
appendStreamRow(shell, {
923+
role: "system",
924+
text: "stop — restarting from your message",
925+
meta: "stop",
926+
})
927+
bag.port.interrupt()
928+
bag.lastSentMessage = ""
929+
bag.turn = turnStateOnInterrupt(bag.turn, now())
930+
}
931+
932+
if (kind === "immediate" || kind === "reinject" || shell.session.run === "idle") {
933+
appendStreamRow(shell, {
934+
role: "user",
935+
text: userRowText(t, attached),
936+
...(kind === "reinject" ? { meta: "reinject" } : {}),
937+
})
914938
bag.pendingEchoes.push(t)
915939
bag.port.sendImmediate(t, attachments)
916940
shell.session = setRunState(shell.session, "busy")

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,15 @@ describe("session-queue", () => {
5252
expect(d3.item?.text).toBe("q1")
5353
})
5454

55-
test("Ctrl+C interrupt clears pending + sets flash + idle", () => {
55+
test("Ctrl+C interrupt sets flash + idle, and never discards pending items", () => {
5656
let s = createSessionQueue("busy")
5757
s = enqueue(s, "a")
5858
s = enqueueSteer(s, "b")
5959
s = interrupt(s)
60-
expect(badgeCount(s)).toBe(0)
60+
// The operator's whole complaint: interrupting used to destroy exactly
61+
// what they queued. It must survive, so it can steer the next run.
62+
expect(badgeCount(s)).toBe(2)
63+
expect(s.items.map((i) => i.text)).toEqual(["a", "b"])
6164
expect(s.interruptFlash).toBe(true)
6265
expect(s.run).toBe("idle")
6366
s = clearInterruptFlash(s)

src/tui-opentui/session-queue.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,15 @@ export function enqueueSteer(
9393
}
9494

9595
/**
96-
* Hard interrupt: discard all pending queue + steer, clear flash flag set,
97-
* force run to idle (caller re-sets busy when a new run starts).
96+
* Hard interrupt: stop the run and set the flash flag. Queued/steered items
97+
* are preserved — an operator who queued a message and then interrupted must
98+
* never lose it; the items steer whichever run picks them up next.
9899
*/
99100
export function interrupt(state: SessionQueueState): SessionQueueState {
100101
return {
102+
...state,
101103
run: "idle",
102-
items: [],
103104
interruptFlash: true,
104-
nextId: state.nextId,
105105
}
106106
}
107107

src/tui-opentui/shell.test.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ describe("product skin: stream + queue + overlay", () => {
448448
)
449449
})
450450

451-
test("Ctrl+C interrupt clears pending + flash", async () => {
451+
test("Ctrl+C interrupt sets flash, and preserves pending to steer next", async () => {
452452
await withTestRenderer(
453453
async (h) => {
454454
const shell = createAppShell(h.renderer, {
@@ -463,14 +463,18 @@ describe("product skin: stream + queue + overlay", () => {
463463
submitPrompt(shell, "steer")
464464
expect(shell.pendingQueue).toBe(2)
465465
interruptShell(shell)
466-
expect(shell.pendingQueue).toBe(0)
466+
// The bug this guards: interrupting must never discard what was
467+
// queued — it stays queued to steer whatever run comes next.
468+
expect(shell.pendingQueue).toBe(2)
467469
expect(shell.session.interruptFlash).toBe(true)
468470
expect(shell.session.run).toBe("idle")
469471
await h.renderOnce()
472+
const interruptRow = shell.streamLog[shell.streamLog.length - 1]
473+
expect(interruptRow?.text).toBe(
474+
"interrupt — 2 queued messages will steer the next run",
475+
)
470476
const row = noticeRow(h.captureCharFrame())
471477
expect(row).toContain("interrupt")
472-
// An empty queue is the default state, so it stays off the row.
473-
expect(row).not.toContain("queue")
474478
} finally {
475479
shell.dispose()
476480
}

0 commit comments

Comments
 (0)