Skip to content

Commit 42053b5

Browse files
committed
fix(ctrl-c): single tap aborts, second tap within 1s exits — no hung state
Three independent things were combining to make ctrl-c feel broken: 1. ink's default exitOnCtrlC unmounts the React tree without exiting the Node process. The user saw a frozen terminal — the "weird state where I can't type to AI." A second ctrl-c finally killed the process via our SIGINT handler. 2. The Permission overlay had no ctrl-c binding, so hitting ctrl-c during an approval prompt did nothing visible. 3. After abort fired, the abandoned turn's trailing turn_end / agent_end events flipped status back to thinking / idle — re-disabling the input. Disable ink's exitOnCtrlC and own ctrl-c at the App root: dismiss any overlay first (deny permission, cancel user query), then abort the agent if busy, then arm the 1-second double-tap exit window. Reducer now ignores turn_end / agent_end while status === "aborted" so the abandoned turn can't re-disable the input.
1 parent b224aa8 commit 42053b5

6 files changed

Lines changed: 109 additions & 8 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.41",
3+
"version": "2.0.0-pre.42",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/agent/events.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,3 +427,58 @@ describe("reducer · usage merging", () => {
427427
expect(s.usage.totalTokens).toBe(100);
428428
});
429429
});
430+
431+
describe("reducer · post-abort event suppression", () => {
432+
it("ignores turn_end while status is aborted (would otherwise re-disable input)", () => {
433+
let s = dispatch(freshState(), { type: "agent-event", event: { type: "agent_start" } as AgentEvent });
434+
expect(s.status).toBe("thinking");
435+
s = dispatch(s, { type: "abort" });
436+
expect(s.status).toBe("aborted");
437+
// Abandoned turn's tail arrives — must NOT flip us to thinking.
438+
s = dispatch(s, { type: "agent-event", event: { type: "turn_end" } as AgentEvent });
439+
expect(s.status).toBe("aborted");
440+
});
441+
442+
it("ignores agent_end while status is aborted", () => {
443+
const s = dispatch(
444+
{ ...freshState(), status: "aborted" },
445+
{
446+
type: "agent-event",
447+
event: { type: "agent_end" } as AgentEvent,
448+
},
449+
);
450+
expect(s.status).toBe("aborted");
451+
});
452+
453+
it("a subsequent user-prompt clears the aborted state and starts a fresh turn", () => {
454+
const s = dispatch({ ...freshState(), status: "aborted" }, { type: "user-prompt", text: "try again" });
455+
expect(s.status).toBe("thinking");
456+
});
457+
458+
it("still applies tool_execution_end while aborted so spinners clear visually", () => {
459+
let s = dispatch(freshState(), {
460+
type: "agent-event",
461+
event: {
462+
type: "tool_execution_start",
463+
toolCallId: "t1",
464+
toolName: "shell",
465+
args: {},
466+
} as unknown as AgentEvent,
467+
});
468+
s = dispatch(s, { type: "abort" });
469+
expect(s.status).toBe("aborted");
470+
expect(s.tools.get("t1")?.status).toBe("running");
471+
s = dispatch(s, {
472+
type: "agent-event",
473+
event: {
474+
type: "tool_execution_end",
475+
toolCallId: "t1",
476+
result: "interrupted",
477+
isError: true,
478+
} as unknown as AgentEvent,
479+
});
480+
expect(s.tools.get("t1")?.status).toBe("error");
481+
// Status itself stays "aborted" — we don't let the abandoned turn flip it.
482+
expect(s.status).toBe("aborted");
483+
});
484+
});

src/agent/events.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ export function reducer(state: ChatState, action: Action): ChatState {
6464
return initialState(state.model);
6565

6666
case "agent-event":
67+
// When the user has aborted a turn, ignore the abandoned turn's
68+
// tail events that would flip status back to thinking/idle (and
69+
// thus re-disable the input). The next user-prompt action resets
70+
// status cleanly. Tool execution and message events still apply
71+
// so any in-flight spinners and partial messages settle visually.
72+
if (state.status === "aborted" && (action.event.type === "turn_end" || action.event.type === "agent_end")) {
73+
return state;
74+
}
6775
return applyAgentEvent(state, action.event);
6876
}
6977
}

