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
62 changes: 53 additions & 9 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
37 changes: 34 additions & 3 deletions src/tui-opentui/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { focusOwner } from "./focus/focus-state.js"
import { setChromeZones } from "./shell.js"
import {
appendStreamRow,
applyShellInterrupt,
createAppShell,
isSlashPopupOpen,
leaveSubagentObserve,
Expand All @@ -43,6 +44,7 @@ import {
streamRowAt,
streamRowCount,
submitPrompt,
truncateStreamRows,
type AppShell,
} from "./shell.js"

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

// Busy: a distinct gesture from plain Enter (queue-and-steer at the
// next boundary) — this one is "reinject", resolved by the bridge to
// stop the run right now and restart from this message.
setShellRunState(shell, "busy")
press(h, chords[0])
expect(sent).toEqual([{ text: "not yet", kind: "steer" }])
expect(sent).toEqual([{ text: "not yet", kind: "reinject" }])
expect(shell.prompt.value).toBe("")
setShellRunState(shell, "idle")
},
},
Expand All @@ -472,6 +483,26 @@ const PROBES: Readonly<Record<string, { readonly group: Group; readonly probe: P
press(h, chords[0])
expect(exited).toBe(1)
setShellRunState(shell, "idle")

// Bridge-less local interrupt: what an operator sees when a message
// was queued and they lose patience — it must report the message
// will still steer, never that it was discarded.
clearShellBridgeHooks(shell)
setShellRunState(shell, "busy")
const rowsBefore = streamRowCount(shell)
shell.prompt.value = "keep me"
submitPrompt(shell, "queue")
applyShellInterrupt(shell)
expect(shell.pendingQueue).toBe(1)
expect(shell.session.items[0]!.text).toBe("keep me")
const notice = shell.streamLog[shell.streamLog.length - 1]
expect(notice?.text).toBe("interrupt — 1 pending kept")
expect(notice?.text).not.toContain("discarded")
// Other probes in this group share one shell — leave both the queue
// and the transcript as this probe found them.
shell.session = { ...shell.session, items: [] }
truncateStreamRows(shell, rowsBefore)
setShellRunState(shell, "idle")
},
},

Expand Down
4 changes: 2 additions & 2 deletions src/tui-opentui/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ export type ShellShortcut = {
}

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: "Enter", description: "queue the message to steer at the next turn boundary (badge); send straight through when idle" },
{ 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" },
{ 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: "Alt+C", description: "copy mode: pick a message, tool output, or diff; press again to close it" },
Expand Down
23 changes: 13 additions & 10 deletions src/tui-opentui/runtime-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,12 @@ describe("attachSessionBridge", () => {
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)
Expand All @@ -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, {
Expand All @@ -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()
Expand Down
34 changes: 29 additions & 5 deletions src/tui-opentui/runtime-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
drainOne,
enqueue,
enqueueSteer,
interrupt,
setRunState,
type QueueItem,
type QueueKind,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -913,16 +916,37 @@ export function attachSessionBridge(

const submit = (
text: string,
kind: "queue" | "steer" | "immediate",
kind: "queue" | "steer" | "immediate" | "reinject",
attachments?: readonly PendingImageAttachment[],
): void => {
if (bag.disposed) return
const t = text.trim()
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")
Expand Down
4 changes: 4 additions & 0 deletions src/tui-opentui/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 40 additions & 9 deletions src/tui-opentui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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 = ""
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading