feat(tui): single ctrl+c clears input, double ctrl+c exits

This commit is contained in:
William Valentin
2026-02-18 11:38:21 -08:00
parent 21232748b9
commit 67d235ebf5
6 changed files with 141 additions and 1 deletions
+47
View File
@@ -38,15 +38,18 @@ function minimalTuiPrivates(value: MinimalTui): {
handleToolEvent: (event: unknown) => void;
handleCommand: (command: unknown) => Promise<void>;
handleEscapeAction: () => boolean;
handleCtrlCPress: (nowMs?: number) => boolean;
prompt: (text: string) => Promise<string>;
rl: {
once: (event: string, cb: () => void) => void;
removeListener: (event: string, cb: () => void) => void;
question: (text: string, cb: (answer: string) => void) => void;
write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void;
prompt: () => void;
};
activePromptCancel: (() => void) | null;
activeOperationCancel: (() => void) | null;
running: boolean;
} {
return value as unknown as {
handleBackendCommand: (provider: string) => Promise<void>;
@@ -56,15 +59,18 @@ function minimalTuiPrivates(value: MinimalTui): {
handleToolEvent: (event: unknown) => void;
handleCommand: (command: unknown) => Promise<void>;
handleEscapeAction: () => boolean;
handleCtrlCPress: (nowMs?: number) => boolean;
prompt: (text: string) => Promise<string>;
rl: {
once: (event: string, cb: () => void) => void;
removeListener: (event: string, cb: () => void) => void;
question: (text: string, cb: (answer: string) => void) => void;
write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void;
prompt: () => void;
};
activePromptCancel: (() => void) | null;
activeOperationCancel: (() => void) | null;
running: boolean;
};
}
@@ -423,6 +429,7 @@ describe('MinimalTui prompt cancellation', () => {
onAnswer = cb;
}),
write,
prompt: vi.fn(),
};
const promptPromise = minimalTuiPrivates(tui).prompt('Confirm? ');
@@ -457,4 +464,44 @@ describe('MinimalTui prompt cancellation', () => {
expect(minimalTuiPrivates(tui).handleEscapeAction()).toBe(true);
expect(cancelRunningOperation).toHaveBeenCalledOnce();
});
it('treats first Ctrl+C as clear-input and second as exit intent', () => {
const mockSession = {
id: 'test',
getHistory: () => [],
addMessage: vi.fn(),
clear: vi.fn(),
replaceHistory: vi.fn(),
};
const write = vi.fn();
const prompt = vi.fn();
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
try {
const tui = new MinimalTui({
session: asSession(mockSession),
modelClient: asRouter({}),
systemPrompt: 'test',
});
minimalTuiPrivates(tui).rl = {
once: vi.fn(),
removeListener: vi.fn(),
question: vi.fn(),
write,
prompt,
};
minimalTuiPrivates(tui).running = true;
const first = minimalTuiPrivates(tui).handleCtrlCPress(1000);
const second = minimalTuiPrivates(tui).handleCtrlCPress(2000);
expect(first).toBe(false);
expect(second).toBe(true);
expect(write).toHaveBeenCalledWith(null, { ctrl: true, name: 'u' });
expect(prompt).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Press Ctrl+C again to quit'));
} finally {
logSpy.mockRestore();
}
});
});