Skip to content
Open
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
50 changes: 44 additions & 6 deletions packages/cli/src/repl/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> } | 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<void> {
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;
Expand Down Expand Up @@ -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<BoxREPLEvent> {
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;
}
}

Expand Down Expand Up @@ -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;
}
}
}
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/repl/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,18 @@ export function parseTodoItems(input: Record<string, unknown>): TodoItem[] {
/**
* Run the agent with a prompt, streaming output as events.
*/
export async function* handleRun(box: Box, prompt: string): AsyncGenerator<BoxREPLEvent> {
export async function* handleRun(
box: Box,
prompt: string,
onRunCreated?: (run: { cancel(): Promise<void> }) => void,
): AsyncGenerator<BoxREPLEvent> {
if (!prompt) {
yield { type: "log", message: "Usage: run <prompt>" };
return;
}

const run = await box.agent.stream({ prompt });
onRunCreated?.(run);

for await (const chunk of run) {
if (chunk.type === "text-delta") {
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/repl/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) => void;
};
const origTtyWrite = rlAnyTty._ttyWrite.bind(rl);
rlAnyTty._ttyWrite = (s: string, key: Record<string, unknown>) => {
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading