diff --git a/.changeset/cool-tigers-control-c.md b/.changeset/cool-tigers-control-c.md new file mode 100644 index 000000000..a35f44ec7 --- /dev/null +++ b/.changeset/cool-tigers-control-c.md @@ -0,0 +1,5 @@ +--- +"eve": patch +--- + +Make `Ctrl+C` cooperatively cancel or steer a running turn like `Esc` in the eve dev TUI. At the idle prompt, the first `Ctrl+C` now shows an exit warning and a second consecutive press exits. diff --git a/docs/guides/dev-tui.md b/docs/guides/dev-tui.md index 6c6dbf724..f1a00b12f 100644 --- a/docs/guides/dev-tui.md +++ b/docs/guides/dev-tui.md @@ -71,9 +71,9 @@ Chat and freeform `ask_question` inputs behave like a shell line editor. | Key | Action | | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `Enter` | Submit the message or question response. While a turn is running, queue the message for the next turn. | -| `Esc` | While a turn runs: pop the oldest queued message and steer with it; with nothing queued, press twice to cancel. | +| `Esc` | While a turn runs: pop the oldest queued message and steer with it; with nothing queued, cancel the turn. | | `Shift+Enter` | Insert a newline without sending (needs a terminal that reports modified keys). | -| `Ctrl+C` | Interrupt a running turn. At the chat or freeform-question prompt, clear non-empty input; when empty, quit. | +| `Ctrl+C` | While a turn runs, behave like Esc. At the idle chat prompt, clear input and arm exit; press again to quit. | | `↑` / `↓` | Move between input lines; at a chat-buffer edge, navigate messages you have sent this session. | | `←` / `→`, `Home` / `End`, `Ctrl+A` / `Ctrl+E` | Move the caret; Home/End stay within the current line. | | `Ctrl+U` / `Ctrl+K` / `Ctrl+W` | Delete to the start of the line, to its end, or through the previous word. | @@ -86,7 +86,7 @@ In terminals that support bracketed paste, pasting multi-line text into chat or Sending a message while a turn is still running does not interrupt it — the message joins a queue of up to five, pinned in a panel directly above the input with one line per message. When the turn ends, the queued messages coalesce into the next turn's message. Submit `/cancel` during a turn to cancel it immediately without queueing the command as model input; `/cancel` also works from the idle prompt when a prior stream disconnected while its server turn may still be running. -`Esc` steers instead of waiting: it pops the oldest queued message, cancels the running turn cooperatively, and submits the popped message as the replacement turn. In the transcript, a steered message carries an accent `↑` on its own line above its gutter bar; a queued message that waited for the boundary carries it below. Any remaining messages stay queued behind it. With nothing queued, one `Esc` cancels the turn immediately. Unlike `Ctrl+C` — which drops the stream client-side — a cancelled turn ends cleanly on the server and the session keeps its context; the conversation picks up at the next prompt. +`Esc` and `Ctrl+C` steer instead of waiting: either key pops the oldest queued message, cancels the running turn cooperatively, and submits the popped message as the replacement turn. In the transcript, a steered message carries an accent `↑` on its own line above its gutter bar; a queued message that waited for the boundary carries it below. Any remaining messages stay queued behind it. With nothing queued, either key cancels the turn immediately. A cancelled turn ends cleanly on the server and the session keeps its context; the conversation picks up at the next prompt. If a turn fails terminally (the server session dies or the connection drops), the TUI starts a fresh session and notes it inline so you can keep going. Server-side context resets with the old session. Messages still queued when a turn is interrupted or fails are restored into the next prompt's input instead of being sent blind. diff --git a/packages/eve/src/cli/dev/tui/message-queue.test.ts b/packages/eve/src/cli/dev/tui/message-queue.test.ts index 291cbc3cb..af0181be5 100644 --- a/packages/eve/src/cli/dev/tui/message-queue.test.ts +++ b/packages/eve/src/cli/dev/tui/message-queue.test.ts @@ -113,7 +113,7 @@ describe("renderMessageQueueRows", () => { const rows = render(queue); expect(rows[0]).toContain("↑ Queue 2/5"); - expect(rows[0]).toContain("esc steers with the next message"); + expect(rows[0]).toContain("esc or ctrl+c steers with the next message"); expect(rows[1]).toContain("│ first message"); expect(rows[1]).not.toContain("second line"); expect(rows[2]).toContain("└ second message"); diff --git a/packages/eve/src/cli/dev/tui/message-queue.ts b/packages/eve/src/cli/dev/tui/message-queue.ts index 289002c10..706808d2a 100644 --- a/packages/eve/src/cli/dev/tui/message-queue.ts +++ b/packages/eve/src/cli/dev/tui/message-queue.ts @@ -4,11 +4,11 @@ * * Enter queues the draft (up to {@link MESSAGE_QUEUE_LIMIT}); each queued * message waits for the turn to end, where the whole queue coalesces into - * the next turn's message. Esc pops the oldest message to steer the + * the next turn's message. Esc or Ctrl+C pops the oldest message to steer the * conversation instead of waiting: the renderer requests cooperative turn * cancellation and the runner submits the popped message as the next turn. - * `/cancel` requests cancellation directly. Esc on an empty queue cancels the - * turn immediately without a replacement message. + * `/cancel` requests cancellation directly. Either key on an empty queue + * cancels the turn immediately without a replacement message. * * The renderer owns lifecycle (keys, cancel requests, when the runner drains * the queue); this module only holds the queue state machine and paints rows. @@ -38,7 +38,7 @@ export interface MessageQueueView { readonly full: boolean; /** A popped message is staged and turn cancellation was requested. */ readonly steering: boolean; - /** Esc on an empty queue landed; cancellation was requested. */ + /** A cancel key on an empty queue landed; cancellation was requested. */ readonly cancelling: boolean; } @@ -70,8 +70,8 @@ export class MessageQueue { } /** - * Applies one Esc press. Pops the oldest queued message into the staged - * steer payload while any remain; with an empty queue, requests + * Applies one Esc or Ctrl+C press. Pops the oldest queued message into the + * staged steer payload while any remain; with an empty queue, requests * cancellation immediately. */ handleEscape(): MessageQueueEscapeOutcome { @@ -161,7 +161,7 @@ export interface MessageQueuePanelRowsInput { readonly view: MessageQueueView; readonly width: number; readonly theme: Theme; - /** True while a turn streams — the only state in which Esc steers. */ + /** True while a turn streams — the only state in which keys steer. */ readonly working: boolean; } @@ -205,7 +205,7 @@ function headerBody(view: MessageQueueView, working: boolean, theme: Theme): str return c.dim(`Steering — cancelling the running turn…${remaining}`); } const fullness = view.full ? `${dot}queue full` : ""; - const hint = working ? `${dot}esc steers with the next message` : ""; + const hint = working ? `${dot}esc or ctrl+c steers with the next message` : ""; return `${c.bold("Queue")} ${c.dim(`${count}${fullness}${hint}`)}`; } diff --git a/packages/eve/src/cli/dev/tui/runner.ts b/packages/eve/src/cli/dev/tui/runner.ts index 3229d318b..15861d772 100644 --- a/packages/eve/src/cli/dev/tui/runner.ts +++ b/packages/eve/src/cli/dev/tui/runner.ts @@ -126,7 +126,7 @@ export type AgentTUIStreamResult = { abort?: () => void; /** * Requests cooperative server-side cancellation of the streaming turn - * (`/cancel` or Esc, which steers when a message is queued). Unlike + * (`/cancel`, Esc, or Ctrl+C; the keys steer when a message is queued). Unlike * {@link abort} — which drops the client stream and forces a fresh session — * the server settles the turn as `turn.cancelled` → `session.waiting`, so * the stream reaches its boundary normally and the session keeps its context. @@ -1069,7 +1069,7 @@ export class EveTUIRunner { pendingInputResponses = undefined; prompt = undefined; - // A staged Esc steer message, or messages queued during the turn, + // A staged key-driven steer message, or messages queued during the turn, // submit immediately as the next turn — but only across a clean turn // boundary. A failed session or a lost stream keeps them; the renderer // restores them into the next prompt's editable buffer instead of @@ -1201,7 +1201,7 @@ export class EveTUIRunner { prompt: string | undefined; inputResponses: readonly InputResponse[] | undefined; }): Promise { - // Backs the result's `abort`: the renderer fires it on Ctrl+C so the + // Backs the result's `abort`: lifecycle interruption fires it so the // in-flight stream read settles instead of dangling until server close. const abortController = new AbortController(); const sendInput: { @@ -1251,9 +1251,9 @@ export class EveTUIRunner { /** * Requests cooperative cancellation of the streaming turn and retries - * while the turn stays live. An Esc that lands in the dispatch window — - * after the turn was sent but before the turn workflow claims its cancel - * hook (i.e. before `turn.started` reaches the client) — resolves as a + * while the turn stays live. A key-driven cancel that lands in the dispatch + * window — after the turn was sent but before the turn workflow claims its + * cancel hook (i.e. before `turn.started` reaches the client) — resolves as a * benign `no_active_turn` and would otherwise be silently lost, leaving * the TUI showing "Cancelling…" while the turn runs to completion. * Retrying until the stream reaches its boundary closes that window. @@ -1265,9 +1265,9 @@ export class EveTUIRunner { * complete while the request is in flight, the cancel can land on the * next turn. Closing it needs turn-scoped cancel admission server-side * (the #867 ledger); until then the renderer backstops it — a - * `turn.cancelled` arriving without an Esc in that stream restores the - * submitted message into the prompt instead of losing it. - * Single-flight per turn: repeated Esc presses join the running loop. + * `turn.cancelled` arriving without a local cancel request in that stream + * restores the submitted message into the prompt instead of losing it. + * Single-flight per turn: repeated cancel keys join the running loop. */ async #requestTurnCancellation(turnState: AgentTUITurnState): Promise { if (turnState.cancelInFlight === true) return; @@ -1283,7 +1283,7 @@ export class EveTUIRunner { if (result.status === "accepted") return; } catch { // No accepted session yet or a transport failure — retry below; - // Ctrl+C remains the hard client-side interrupt. + // lifecycle interruption remains the hard client-side escape hatch. } await delayMs(turnCancelRetryDelayMs); } @@ -1834,7 +1834,7 @@ async function* eveEventsToTUIStream( break; case "turn.started": - // Recorded so Esc-driven cancellation can scope its request to the + // Recorded so key-driven cancellation can scope its request to the // turn the user is watching; a cancel that arrives after the // boundary then no-ops instead of hitting the next turn. turnState.turnId = event.data.turnId; @@ -2122,7 +2122,7 @@ async function* eveEventsToTUIStream( break; case "turn.cancelled": - // A cooperative cancel (`/cancel`, Esc, or an Esc steer) — not a failure. + // A cooperative cancel (`/cancel`, Esc, Ctrl+C, or a steer) — not a failure. // `session.waiting` follows and finishes the stream normally. onTurnCancelled?.(); yield* closeOpenParts(textParts, "assistant-complete", stepEpoch); diff --git a/packages/eve/src/cli/dev/tui/stream-format.test.ts b/packages/eve/src/cli/dev/tui/stream-format.test.ts index 7c87f91a6..ce1879e3d 100644 --- a/packages/eve/src/cli/dev/tui/stream-format.test.ts +++ b/packages/eve/src/cli/dev/tui/stream-format.test.ts @@ -309,8 +309,8 @@ describe("takeUntil", () => { expect(cleaned).toBe(false); // The caller aborting the underlying stream settles the pull (mirrors the - // renderer's Ctrl+C firing `result.abort()`); only then does the - // generator's cleanup run. + // renderer firing `result.abort()` on lifecycle interruption); only then + // does the generator's cleanup run. rejectPull(new Error("aborted")); await new Promise((resolve) => setImmediate(resolve)); expect(cleaned).toBe(true); diff --git a/packages/eve/src/cli/dev/tui/stream-format.ts b/packages/eve/src/cli/dev/tui/stream-format.ts index 00067dc62..157e94e72 100644 --- a/packages/eve/src/cli/dev/tui/stream-format.ts +++ b/packages/eve/src/cli/dev/tui/stream-format.ts @@ -323,7 +323,7 @@ export async function* takeUntil( // error) so abandoned generators run their cleanup — e.g. the client // event stream advancing its session cursor. For generator sources this // resolves only once the pending pull settles, so callers that stop - // mid-pull must also abort the underlying stream (the renderer's Ctrl+C + // mid-pull must also abort the underlying stream (a lifecycle interrupt // path fires `result.abort()` for exactly this reason). void iterator.return?.()?.catch(() => {}); } diff --git a/packages/eve/src/cli/dev/tui/subagent-pump.ts b/packages/eve/src/cli/dev/tui/subagent-pump.ts index c4438a7c0..20a54dce0 100644 --- a/packages/eve/src/cli/dev/tui/subagent-pump.ts +++ b/packages/eve/src/cli/dev/tui/subagent-pump.ts @@ -187,8 +187,8 @@ export class SubagentPump { /** * Settles every live run and stops its child stream. Called when the - * parent turn is cancelled (`/cancel`, an Esc steer, or an empty-queue - * Esc): the server cancels the pending descendants, so their sections must + * parent turn is cancelled (`/cancel`, a key-driven steer, or an empty-queue + * cancel): the server cancels the pending descendants, so their sections must * close now. A child still flushing reasoning would otherwise keep painting * stale sections into the next (steered) turn's transcript. Runs stay * registered so a late parent `subagent.completed` settles as a no-op. diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts index e37a4ca95..4175d7f9a 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.test.ts @@ -152,6 +152,7 @@ describe("TerminalRenderer (inline scrollback)", () => { // Ctrl-C at the prompt restores the terminal inside the reader itself; // the runner's teardown-time shutdown() must still print the tag. input.ctrlC(); + input.ctrlC(); await expect(prompt).rejects.toThrow("Interrupted"); renderer.shutdown(); @@ -173,6 +174,7 @@ describe("TerminalRenderer (inline scrollback)", () => { renderer.setSessionId("ses_0123456789"); const prompt = renderer.readPrompt(); input.ctrlC(); + input.ctrlC(); await expect(prompt).rejects.toThrow("Interrupted"); renderer.shutdown(); @@ -186,6 +188,7 @@ describe("TerminalRenderer (inline scrollback)", () => { latest.renderer.setSessionId("ses_second"); const latestPrompt = latest.renderer.readPrompt(); latest.input.ctrlC(); + latest.input.ctrlC(); await expect(latestPrompt).rejects.toThrow("Interrupted"); latest.renderer.shutdown(); expect(latest.screen.snapshot()).toContain("session ses_second"); @@ -207,6 +210,7 @@ describe("TerminalRenderer (inline scrollback)", () => { // A normal teardown removes the last-resort hook. input.ctrlC(); + input.ctrlC(); await expect(prompt).rejects.toThrow("Interrupted"); expect(process.listeners("exit")).toEqual(before); }); @@ -715,13 +719,15 @@ describe("TerminalRenderer (inline scrollback)", () => { renderer.shutdown(); }); - it("interrupts a running response and returns to the prompt without exiting", async () => { + it("cooperatively cancels a running response on Ctrl+C and preserves the prompt", async () => { const { screen, input, renderer } = makeRenderer(); let streamController: ReadableStreamDefaultController | undefined; const abort = vi.fn(); + const cancel = vi.fn(); const rendering = renderer.renderStream( { abort, + cancel, events: new ReadableStream({ start(controller) { streamController = controller; @@ -740,14 +746,16 @@ describe("TerminalRenderer (inline scrollback)", () => { expect(screen.snapshot()).toContain("partial response"); }); - // The first Ctrl+C aborts the in-flight turn and unblocks the render - // loop even though the server stream never closes on its own. Draining - // instead would wait forever for an event that never arrives. input.ctrlC(); + expect(cancel).toHaveBeenCalledOnce(); + expect(abort).not.toHaveBeenCalled(); + expect(screen.snapshot()).toContain("Cancelling turn…"); + + streamController?.enqueue({ type: "turn-cancelled" }); + streamController?.close(); await expect(rendering).resolves.toBeUndefined(); - expect(abort).toHaveBeenCalledTimes(1); - expect(screen.snapshot()).toContain("Interrupted"); + expect(screen.snapshot()).toContain("Cancelled"); expect(input.rawModes).toEqual([true]); expect(screen.rawOutput()).toContain("\x1b[?2004h"); @@ -783,22 +791,29 @@ describe("TerminalRenderer (inline scrollback)", () => { renderer.shutdown(); }); - it("clears a non-empty prompt on Ctrl+C, and quits only when already empty", async () => { - const { input, renderer } = makeRenderer(); + it("clears and arms on the first idle Ctrl+C, then exits on a second press", async () => { + const { screen, input, renderer } = makeRenderer(); const prompt = renderer.readPrompt(); input.type("draft message"); - input.ctrlC(); // first Ctrl+C clears the buffer instead of quitting + input.ctrlC(); + expect(renderer.exitRequested()).toBe(false); + expect(screen.snapshot()).toContain("Press Ctrl+C again to exit"); input.type("real message"); + expect(screen.snapshot()).not.toContain("Press Ctrl+C again to exit"); input.enter(); // The cleared draft is gone (otherwise this would be "draft messagereal message"). expect(await prompt).toBe("real message"); - // A Ctrl+C on the now-empty prompt quits. + // Consecutive Ctrl+C presses on the now-empty prompt quit. const second = renderer.readPrompt(); input.ctrlC(); + expect(renderer.exitRequested()).toBe(false); + expect(screen.snapshot()).toContain("Press Ctrl+C again to exit"); + input.ctrlC(); await expect(second).rejects.toThrow(); + expect(renderer.exitRequested()).toBe(true); renderer.shutdown(); }); @@ -1352,6 +1367,7 @@ describe("TerminalRenderer (inline scrollback)", () => { const second = renderer.readPrompt(); expect(countOccurrences(screen.snapshot(), "└ Done in")).toBe(1); input.ctrlC(); + input.ctrlC(); await expect(second).rejects.toThrow(); renderer.shutdown(); }); @@ -1582,6 +1598,7 @@ describe("TerminalRenderer (inline scrollback)", () => { // crossing the 20K input threshold is what earns the row. expect(screen.snapshot()).toContain("└ Done in 1s ── ↑ 20.5K ↓ 43"); input.ctrlC(); + input.ctrlC(); await expect(second).rejects.toThrow(); renderer.shutdown(); }); @@ -1609,6 +1626,7 @@ describe("TerminalRenderer (inline scrollback)", () => { const second = renderer.readPrompt(); expect(screen.snapshot()).not.toContain("\n└ "); input.ctrlC(); + input.ctrlC(); await expect(second).rejects.toThrow(); renderer.shutdown(); }); @@ -1714,6 +1732,7 @@ describe("TerminalRenderer (inline scrollback)", () => { expect(screen.snapshot()).toContain("└ Done in 12s"); expect(screen.snapshot()).not.toContain("Thought for"); input.ctrlC(); + input.ctrlC(); await expect(second).rejects.toThrow(); renderer.shutdown(); } finally { @@ -2093,8 +2112,8 @@ describe("TerminalRenderer (inline scrollback)", () => { expect(screen.snapshot()).toContain("Queue 2/5"); }); - // A client-side interrupt skips the runner's queue drain… - input.ctrlC(); + // A lifecycle interruption skips the runner's queue drain… + renderer.requestInterrupt(); await rendering; expect(abort).toHaveBeenCalledTimes(1); void streamController; @@ -2136,6 +2155,7 @@ describe("TerminalRenderer (inline scrollback)", () => { const prompt = renderer.readPrompt(); await screen.waitForIdlePrompt(1_000); input.ctrlC(); + input.ctrlC(); await expect(prompt).rejects.toThrow(); renderer.shutdown(); }); diff --git a/packages/eve/src/cli/dev/tui/terminal-renderer.ts b/packages/eve/src/cli/dev/tui/terminal-renderer.ts index 4be21199f..631c74c2d 100644 --- a/packages/eve/src/cli/dev/tui/terminal-renderer.ts +++ b/packages/eve/src/cli/dev/tui/terminal-renderer.ts @@ -469,6 +469,8 @@ export class TerminalRenderer implements AgentTUIRenderer { #partingLinePrinted = false; #interrupted = false; #exitRequested = false; + /** True after the first consecutive Ctrl+C at the idle chat prompt. */ + #exitArmed = false; readonly #onExitRequest?: () => void; #caretVisible = true; #spinnerIndex = 0; @@ -533,13 +535,13 @@ export class TerminalRenderer implements AgentTUIRenderer { #todoCommittedSignature?: string; /** * Messages submitted while a turn streams, pinned in a panel directly - * above the input. Enter queues, `/cancel` cancels directly, and Esc - * pops-to-steer or cancels immediately when empty; the runner drains via - * {@link takeQueuedPrompt} at a clean turn boundary and {@link readPrompt} - * restores any leftovers as a draft. + * above the input. Enter queues, `/cancel` cancels directly, and Esc or + * Ctrl+C pops-to-steer or cancels immediately when empty; the runner drains + * via {@link takeQueuedPrompt} at a clean turn boundary and + * {@link readPrompt} restores any leftovers as a draft. */ readonly #messageQueue = new MessageQueue(); - /** The streaming result's cooperative cancel, armed for Esc while it renders. */ + /** The streaming result's cooperative cancel, available to Esc and Ctrl+C. */ #requestTurnCancel?: () => void; /** Set by the `turn-cancelled` stream event: settle in-flight tool blocks. */ #turnCancelled = false; @@ -654,6 +656,7 @@ export class TerminalRenderer implements AgentTUIRenderer { this.#promptPlaceholderActive = true; this.#turnIndicator = { kind: "idle" }; this.#status = ""; + this.#exitArmed = false; // A draft typed during the turn carries into the prompt; an explicit // initial draft (`eve dev --input`) wins over it. Queued messages the // runner never drained (an interrupted or failed turn) fold back in @@ -709,6 +712,10 @@ export class TerminalRenderer implements AgentTUIRenderer { }; this.#consumeKey = (key) => { + if (key.type !== "ctrl-c" && this.#exitArmed) { + this.#exitArmed = false; + this.#paint(); + } // Chat keeps pasted newlines and honors Shift+Enter. Setup-panel inputs // stay single-line; freeform questions opt in separately below. const edited = applyLineEditorKey(editor, key, { multiline: true }); @@ -811,12 +818,13 @@ export class TerminalRenderer implements AgentTUIRenderer { this.#paint(); break; case "ctrl-c": - if (editor.text.length === 0) { + if (this.#exitArmed) { this.#requestExit(); interrupt(); - } else { - apply(EMPTY_LINE); + break; } + this.#exitArmed = true; + apply(EMPTY_LINE); break; default: break; @@ -2953,15 +2961,6 @@ export class TerminalRenderer implements AgentTUIRenderer { case "ctrl-r": this.#paint(); break; - case "ctrl-c": - if (!this.#interrupted) { - this.#interrupted = true; - this.#turnIndicator = { kind: "idle" }; - this.#status = "Interrupted"; - this.#resolveStreamInterrupt?.(); - this.#paint(); - } - break; case "enter": { const message = this.#streamDraft.text; if (message.trim().length === 0) break; @@ -2979,7 +2978,7 @@ export class TerminalRenderer implements AgentTUIRenderer { break; } // Mid-turn Enter queues the draft as a message for the next turn - // (or for an Esc steer pop). A full queue keeps the draft in place — + // (or for a steer-key pop). A full queue keeps the draft in place — // the panel header says why — rather than silently dropping input. if (this.#messageQueue.enqueue(message)) { this.#streamDraft = EMPTY_LINE; @@ -2987,11 +2986,12 @@ export class TerminalRenderer implements AgentTUIRenderer { this.#paint(); break; } + case "ctrl-c": case "escape": { - // Esc drives steering and cancellation: pop the oldest queued - // message and cancel the running turn so the runner submits it as - // the replacement turn; with nothing queued, cancel immediately. - // Without a cancel capability an empty queue leaves Esc inert. + // Esc and Ctrl+C drive steering and cancellation: pop the oldest + // queued message and cancel the running turn so the runner submits it + // as the replacement turn; with nothing queued, cancel immediately. + // Without a cancel capability an empty queue leaves either key inert. if (this.#messageQueue.idle && this.#requestTurnCancel === undefined) break; this.#messageQueue.handleEscape(); this.#cancelRequestedByUser = true; @@ -3372,8 +3372,8 @@ export class TerminalRenderer implements AgentTUIRenderer { break; case "turn-cancelled": - // The server settled the turn cooperatively (an Esc steer or an - // empty-queue Esc); its in-flight tool calls get no further updates. + // The server settled the turn cooperatively (a key-driven steer or an + // empty-queue cancel); its in-flight tool calls get no further updates. // The interrupted-blocks sweep settles them at stream end. this.#turnCancelled = true; // A cancellation nobody asked for through THIS prompt — a stale @@ -3931,7 +3931,7 @@ export class TerminalRenderer implements AgentTUIRenderer { // The message-queue panel takes the slot directly above the input — // ahead of the todo panel — because it holds the user's own undelivered - // words and carries the Esc steering/cancel affordance. + // words and carries the steering/cancel affordance. const queueRows = renderMessageQueueRows({ view: this.#messageQueue.view(), width, @@ -3953,6 +3953,9 @@ export class TerminalRenderer implements AgentTUIRenderer { ) { rows.push(...renderCommandSuggestions(this.#typeahead, this.#theme, width)); } + if (this.#exitArmed) { + rows.push(clip(c.dim("Press Ctrl+C again to exit"), width), ""); + } // A fully typed known command paints blue, confirming it will dispatch // as a command instead of being sent to the agent as a message. const isCommand = isPromptControlCommand(this.#inputText); diff --git a/packages/eve/test/tui-client/tui-connection-auth-states.ts b/packages/eve/test/tui-client/tui-connection-auth-states.ts index 0eec769b0..2f0960947 100644 --- a/packages/eve/test/tui-client/tui-connection-auth-states.ts +++ b/packages/eve/test/tui-client/tui-connection-auth-states.ts @@ -325,12 +325,13 @@ void (async () => { console.log(theme.muted("[states] failure reason surfaced")); // The turn is complete; wait until the runner is back at the prompt so - // Ctrl+C exits the session. A Ctrl+C mid-stream now only interrupts the - // turn and returns to the prompt (Claude Code's two-step exit). + // Two idle Ctrl+C presses exit; mid-stream Ctrl+C cancels cooperatively. await screen.waitForIdlePrompt(10_000); input.ctrlC(); + input.ctrlC(); await runPromise; } catch (error) { + input.ctrlC(); input.ctrlC(); await runPromise.catch(() => undefined); throw error; diff --git a/packages/eve/test/tui-client/tui-questions-freeform.ts b/packages/eve/test/tui-client/tui-questions-freeform.ts index 1e3c93af7..c1767dfe8 100644 --- a/packages/eve/test/tui-client/tui-questions-freeform.ts +++ b/packages/eve/test/tui-client/tui-questions-freeform.ts @@ -98,10 +98,10 @@ run({ app: "agent-tui-client", kind: "local-build" }, async (target) => { } // The turn is complete; wait until the runner is back at the prompt so - // Ctrl+C exits the session. A Ctrl+C mid-stream now only interrupts the - // turn and returns to the prompt (Claude Code's two-step exit). + // Two idle Ctrl+C presses exit; mid-stream Ctrl+C cancels cooperatively. await screen.waitForIdlePrompt(30_000); input.ctrlC(); + input.ctrlC(); await runPromise; }); diff --git a/packages/eve/test/tui-client/tui-questions.ts b/packages/eve/test/tui-client/tui-questions.ts index 40848792b..1d806daba 100644 --- a/packages/eve/test/tui-client/tui-questions.ts +++ b/packages/eve/test/tui-client/tui-questions.ts @@ -100,10 +100,10 @@ run({ app: "agent-tui-client", kind: "local-build" }, async (target) => { } // The turn is complete; wait until the runner is back at the prompt so - // Ctrl+C exits the session. A Ctrl+C mid-stream now only interrupts the - // turn and returns to the prompt (Claude Code's two-step exit). + // Two idle Ctrl+C presses exit; mid-stream Ctrl+C cancels cooperatively. await screen.waitForIdlePrompt(30_000); input.ctrlC(); + input.ctrlC(); await runPromise; }); diff --git a/packages/eve/test/tui-client/tui-queue-steer.ts b/packages/eve/test/tui-client/tui-queue-steer.ts index 3aa813069..d2e4d9390 100644 --- a/packages/eve/test/tui-client/tui-queue-steer.ts +++ b/packages/eve/test/tui-client/tui-queue-steer.ts @@ -8,12 +8,12 @@ import { run } from "./lib/run.ts"; import { theme } from "./lib/theme.ts"; /** - * End-to-end proof of the mid-turn message queue, Esc steering, and - * single-Esc cooperative cancellation against a live server: + * End-to-end proof of the mid-turn message queue, Ctrl+C steering, single-Esc + * cooperative cancellation, and double-Ctrl+C exit against a live server: * * 1. Start a long turn, then submit two messages while it streams — * both must land in the pinned `↑ Queue n/5` panel, not the turn. - * 2. Esc pops the oldest queued message: the panel flips to Steering, + * 2. Ctrl+C pops the oldest queued message: the panel flips to Steering, * the running turn settles as `turn.cancelled` → `session.waiting` * (cooperative — the session keeps its context), and the popped * message runs as the replacement turn. @@ -67,9 +67,8 @@ run({ app: "agent-tui-client", kind: "local-build" }, async (target) => { await screen.waitForText("Queue 2/5", 10_000); console.log(theme.muted("[tui-queue-steer] two messages queued behind the running turn")); - // Esc pops the oldest message and cooperatively cancels the running turn. - input.emit("data", Buffer.from("\x1b")); - await sleep(60); // the decoder holds a lone ESC briefly (escape-vs-sequence) + // Ctrl+C pops the oldest message and cooperatively cancels the running turn. + input.ctrlC(); await screen.waitForText("Steering", 10_000); console.log(theme.muted("[tui-queue-steer] steering engaged, cancelling the turn")); @@ -129,6 +128,8 @@ run({ app: "agent-tui-client", kind: "local-build" }, async (target) => { await screen.waitForIdlePrompt(60_000); console.log(theme.muted("[tui-queue-steer] preserved session answered after cancellation")); + input.ctrlC(); + await screen.waitForText("Press Ctrl+C again to exit", 5_000); input.ctrlC(); await runPromise; }); diff --git a/packages/eve/test/tui-client/tui-server-logs.ts b/packages/eve/test/tui-client/tui-server-logs.ts index 0b1fee57a..1714f4b6f 100644 --- a/packages/eve/test/tui-client/tui-server-logs.ts +++ b/packages/eve/test/tui-client/tui-server-logs.ts @@ -83,6 +83,7 @@ void (async () => { `${theme.muted("[tui-server-logs] log region rendered, single user region")}\n`, ); } catch (error) { + input.ctrlC(); input.ctrlC(); await runPromise.catch(() => {}); throw error; diff --git a/packages/eve/test/tui-client/tui-subagents-weather.ts b/packages/eve/test/tui-client/tui-subagents-weather.ts index f3561dc4a..aaeb6f848 100644 --- a/packages/eve/test/tui-client/tui-subagents-weather.ts +++ b/packages/eve/test/tui-client/tui-subagents-weather.ts @@ -179,10 +179,10 @@ run(WEATHER_SMOKE_TARGET, async (target) => { } // The turn is complete; wait until the runner is back at the prompt so - // Ctrl+C exits the session. A Ctrl+C mid-stream now only interrupts the - // turn and returns to the prompt (Claude Code's two-step exit). + // Two idle Ctrl+C presses exit; mid-stream Ctrl+C cancels cooperatively. await screen.waitForIdlePrompt(30_000); input.ctrlC(); + input.ctrlC(); await runPromise; }); diff --git a/packages/eve/test/tui-client/tui-subagents.ts b/packages/eve/test/tui-client/tui-subagents.ts index ae7075dcc..8aed4b1da 100644 --- a/packages/eve/test/tui-client/tui-subagents.ts +++ b/packages/eve/test/tui-client/tui-subagents.ts @@ -88,10 +88,10 @@ run({ app: "agent-tui-client", kind: "local-build" }, async (target) => { } // The turn is complete; wait until the runner is back at the prompt so - // Ctrl+C exits the session. A Ctrl+C mid-stream now only interrupts the - // turn and returns to the prompt (Claude Code's two-step exit). + // Two idle Ctrl+C presses exit; mid-stream Ctrl+C cancels cooperatively. await screen.waitForIdlePrompt(30_000); input.ctrlC(); + input.ctrlC(); await runPromise; }); diff --git a/packages/eve/test/tui-client/tui-traces.ts b/packages/eve/test/tui-client/tui-traces.ts index 863c10f44..04fd4d48c 100644 --- a/packages/eve/test/tui-client/tui-traces.ts +++ b/packages/eve/test/tui-client/tui-traces.ts @@ -296,6 +296,7 @@ void (async () => { emptyRunner.input.enter(); await withTimeout(emptyRunner.runPromise, 5_000, "empty /exit did not terminate"); } finally { + emptyRunner.input.ctrlC(); emptyRunner.input.ctrlC(); await emptyRunner.runPromise.catch(() => {}); } diff --git a/packages/eve/test/tui-client/tui-transport-error.ts b/packages/eve/test/tui-client/tui-transport-error.ts index c6d235adb..62fd993bd 100644 --- a/packages/eve/test/tui-client/tui-transport-error.ts +++ b/packages/eve/test/tui-client/tui-transport-error.ts @@ -50,9 +50,11 @@ void (async () => { await screen.waitForIdlePrompt(5_000); console.log(theme.muted("[tui-transport-error] runner returned to prompt")); + input.ctrlC(); input.ctrlC(); await runPromise; } catch (error) { + input.ctrlC(); input.ctrlC(); await runPromise.catch(() => {}); throw error;