TUI: make Ctrl+C exit behavior reliably responsive

This commit is contained in:
Vignesh Natarajan 2026-02-22 01:28:48 -08:00
parent a96d89f343
commit b4cdffc7a4
5 changed files with 98 additions and 17 deletions

View File

@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
- TUI/Input: enable multiline-paste burst coalescing on macOS Terminal.app and iTerm so pasted blocks no longer submit line-by-line as separate messages. (#18809) Thanks @fwends.
- TUI/RTL: isolate right-to-left script lines (Arabic/Hebrew ranges) with Unicode bidi isolation marks in TUI text sanitization so RTL assistant output no longer renders in reversed visual order in terminal chat panes. (#21936) Thanks @Asm3r96.
- TUI/Status: request immediate renders after setting `sending`/`waiting` activity states so in-flight runs always show visible progress indicators instead of appearing idle until completion. (#21549) Thanks @13Guinness.
- TUI/Input: arm Ctrl+C exit timing when clearing non-empty composer text and add a SIGINT fallback path so double Ctrl+C exits remain responsive during active runs instead of requiring an extra press or appearing stuck. (#23407) Thanks @tinybluedev.
- Agents/Fallbacks: treat JSON payloads with `type: "api_error"` + `"Internal server error"` as transient failover errors so Anthropic 500-style failures trigger model fallback. (#23193) Thanks @jarvis-lane.
- Agents/Transcripts: validate assistant tool-call names (syntax/length + registered tool allowlist) before transcript persistence and during replay sanitization so malformed failover tool names no longer poison sessions with repeated provider HTTP 400 errors. (#23324) Thanks @johnsantry.
- Agents/Compaction: strip stale assistant usage snapshots from pre-compaction turns when replaying history after a compaction summary so context-token estimation no longer reuses pre-compaction totals and immediately re-triggers destructive follow-up compactions. (#19127) Thanks @tedwatson.

View File

@ -42,6 +42,7 @@ function createHarness(params?: {
applySessionInfoFromPatch: vi.fn(),
noteLocalRunId: vi.fn(),
forgetLocalRunId: vi.fn(),
requestExit: vi.fn(),
});
return {
@ -91,6 +92,7 @@ describe("tui command handlers", () => {
formatSessionKey: vi.fn(),
applySessionInfoFromPatch: vi.fn(),
noteLocalRunId: vi.fn(),
requestExit: vi.fn(),
});
const pending = handleCommand("/context");

View File

@ -43,6 +43,7 @@ type CommandHandlerContext = {
applySessionInfoFromPatch: (result: SessionsPatchResult) => void;
noteLocalRunId: (runId: string) => void;
forgetLocalRunId?: (runId: string) => void;
requestExit: () => void;
};
export function createCommandHandlers(context: CommandHandlerContext) {
@ -65,6 +66,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
applySessionInfoFromPatch,
noteLocalRunId,
forgetLocalRunId,
requestExit,
} = context;
const setAgent = async (id: string) => {
@ -451,9 +453,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
break;
case "exit":
case "quit":
client.stop();
tui.stop();
process.exit(0);
requestExit();
break;
default:
await sendMessage(raw);

View File

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { getSlashCommands, parseCommand } from "./commands.js";
import {
createBackspaceDeduper,
resolveCtrlCAction,
resolveFinalAssistantText,
resolveGatewayDisconnectState,
resolveTuiSessionKey,
@ -120,3 +121,26 @@ describe("createBackspaceDeduper", () => {
expect(dedupe("\x1b[A")).toBe("\x1b[A");
});
});
describe("resolveCtrlCAction", () => {
it("clears input and arms exit on first ctrl+c when editor has text", () => {
expect(resolveCtrlCAction({ hasInput: true, now: 2000, lastCtrlCAt: 0 })).toEqual({
action: "clear",
nextLastCtrlCAt: 2000,
});
});
it("exits on second ctrl+c within the exit window", () => {
expect(resolveCtrlCAction({ hasInput: false, now: 2800, lastCtrlCAt: 2000 })).toEqual({
action: "exit",
nextLastCtrlCAt: 2000,
});
});
it("shows warning when exit window has elapsed", () => {
expect(resolveCtrlCAction({ hasInput: false, now: 3501, lastCtrlCAt: 2000 })).toEqual({
action: "warn",
nextLastCtrlCAt: 3501,
});
});
});

View File

@ -246,6 +246,33 @@ export function createBackspaceDeduper(params?: { dedupeWindowMs?: number; now?:
};
}
type CtrlCAction = "clear" | "warn" | "exit";
export function resolveCtrlCAction(params: {
hasInput: boolean;
now: number;
lastCtrlCAt: number;
exitWindowMs?: number;
}): { action: CtrlCAction; nextLastCtrlCAt: number } {
const exitWindowMs = Math.max(1, Math.floor(params.exitWindowMs ?? 1000));
if (params.hasInput) {
return {
action: "clear",
nextLastCtrlCAt: params.now,
};
}
if (params.now - params.lastCtrlCAt <= exitWindowMs) {
return {
action: "exit",
nextLastCtrlCAt: params.lastCtrlCAt,
};
}
return {
action: "warn",
nextLastCtrlCAt: params.now,
};
}
export async function runTui(opts: TuiOptions) {
const config = loadConfig();
const initialSessionInput = (opts.session ?? "").trim();
@ -272,6 +299,7 @@ export async function runTui(opts: TuiOptions) {
let autoMessageSent = false;
let sessionInfo: SessionInfo = {};
let lastCtrlCAt = 0;
let exitRequested = false;
let activityStatus = "idle";
let connectionStatus = "connecting";
let statusTimeout: NodeJS.Timeout | null = null;
@ -736,6 +764,16 @@ export async function runTui(opts: TuiOptions) {
clearLocalRunIds,
});
const requestExit = () => {
if (exitRequested) {
return;
}
exitRequested = true;
client.stop();
tui.stop();
process.exit(0);
};
const { handleCommand, sendMessage, openModelSelector, openAgentSelector, openSessionSelector } =
createCommandHandlers({
client,
@ -756,6 +794,7 @@ export async function runTui(opts: TuiOptions) {
formatSessionKey,
noteLocalRunId,
forgetLocalRunId,
requestExit,
});
const { runLocalShellLine } = createLocalShellRunner({
@ -779,27 +818,32 @@ export async function runTui(opts: TuiOptions) {
editor.onEscape = () => {
void abortActive();
};
editor.onCtrlC = () => {
const handleCtrlC = () => {
const now = Date.now();
if (editor.getText().trim().length > 0) {
const decision = resolveCtrlCAction({
hasInput: editor.getText().trim().length > 0,
now,
lastCtrlCAt,
});
lastCtrlCAt = decision.nextLastCtrlCAt;
if (decision.action === "clear") {
editor.setText("");
setActivityStatus("cleared input");
setActivityStatus("cleared input; press ctrl+c again to exit");
tui.requestRender();
return;
}
if (now - lastCtrlCAt < 1000) {
client.stop();
tui.stop();
process.exit(0);
if (decision.action === "exit") {
requestExit();
return;
}
lastCtrlCAt = now;
setActivityStatus("press ctrl+c again to exit");
tui.requestRender();
};
editor.onCtrlC = () => {
handleCtrlC();
};
editor.onCtrlD = () => {
client.stop();
tui.stop();
process.exit(0);
requestExit();
};
editor.onCtrlO = () => {
toolsExpanded = !toolsExpanded;
@ -874,12 +918,22 @@ export async function runTui(opts: TuiOptions) {
updateHeader();
setConnectionStatus("connecting");
updateFooter();
const sigintHandler = () => {
handleCtrlC();
};
const sigtermHandler = () => {
requestExit();
};
process.on("SIGINT", sigintHandler);
process.on("SIGTERM", sigtermHandler);
tui.start();
client.start();
await new Promise<void>((resolve) => {
const finish = () => resolve();
const finish = () => {
process.removeListener("SIGINT", sigintHandler);
process.removeListener("SIGTERM", sigtermHandler);
resolve();
};
process.once("exit", finish);
process.once("SIGINT", finish);
process.once("SIGTERM", finish);
});
}