Skip to content

Commit 5514c68

Browse files
bwachmanclaude
andcommitted
fix(tui): let Ctrl+C stop the run while an approval prompt is open
A permission or operator prompt holds every key until it is answered, so Ctrl+C never reached the interrupt while one was open. Now that every parked approval reaches the operator, a run that keeps issuing tool calls after a rejection can raise prompts faster than they can be answered, which left no way to stop it short of killing the process. Inside a decision prompt, Ctrl+C now declines the prompt the way Esc does and then interrupts the turn. It interrupts whether or not the shell reads as busy, since a turn started by a delivered message can park without a composer send marking it. The interrupt bumps the delivery generation that queued main-session prompts listen to, so they drop instead of taking over the prompt. Esc keeps its meaning of declining only the open prompt. Bridge tests press the keys into wired gates: Ctrl+C on a permission prompt, with the shell busy and idle; queued prompts dropping on the identity abort; Ctrl+C on an operator question; and Esc declining without interrupting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8ab537e commit 5514c68

2 files changed

Lines changed: 151 additions & 2 deletions

File tree

‎src/tui/runtime-bridge.test.ts‎

Lines changed: 133 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,10 @@ import { streamRowCount } from "./shell/transcript";
2424
import { STEER_WAIT_NOTICE_MS } from "./notice-line";
2525
import type { Harness } from "./harness";
2626
import { withAppShell } from "./test-helpers";
27-
import { wireGates } from "./gate-wire.js";
27+
import { operatorCancelResult, wireGates } from "./gate-wire.js";
2828
import { acceptOverlaySelection } from "./shell/overlay-host.js";
2929
import { moveOverlaySelection } from "./shell/overlay-list.js";
30-
import { badgeCount } from "./delivery-queue";
30+
import { badgeCount, SESSION_IDENTITY_ABORT_REASON } from "./delivery-queue";
3131
import { LIVE_ACTIVITY_WORDS } from "./chrome-state";
3232

3333
type RecordingPort = ReturnType<typeof createRecordingPort>;
@@ -1046,6 +1046,137 @@ describe("parallel sub-agent dispatch on the live session bridge", () => {
10461046
});
10471047
});
10481048

