TUI: make Ctrl+C exit behavior reliably responsive
This commit is contained in:
parent
a96d89f343
commit
b4cdffc7a4
@ -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.
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user