From e297fda2e0f986fce806f1b47f228f1d8149abc5 Mon Sep 17 00:00:00 2001 From: RSI smoke Date: Wed, 14 Oct 2026 08:37:41 -0600 Subject: [PATCH] Force quit the terminal chat on a second Ctrl-C during a turn While a turn is active every interrupt was swallowed waiting for the runner to stop, so a hung turn left no keyboard path out. The first Ctrl-C still aborts gracefully; the second exits 130 immediately. --- docs/terminal-chat.md | 2 +- src/terminal/__tests__/chat.test.ts | 43 +++++++++++++++++++++++++++-- src/terminal/chat.ts | 15 ++++++++-- src/terminal/cli.ts | 12 ++++++++ 4 files changed, 67 insertions(+), 5 deletions(-) diff --git a/docs/terminal-chat.md b/docs/terminal-chat.md index 4fd438a..8554ce9 100644 --- a/docs/terminal-chat.md +++ b/docs/terminal-chat.md @@ -27,7 +27,7 @@ The chat command supports these slash commands: Quote file paths with spaces, for example `/import claude "exports/chat.json"`. Prefix a slash command with another slash to send it as a message, such as `//help`. -Ctrl-C aborts an active turn through the runner's abort signal. At the prompt it cancels the current input. Ctrl-D or end-of-file exits; pending messages are saved before exit. The session archive is the source of saved conversation history. Turn failures and save errors are shown in the terminal. A revision conflict keeps the turn pending and requires an explicit `/save` retry. An input backlog above the safety limit is discarded with an error, and chat exits so it cannot interpret a partial paste as complete input. +Ctrl-C aborts an active turn through the runner's abort signal; a second Ctrl-C while the turn is stopping force quits without saving. At the prompt it cancels the current input. Ctrl-D or end-of-file exits; pending messages are saved before exit. The session archive is the source of saved conversation history. Turn failures and save errors are shown in the terminal. A revision conflict keeps the turn pending and requires an explicit `/save` retry. An input backlog above the safety limit is discarded with an error, and chat exits so it cannot interpret a partial paste as complete input. Each turn uses the existing headless runner's current modes, permissions, budgets, evidence configuration, and emergency controls. `SIGTERM` stops the active turn and saves captured messages before chat exits. The archive keeps messages captured before context compaction, but it is an editable conversation record, not an authenticated evidence journal. Historical system and developer instructions and opaque content are excluded from continuation context; older context may be shortened with a displayed warning. diff --git a/src/terminal/__tests__/chat.test.ts b/src/terminal/__tests__/chat.test.ts index 59287be..53ec231 100644 --- a/src/terminal/__tests__/chat.test.ts +++ b/src/terminal/__tests__/chat.test.ts @@ -62,7 +62,12 @@ function streams(text: string): { input: PassThrough; output: PassThrough; read: } function dependencies(store: MemoryStore, runTurn: ChatTurnRunner, input: PassThrough, output: PassThrough, overrides: Partial = {}): ChatCliDependencies { - return { createStore: () => store, runTurn, input, output, errorOutput: output, terminal: false, cwd: workspaceRoot, env: { NO_COLOR: "1" }, ...overrides } + const unused: (code: number) => never = () => { + throw new Error("forceExit should be stubbed") + } + return { createStore: () => store, runTurn, input, output, + errorOutput: output, terminal: false, cwd: workspaceRoot, + env: { NO_COLOR: "1" }, forceExit: unused, ...overrides } } async function testArgsAndSlashParsing(): Promise { @@ -455,6 +460,39 @@ async function testReadlineCtrlCInPseudoTerminal(): Promise { assert.match(result.output, /PTY_RESULT:interrupt/) } +function ttyStreams(): { input: PassThrough; output: PassThrough } { + const input = new PassThrough() as PassThrough & { isTTY: boolean } + const output = new PassThrough() as PassThrough & { isTTY: boolean } + input.isTTY = true + output.isTTY = true + return { input, output } +} + +async function settle(): Promise { + await new Promise((resolve) => setTimeout(resolve, 50)) +} + +async function testSecondInterruptForceQuitsHungTurn(): Promise { + const { input, output } = ttyStreams() + let turnStarted = false; const exits: number[] = [] + const hung: ChatTurnRunner = async () => { + turnStarted = true; return await new Promise(() => {}) + } + const forceExit = ((code: number): void => { + exits.push(code); input.end() + }) as (code: number) => never + const deps = dependencies(new MemoryStore(), hung, input, output, + { terminal: true, env: {}, forceExit }) + chatMain([], deps) + input.write("work\n"); while (!turnStarted) await settle() + input.write(String.fromCharCode(3)) + await settle() + assert.deepEqual(exits, []) + input.write(String.fromCharCode(3)) + await settle() + assert.deepEqual(exits, [130]) +} + async function main(): Promise { await testArgsAndSlashParsing() await testTurnsPersistAndResume() @@ -467,7 +505,8 @@ async function main(): Promise { await testSaveFailureCanBeRetried() await testInputAndRendererBounds() await testReadlineCtrlCInPseudoTerminal() - console.log("All 11 terminal chat tests passed") + await testSecondInterruptForceQuitsHungTurn() + console.log("All 12 terminal chat tests passed") } main().catch((error) => { console.error(error); process.exitCode = 1 }) diff --git a/src/terminal/chat.ts b/src/terminal/chat.ts index da08495..ac5b37f 100644 --- a/src/terminal/chat.ts +++ b/src/terminal/chat.ts @@ -39,6 +39,7 @@ export interface ChatCliDependencies { validateMode?: (mode: string) => boolean | Promise skills?: ChatSkillService imports?: ChatImportService + forceExit: (code: number) => never } export interface ChatCliOptions { workspaceRoot: string; resumeId?: string; model?: string; mode?: string; help: boolean } @@ -101,6 +102,7 @@ export async function chatMain(argv: string[], deps: ChatCliDependencies): Promi let model = options.model ?? deps.initialModel let mode = options.mode ?? deps.initialMode ?? DEFAULT_MODE let activeTurn: AbortController | undefined + let abortRequested = false let exitRequested = false let exitCode = 0 let pendingMessages: SessionMessage[] = [] @@ -220,6 +222,7 @@ export async function chatMain(argv: string[], deps: ChatCliDependencies): Promi } finally { deps.signal?.removeEventListener("abort", externalAbort) activeTurn = undefined + abortRequested = false } const messages = [...result.messages] if (!messages.some((message) => message.role === "user" && message.content === prompt)) { @@ -344,8 +347,15 @@ export async function chatMain(argv: string[], deps: ChatCliDependencies): Promi input.setInterruptHandler(() => { if (activeTurn) { + if (abortRequested) { + renderer.writeError("Turn is not responding; force quitting.") + deps.forceExit(130) + return true + } + abortRequested = true activeTurn.abort(new Error("interrupted by user")) - renderer.writeNotice("Interrupt requested; waiting for the active turn to stop.") + renderer.writeNotice("Interrupt requested; waiting for the turn to stop.") + renderer.writeNotice("Press Ctrl-C again to force quit without saving.") return true } return false @@ -424,7 +434,8 @@ export function chatHelp(): string { "Enter a message to start a turn. Use \"\"\" on its own line to enter or end a multiline message.", "/help /exit /new /fork /resume /sessions /save", "/model [name] /mode [name] /skills /skill /import ", - "Prefix a slash command with another / to send it as a message. Ctrl-C cancels an active turn; Ctrl-D exits.", + "Prefix a slash command with another / to send it as a message.", + "Ctrl-C cancels an active turn, twice force quits; Ctrl-D exits.", ].join("\n") } diff --git a/src/terminal/cli.ts b/src/terminal/cli.ts index 41b8796..e29e291 100644 --- a/src/terminal/cli.ts +++ b/src/terminal/cli.ts @@ -11,6 +11,16 @@ import { chatMain, chatUsage, parseChatArgs } from "./chat.js" import { createChatTurnRunner } from "./bridge.js" import { sanitizeTerminalText } from "./renderer.js" +export function defaultForceExit(code: number): never { + process.exit(code) +} + +function chatForceExit( + runtime: TerminalRuntimeOptions, +): (code: number) => never { + return runtime.forceExit ?? defaultForceExit +} + export interface TerminalRuntimeOptions { llmClient?: LlmClient skillRegistry?: SkillRegistry @@ -19,6 +29,7 @@ export interface TerminalRuntimeOptions { errorOutput?: Writable signal?: AbortSignal extraArgs?: string[] + forceExit?: (code: number) => never } export async function terminalMain(argv: string[], runtime: TerminalRuntimeOptions = {}): Promise { @@ -53,6 +64,7 @@ export async function terminalMain(argv: string[], runtime: TerminalRuntimeOptio return await chatMain(argv, { runTurn, createStore: (workspace) => new SessionStore(workspace), input: runtime.input, output, errorOutput, signal, + forceExit: chatForceExit(runtime), validateMode: async (mode) => Boolean(getModeBySlug(mode, await loadCustomModes(options.workspaceRoot)) ?? modes.find((entry) => entry.slug === mode)), skills: { list: async () => frozenRegistry.skills, activate: async (name) => frozenRegistry.activate(name) }, imports: { importFile: async (provider, file, workspace) => {