1049+
describe("Ctrl+C on a decision gate", () => {
1050+
const shellRequest = (subject: string): PermissionRequest => ({
1051+
tool: "run_shell",
1052+
action: "Run shell command",
1053+
subject,
1054+
scopes: [],
1055+
});
1056+
1057+
function emitPermissionGate(
1058+
emitter: EventEmitter,
1059+
id: string,
1060+
signal?: AbortSignal,
1061+
) {
1062+
let outcome: unknown;
1063+
emitter.emit("permission.gate", {
1064+
id,
1065+
request: shellRequest(`touch ${id}`),
1066+
resolve: (value: unknown) => {
1067+
outcome = value;
1068+
},
1069+
...(signal !== undefined ? { signal } : {}),
1070+
});
1071+
return () => outcome;
1072+
}
1073+
1074+
test("declines the open permission gate and interrupts the turn", async () => {
1075+
await withBridge(
1076+
{ run: "busy", wireKeys: true, gates: true },
1077+
async ({ h, shell, port, emitter }) => {
1078+
const outcome = emitPermissionGate(emitter, "req-1");
1079+
expect(shell.overlayKind).toBe("permissions");
1080+
port.clear();
1081+
1082+
h.pressKey("c", { ctrl: true });
1083+
await h.renderOnce();
1084+
1085+
expect(outcome()).toEqual({ allow: false });
1086+
expect(port.calls.some((c) => c.op === "interrupt")).toBe(true);
1087+
expect(shell.overlayList).toBeNull();
1088+
},
1089+
);
1090+
});
1091+
1092+
test("interrupts even when the shell does not read as busy", async () => {
1093+
// A turn started by a delivered message (a background shell exit, a
1094+
// worker wake) can park on a gate without a composer send marking busy.
1095+
await withBridge(
1096+
{ run: "idle", wireKeys: true, gates: true },
1097+
async ({ h, port, emitter }) => {
1098+
const outcome = emitPermissionGate(emitter, "req-1");
1099+
port.clear();
1100+
1101+
h.pressKey("c", { ctrl: true });
1102+
await h.renderOnce();
1103+
1104+
expect(outcome()).toEqual({ allow: false });
1105+
expect(port.calls.some((c) => c.op === "interrupt")).toBe(true);
1106+
},
1107+
);
1108+
});
1109+
1110+
test("queued gates drop with the interrupt instead of taking over the prompt", async () => {
1111+
await withBridge(
1112+
{ run: "busy", wireKeys: true, gates: true },
1113+
async ({ h, shell, port, emitter }) => {
1114+
// Production gates share the session identity signal, which the
1115+
// interrupt's delivery-generation bump aborts.
1116+
const identity = new AbortController();
1117+
const first = emitPermissionGate(emitter, "req-1", identity.signal);
1118+
const queued = emitPermissionGate(emitter, "req-2", identity.signal);
1119+
port.clear();
1120+
1121+
h.pressKey("c", { ctrl: true });
1122+
await h.renderOnce();
1123+
expect(first()).toEqual({ allow: false });
1124+
expect(port.calls.some((c) => c.op === "interrupt")).toBe(true);
1125+
1126+
identity.abort(SESSION_IDENTITY_ABORT_REASON);
1127+
await h.renderOnce();
1128+
expect(queued()).toEqual({
1129+
allow: false,
1130+
message: SESSION_IDENTITY_ABORT_REASON,
1131+
});
1132+
expect(shell.overlayList).toBeNull();
1133+
},
1134+
);
1135+
});
1136+
1137+
test("declines an open operator question and interrupts the turn", async () => {
1138+
await withBridge(
1139+
{ run: "busy", wireKeys: true, gates: true },
1140+
async ({ h, port, emitter }) => {
1141+
let outcome: unknown;
1142+
emitter.emit("operator.gate", {
1143+
id: "ask-1",
1144+
question: "Proceed?",
1145+
options: ["Cancel", "Continue"],
1146+
resolve: (value: unknown) => {
1147+
outcome = value;
1148+
},
1149+
});
1150+
port.clear();
1151+
1152+
h.pressKey("c", { ctrl: true });
1153+
await h.renderOnce();
1154+
1155+
expect(outcome).toEqual(operatorCancelResult());
1156+
expect(port.calls.some((c) => c.op === "interrupt")).toBe(true);
1157+
},
1158+
);
1159+
});
1160+
1161+
test("Esc still declines only the open gate and leaves the turn running", async () => {
1162+
await withBridge(
1163+
{ run: "busy", wireKeys: true, gates: true },
1164+
async ({ h, port, emitter }) => {
1165+
const outcome = emitPermissionGate(emitter, "req-1");
1166+
port.clear();
1167+
1168+
// ESC needs a disambiguation delay on the mock stdin path.
1169+
h.pressKey("Escape");
1170+
await new Promise((r) => setTimeout(r, 60));
1171+
await h.renderOnce();
1172+
1173+
expect(outcome()).toEqual({ allow: false });
1174+
expect(port.calls.some((c) => c.op === "interrupt")).toBe(false);
1175+
},
1176+
);
1177+
});
1178+
});
1179+
10491180
describe("calls parked on an approval gate", () => {
10501181
type Event = { type: string; data?: unknown };
10511182

‎src/tui/shell/keys.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,13 @@ function toggleCloseOpenSurface(shell: AppShell, key: KeyEvent): boolean {
152152
return true;
153153
}
154154

155+
/** Permission and operator gates: the overlays that hold every key. */
156+
function isDecisionGate(shell: AppShell): boolean {
157+
return (
158+
shell.overlayKind === "permissions" || shell.overlayKind === "operator"
159+
);
160+
}
161+
155162
/** Window in which a second Ctrl+C is read as "yes, quit". */
156163
export const CTRL_C_EXIT_WINDOW_MS = 2000;
157164

@@ -343,6 +350,17 @@ export function createShellKeyHandlers(
343350
}
344351

345352
if (shell.overlayList) {
353+
// A decision gate keeps every key until it is answered, and a run that
354+
// keeps raising gates (each rejection re-infers into more calls) would
355+
// then leave the stop key unreachable. Ctrl+C declines the open gate the
356+
// way Esc does, then interrupts the turn, which drops queued gates too.
357+
if (key.ctrl && key.name === "c" && isDecisionGate(shell)) {
358+
key.preventDefault();
359+
abortOverlayHostReservations(shell);
360+
closeInsetOverlay(shell);
361+
interruptShell(shell);
362+
return;
363+
}
346364
// Checked ahead of the filter handlers: an opener chord pressed again is
347365
// a request to close, not a character to narrow the list with.
348366
if (toggleCloseOpenSurface(shell, key)) {

0 commit comments

Comments
 (0)