diff --git a/CHANGELOG.md b/CHANGELOG.md index 69cac156521..a723a5be882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index c71ae8907d8..bb73f295344 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -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"); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 1695169bcdd..f259b71a9ea 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -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); diff --git a/src/tui/tui.test.ts b/src/tui/tui.test.ts index 2ba2ba6ef0c..61b367b08d3 100644 --- a/src/tui/tui.test.ts +++ b/src/tui/tui.test.ts @@ -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, + }); + }); +}); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 33c3287ccf4..4474267af5b 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -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((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); }); }