mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 10:41:25 +00:00
TUI: make Ctrl+C exit behavior reliably responsive
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user