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
5 changes: 5 additions & 0 deletions .changeset/cool-tigers-control-c.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions docs/guides/dev-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/cli/dev/tui/message-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
16 changes: 8 additions & 8 deletions packages/eve/src/cli/dev/tui/message-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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}`)}`;
}

Expand Down
24 changes: 12 additions & 12 deletions packages/eve/src/cli/dev/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1201,7 +1201,7 @@ export class EveTUIRunner {
prompt: string | undefined;
inputResponses: readonly InputResponse[] | undefined;
}): Promise<AgentTUIStreamResult> {
// 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: {
Expand Down Expand Up @@ -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.
Expand All @@ -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<void> {
if (turnState.cancelInFlight === true) return;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions packages/eve/src/cli/dev/tui/stream-format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion packages/eve/src/cli/dev/tui/stream-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ export async function* takeUntil<T>(
// 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(() => {});
}
Expand Down
4 changes: 2 additions & 2 deletions packages/eve/src/cli/dev/tui/subagent-pump.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 32 additions & 12 deletions packages/eve/src/cli/dev/tui/terminal-renderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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();

Expand All @@ -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");
Expand All @@ -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);
});
Expand Down Expand Up @@ -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<AgentTUIStreamEvent> | undefined;
const abort = vi.fn();
const cancel = vi.fn();
const rendering = renderer.renderStream(
{
abort,
cancel,
events: new ReadableStream<AgentTUIStreamEvent>({
start(controller) {
streamController = controller;
Expand All @@ -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");

Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
});
Expand Down
Loading
Loading