Skip to content

Commit 1e5af67

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 48908d2 commit 1e5af67

8 files changed

Lines changed: 189 additions & 40 deletions

File tree

docs/TUI.md

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -430,10 +430,52 @@ The prompt is a genuine multi-line composing area built on OpenTUI's
430430
`TextareaRenderable` rather than its single-line `InputRenderable`, because
431431
the single-line widget is hard-wired to one row, no wrapping, and strips
432432
newlines (`src/tui-opentui/prompt-input.ts`). Enter sends; a literal newline
433-
needs an explicit chord (Shift+Enter or Ctrl+Enter where the terminal reports
434-
the modifier via the kitty keyboard protocol, Ctrl+J everywhere else, since a
435-
plain terminal cannot report Shift+Enter at all). Alt+Enter is claimed by the
436-
shell before the textarea ever sees it, as the mid-run "steer" action.
433+
needs an explicit chord: Ctrl+Enter or Ctrl+J work on every terminal, and
434+
Shift+Enter works too on a terminal that negotiates the kitty keyboard
435+
protocol (this app requests it — `useKittyKeyboard` in `product-host.ts`) and
436+
reports the modifier back. A plain terminal sends the same bare `\r` for
437+
Enter and Shift+Enter, so on those Shift+Enter silently does nothing — driven
438+
live, this is exactly what happens, not a hypothetical. Ctrl+Enter/Ctrl+J are
439+
the chord to point an operator at when Shift+Enter doesn't respond.
440+
441+
### Queue-and-steer vs. stop-and-reinject
442+
443+
There used to be two gestures that both waited for a run to reach a turn
444+
boundary before delivering — a bug in its own right, since an operator had no
445+
way to tell them apart from the result. There are now two gestures with two
446+
different effects:
447+
448+
- **Enter, mid-run** — queues the message and delivers it at the next turn
449+
boundary, where it steers the run. The queued row in the transcript says
450+
`[will steer next]` while pending and `[steering]` once delivered, so the
451+
operator sees what will happen to it, not just a badge count
452+
(`submitPrompt`, `drainAtBoundary` in `runtime-bridge.ts`).
453+
- **Alt+Enter, mid-run** — stops the run immediately, without waiting for a
454+
boundary, and restarts from this message. A `stop — restarting from your
455+
message` system row and a `[restarted here]` user row mark the cut. Idle,
456+
or with an empty prompt, Alt+Enter does nothing — there is nothing to stop
457+
or restart from.
458+
459+
Interrupting (Ctrl+C) never discards a queued or steered message. It used to
460+
— the transcript literally said `interrupt — discarded N pending`, and an
461+
operator who queued an instruction and then lost patience destroyed the very
462+
thing they were trying to deliver. It now reports `interrupt — N pending
463+
kept`: the run stops, the queue survives, and those messages are handed over
464+
at the interrupt itself (`doInterrupt` drains after `port.interrupt()`), not
465+
left waiting on an idle event the stop may never produce (`interrupt` in
466+
`session-queue.ts` no longer clears `items`).
467+
468+
**Sub-agent lanes on redirect.** Both Ctrl+C and Alt+Enter interrupt by
469+
closing the underlying agent (`runner.ts`'s `interrupt()` — "the only thing
470+
that aborts the reactor mid-inference"). That close cascades: it aborts the
471+
shared operation signal the `task` tool was given, which the tool forwards to
472+
the child agent's own controller, so an in-flight sub-agent dispatch is
473+
aborted along with the parent's turn and reports back as cancelled by the
474+
operator rather than being left to finish silently detached
475+
(`src/subagent/task-tool.ts`). Redirecting the parent — by either gesture —
476+
is a decision to stop the fleet it dispatched too, not just the parent's own
477+
turn; there is no path today to redirect the parent while leaving running
478+
lanes alone.
437479

438480
Up/Down are caret motion first inside a multi-line buffer. History recall
439481
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
479521
Ctrl+C within a 2-second window (`CTRL_C_EXIT_WINDOW_MS`) quits — this
480522
replaced an Ink-era yes/no exit-confirm modal with the same intent (an
481523
explicit second confirmation) without adding a modal (`handleCtrlC`,
482-
`shell.ts`). The interrupt keeps whatever is sitting in the queue rather than
483-
discarding it — the operator typed those messages meaning them delivered, not
484-
meaning "cancel this run and also throw away what I typed"; the transcript
485-
row says so (`"interrupt — N pending kept"`). Kept items are handed over at
486-
the interrupt itself (`doInterrupt` in `runtime-bridge.ts` drains after
524+
`shell.ts`). See "Queue-and-steer vs. stop-and-reinject" above for the two
525+
mid-run gestures and what interrupting does to sub-agent lanes. The interrupt
526+
keeps whatever is sitting in the queue rather than discarding it — the
527+
operator typed those messages meaning them delivered, not meaning "cancel
528+
this run and also throw away what I typed"; the transcript row says so
529+
(`"interrupt — N pending kept"`). Kept items are handed over at the
530+
interrupt itself (`doInterrupt` in `runtime-bridge.ts` drains after
487531
`port.interrupt()`), serialized behind the agent rebuild the stop starts —
488532
a stop does not reliably produce an idle event to drain against later.
489533

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 pending kept")
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: 13 additions & 10 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()

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,
@@ -162,7 +163,7 @@ export type SessionBridge = {
162163
/** Operator paths — shell keys go through the same logic via exclusive hooks. */
163164
submit: (
164165
text: string,
165-
kind: "queue" | "steer" | "immediate",
166+
kind: "queue" | "steer" | "immediate" | "reinject",
166167
attachments?: readonly PendingImageAttachment[],
167168
) => void
168169
interrupt: () => void
@@ -634,7 +635,9 @@ function drainAtBoundary(shell: AppShell, bag: BridgeBag): void {
634635
appendStreamRow(shell, {
635636
role: "user",
636637
text: userRowText(item.text, item.attachments ?? []),
637-
meta: item.kind === "steer" ? "steer" : "queued",
638+
// Distinct from the "steer" tag on the still-pending row above — this
639+
// one is being handed to the run right now, not waiting for one.
640+
meta: "steering",
638641
})
639642
bag.pendingEchoes.push(item.text.trim())
640643
bag.port.deliver(item)
@@ -913,16 +916,37 @@ export function attachSessionBridge(
913916

914917
const submit = (
915918
text: string,
916-
kind: "queue" | "steer" | "immediate",
919+
kind: "queue" | "steer" | "immediate" | "reinject",
917920
attachments?: readonly PendingImageAttachment[],
918921
): void => {
919922
if (bag.disposed) return
920923
const t = text.trim()
921924
const attached = attachments ?? []
922925
if (t.length === 0 && attached.length === 0) return
923926

924-
if (kind === "immediate" || shell.session.run === "idle") {
925-
appendStreamRow(shell, { role: "user", text: userRowText(t, attached) })
927+
if (kind === "reinject") {
928+
// Not a boundary wait: stop the run right now, then fall straight into
929+
// the immediate-send branch below with this message as the opener.
930+
if (shell.session.run !== "busy") return
931+
closeOpenRow(shell, bag)
932+
bag.pendingEchoes.length = 0
933+
shell.session = interrupt(shell.session)
934+
appendStreamRow(shell, {
935+
role: "system",
936+
text: "stop — restarting from your message",
937+
meta: "stop",
938+
})
939+
bag.port.interrupt()
940+
bag.lastSentMessage = ""
941+
bag.turn = turnStateOnInterrupt(bag.turn, now())
942+
}
943+
944+
if (kind === "immediate" || kind === "reinject" || shell.session.run === "idle") {
945+
appendStreamRow(shell, {
946+
role: "user",
947+
text: userRowText(t, attached),
948+
...(kind === "reinject" ? { meta: "reinject" } : {}),
949+
})
926950
bag.pendingEchoes.push(t)
927951
bag.port.sendImmediate(t, attachments)
928952
shell.session = setRunState(shell.session, "busy")

src/tui-opentui/shell.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,10 @@ describe("product skin: stream + queue + overlay", () => {
467467
expect(shell.session.interruptFlash).toBe(true)
468468
expect(shell.session.run).toBe("idle")
469469
await h.renderOnce()
470+
const interruptRow = shell.streamLog[shell.streamLog.length - 1]
471+
expect(interruptRow?.text).toBe(
472+
"interrupt — 2 pending kept",
473+
)
470474
const row = noticeRow(h.captureCharFrame())
471475
expect(row).toContain("interrupt")
472476
} finally {

src/tui-opentui/shell.ts

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ export function clearShellExitHandler(shell: AppShell): void {
222222
export type ShellBridgeHooks = {
223223
onSubmit: (
224224
text: string,
225-
kind: "queue" | "steer" | "immediate",
225+
kind: "queue" | "steer" | "immediate" | "reinject",
226226
attachments?: readonly PendingImageAttachment[],
227227
) => void
228228
onInterrupt: () => void
@@ -3068,15 +3068,24 @@ export function userRowText(
30683068
return text.length === 0 ? `[${summary}]` : `${text}\n[${summary}]`
30693069
}
30703070

3071-
/** Submit prompt as queue (busy) or immediate user send (idle). */
3071+
/**
3072+
* Submit the prompt. Three kinds, three distinct gestures:
3073+
* - "queue": mid-run send — steers at the next turn boundary (badge).
3074+
* - "reinject": hard-stop the run right now and restart from this message,
3075+
* without waiting for a boundary. No-op when the run isn't busy, or the
3076+
* prompt is empty — there's nothing to stop or restart from.
3077+
* - Idle sends (either kind) go straight through immediately; "kind" only
3078+
* matters while a run is in flight.
3079+
*/
30723080
export function submitPrompt(
30733081
shell: AppShell,
3074-
kind: "queue" | "steer" = "queue",
3082+
kind: "queue" | "steer" | "reinject" = "queue",
30753083
): void {
30763084
const text = shell.prompt.value
30773085
const t = text.trim()
30783086
const attachments = shell.pendingAttachments
30793087
if (t.length === 0 && attachments.length === 0) return
3088+
if (kind === "reinject" && shell.session.run !== "busy") return
30803089

30813090
// Shell/REPL muscle memory: a bare `exit` or `quit` quits rather than being
30823091
// sent to the model. Attachments mean the operator meant it as a message.
@@ -3094,12 +3103,30 @@ export function submitPrompt(
30943103
if (hooks?.exclusive) {
30953104
shell.prompt.value = ""
30963105
clearPendingAttachments(shell)
3097-
const resolved: "queue" | "steer" | "immediate" =
3098-
shell.session.run === "idle" ? "immediate" : kind
3106+
const resolved: "queue" | "steer" | "immediate" | "reinject" =
3107+
kind === "reinject" ? "reinject" : shell.session.run === "idle" ? "immediate" : kind
30993108
hooks.onSubmit(text, resolved, attachments)
31003109
return
31013110
}
31023111

3112+
if (kind === "reinject") {
3113+
shell.session = interrupt(shell.session)
3114+
shell.prompt.value = ""
3115+
clearPendingAttachments(shell)
3116+
appendStreamRow(shell, {
3117+
role: "system",
3118+
text: "stop — restarting from your message",
3119+
meta: "stop",
3120+
})
3121+
appendStreamRow(shell, {
3122+
role: "user",
3123+
text: userRowText(t, attachments),
3124+
meta: "reinject",
3125+
})
3126+
paintChrome(shell)
3127+
return
3128+
}
3129+
31033130
if (shell.session.run === "idle") {
31043131
appendStreamRow(shell, { role: "user", text: t })
31053132
shell.prompt.value = ""
@@ -5596,17 +5623,21 @@ export function createAppShell(
55965623
(key.meta || key.option) &&
55975624
!key.ctrl
55985625
) {
5626+
// Alt+Enter: stop-and-reinject — the one gesture that doesn't wait for
5627+
// a boundary. Plain Enter (below) already covers "queue to steer at
5628+
// the next boundary", so this chord's whole job is skipping the wait.
55995629
key.preventDefault()
5600-
if (shell.session.run === "busy") {
5601-
submitPrompt(shell, "steer")
5602-
}
5630+
submitPrompt(shell, "reinject")
56035631
return
56045632
}
56055633
}
56065634

56075635
const onEnter = (): void => {
56085636
if (disposed || shell.overlayList) return
5609-
submitPrompt(shell, "queue")
5637+
// Every mid-run send steers — there is no longer a plain "queue and wait
5638+
// quietly" gesture distinct from it (that's what collapsed into Alt+Enter
5639+
// stop-and-reinject instead). Idle sends ignore "kind" entirely.
5640+
submitPrompt(shell, "steer")
56105641
}
56115642

56125643
// Per frame rather than per keystroke: the editor view's wrapped-line table is

0 commit comments

Comments
 (0)