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", 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 () => {