From a5beb7b34b75d1bbea6f22e4060feea8def926e1 Mon Sep 17 00:00:00 2001 From: CahidArda Date: Sun, 15 Mar 2026 02:16:27 +0300 Subject: [PATCH 1/2] feat: cancel active run on Ctrl+C instead of exiting the REPL Intercepts Ctrl+C via readline's _ttyWrite so that when a shell or agent run is active, the run is cancelled and the user returns to the prompt instead of the entire REPL exiting. Co-Authored-By: Claude Opus 4.6 --- packages/cli/src/repl/client.ts | 50 +++++++++++++++++++++++---- packages/cli/src/repl/commands/run.ts | 7 +++- packages/cli/src/repl/terminal.ts | 15 ++++++++ 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/repl/client.ts b/packages/cli/src/repl/client.ts index 90277c5..3b1b82a 100644 --- a/packages/cli/src/repl/client.ts +++ b/packages/cli/src/repl/client.ts @@ -66,6 +66,23 @@ export class BoxREPLClient { private _suggestion: string | null = "ls"; private _cwdEntries: string[] = []; + /** The currently active streaming run (shell or agent), if any. */ + activeRun: { cancel(): Promise } | null = null; + private _runCancelled = false; + + /** + * Cancel the active run. Called when the user presses Ctrl+C during execution. + * Sets `_runCancelled` flag so the generator can distinguish cancellation from errors. + */ + async cancelActiveRun(): Promise { + const run = this.activeRun; + if (run) { + this._runCancelled = true; + this.activeRun = null; + await run.cancel(); + } + } + constructor(box: Box, options?: BoxREPLClientOptions) { this.box = box; this._onModelConfiguration = options?.onModelConfiguration; @@ -143,10 +160,16 @@ export class BoxREPLClient { /** Execute a shell command in the box, streaming output in real time. */ private async *execShellCommand(command: string): AsyncGenerator { const run = await this.box.exec.stream(command); - for await (const chunk of run) { - if (chunk.type === "output") { - yield { type: "stream", text: chunk.data }; + this.activeRun = run; + this._runCancelled = false; + try { + for await (const chunk of run) { + if (chunk.type === "output") { + yield { type: "stream", text: chunk.data }; + } } + } finally { + this.activeRun = null; } } @@ -263,19 +286,34 @@ export class BoxREPLClient { }; } } catch (err) { - yield { type: "error", message: `Error: ${err instanceof Error ? err.message : err}` }; + if (this._runCancelled) { + yield { type: "stream", text: "\n" }; + yield { type: "log", message: "Cancelled." }; + } else { + yield { type: "error", message: `Error: ${err instanceof Error ? err.message : err}` }; + } } } else { // Agent mode this._suggestion = getNextSuggestion({ kind: "agent", initial: false }); yield { type: "command:start", command: "agent", args: trimmed }; const start = Date.now(); + this._runCancelled = false; try { - yield* handleRun(this.box, trimmed); + yield* handleRun(this.box, trimmed, (run) => { + this.activeRun = run; + }); const durationMs = Date.now() - start; yield { type: "command:complete", command: "agent", durationMs }; } catch (err) { - yield { type: "error", message: `Error: ${err instanceof Error ? err.message : err}` }; + if (this._runCancelled) { + yield { type: "stream", text: "\n" }; + yield { type: "log", message: "Cancelled." }; + } else { + yield { type: "error", message: `Error: ${err instanceof Error ? err.message : err}` }; + } + } finally { + this.activeRun = null; } } } diff --git a/packages/cli/src/repl/commands/run.ts b/packages/cli/src/repl/commands/run.ts index 9e52086..248d639 100644 --- a/packages/cli/src/repl/commands/run.ts +++ b/packages/cli/src/repl/commands/run.ts @@ -67,13 +67,18 @@ export function parseTodoItems(input: Record): TodoItem[] { /** * Run the agent with a prompt, streaming output as events. */ -export async function* handleRun(box: Box, prompt: string): AsyncGenerator { +export async function* handleRun( + box: Box, + prompt: string, + onRunCreated?: (run: { cancel(): Promise }) => void, +): AsyncGenerator { if (!prompt) { yield { type: "log", message: "Usage: run " }; return; } const run = await box.agent.stream({ prompt }); + onRunCreated?.(run); for await (const chunk of run) { if (chunk.type === "text-delta") { diff --git a/packages/cli/src/repl/terminal.ts b/packages/cli/src/repl/terminal.ts index 2081e69..584cfa9 100644 --- a/packages/cli/src/repl/terminal.ts +++ b/packages/cli/src/repl/terminal.ts @@ -172,6 +172,21 @@ export async function startRepl(box: Box, options?: BoxREPLClientOptions): Promi let isMetaReturn = false; if (stdin.isTTY) { + // Intercept Ctrl+C: cancel the active run instead of exiting the REPL. + // Wraps readline's _ttyWrite so the \x03 character never reaches readline + // when there is an active run, preventing it from closing the interface. + const rlAnyTty = rl as unknown as { + _ttyWrite: (s: string, key: Record) => void; + }; + const origTtyWrite = rlAnyTty._ttyWrite.bind(rl); + rlAnyTty._ttyWrite = (s: string, key: Record) => { + if (key?.ctrl && key?.name === "c" && client.activeRun) { + client.cancelActiveRun(); + return; + } + return origTtyWrite(s, key); + }; + // Must run before readline's handler so the cursor is still on the input line stdin.prependListener( "keypress", From fce8c8cfc269bc09d07eb991ee39d0433029027e Mon Sep 17 00:00:00 2001 From: CahidArda Date: Sun, 15 Mar 2026 02:18:02 +0300 Subject: [PATCH 2/2] fix: add check for status after cancel --- .../sdk/src/__tests__/integration/lifecycle.integration.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/sdk/src/__tests__/integration/lifecycle.integration.test.ts b/packages/sdk/src/__tests__/integration/lifecycle.integration.test.ts index b3b8b9b..8c0c917 100644 --- a/packages/sdk/src/__tests__/integration/lifecycle.integration.test.ts +++ b/packages/sdk/src/__tests__/integration/lifecycle.integration.test.ts @@ -96,6 +96,7 @@ describe.skipIf(!UPSTASH_BOX_API_KEY)("lifecycle", () => { // Verify the run shows up in listRuns const runs = await box.listRuns(); expect(runs.length).toBeGreaterThanOrEqual(1); + expect(runs.find((r) => r.id === run.id)?.status).toBe("cancelled"); }, 120000); it("box.logs: returns structured logs", async () => {