src/cli.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,11 @@ if (argv[0] === "--version" || argv[0] === "-v") {
8585
// collapses the content into a placeholder. terminal-restore.ts emits
8686
// the matching disable sequence on every exit path.
8787
if (process.stdout.isTTY) process.stdout.write("\x1b[?2004h");
88-
const instance = render(<App />);
88+
// Disable ink's default ctrl-c handling. ink unmounts on ctrl-c but
89+
// doesn't exit the process — leaves the user staring at a frozen
90+
// terminal. We handle ctrl-c ourselves: first press aborts the agent
91+
// and any in-flight overlay, a second press within 1s exits cleanly.
92+
const instance = render(<App />, { exitOnCtrlC: false });
8993
installTerminalRestoreHandlers(instance);
9094
instance.waitUntilExit().catch(() => {
9195
process.exit(1);

src/ui/App.tsx

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, Text, useApp } from "ink";
1+
import { Box, Text, useApp, useInput } from "ink";
22
import { useEffect, useMemo, useReducer, useState } from "react";
33
import { type AgentBundle, createAgent } from "../agent/agent.js";
44
import { ConfigError } from "../agent/config.js";
@@ -218,11 +218,14 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
218218
};
219219

220220
// Ctrl-C semantics:
221+
// • Any open overlay (Permission, UserQuery) gets dismissed first
222+
// so the user is never trapped behind a prompt with no escape.
221223
// • While the agent is busy: abort the turn. Stays in the app.
222224
// • A second Ctrl-C within DOUBLE_TAP_MS exits, regardless of
223225
// whether the previous press aborted or just landed a hint.
224226
// "Twice real fast" is universally understood as "I want out."
225-
// • While idle: first press posts a hint + arms the exit window.
227+
// • While idle at the prompt: first press posts a hint + arms the
228+
// exit window so the second tap exits.
226229
//
227230
// 1000ms is "real fast" — wide enough that intentional double-taps
228231
// register, tight enough that mashing Ctrl-C twice while flustered
@@ -236,6 +239,29 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
236239
return;
237240
}
238241
exitTimerRef.deadline = now + DOUBLE_TAP_MS;
242+
243+
// Overlays first: dismissing them brings the user back to a usable
244+
// input row without exiting the agent loop. Permission denies the
245+
// pending request (and any tool waiting on it surfaces the denial
246+
// to the model as a normal tool error); UserQuery cancels the
247+
// pending question.
248+
if (permRequest) {
249+
bundle.permissions.respond(permRequest.id, "deny");
250+
if (busy) {
251+
bundle.agent.abort();
252+
dispatch({ type: "abort" });
253+
}
254+
return;
255+
}
256+
if (userQuery) {
257+
bundle.userQueries.cancel(userQuery.id);
258+
if (busy) {
259+
bundle.agent.abort();
260+
dispatch({ type: "abort" });
261+
}
262+
return;
263+
}
264+
239265
if (busy) {
240266
bundle.agent.abort();
241267
dispatch({ type: "abort" });
@@ -247,6 +273,14 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
247273
appendStatus("Press Ctrl-C again to exit.");
248274
};
249275

276+
// Top-level Ctrl-C capture. Fires regardless of which child component
277+
// is mounted (Input, Permission, UserQuery) so the user always has a
278+
// way out. The per-component handlers stay for their other shortcuts
279+
// (Esc to deny, etc.) but Ctrl-C now routes through here.
280+
useInput((input, key) => {
281+
if (key.ctrl && input === "c") handleAbort();
282+
});
283+
250284
return (
251285
<Box flexDirection="column">
252286
{state.messages.length === 0 && !state.streaming ? (

src/ui/Input.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,10 +147,10 @@ export function Input({
147147
// we can diagnose "key X doesn't work" reports from real terminals.
148148
logInputEvent(input, key);
149149

150-
if (key.ctrl && input === "c") {
151-
onAbort?.();
152-
return;
153-
}
150+
// Ctrl-C is owned by App's top-level useInput (it needs to fire
151+
// even when Input is hidden behind an overlay). Swallow it here
152+
// so we don't double-dispatch onAbort.
153+
if (key.ctrl && input === "c") return;
154154

155155
if (disabled) return;
156156

0 commit comments

Comments
 (0)