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
+25
View File
@@ -238,7 +238,30 @@ export function registerTuiCommand(program: Command): void {
return cleanupPromise;
};
let activeCtrlCHandler: (() => boolean) | null = null;
let lastCtrlCAtMs = 0;
const ctrlCExitWindowMs = 1_500;
const signalHandler = (signal: NodeJS.Signals) => {
if (signal === 'SIGINT' && activeCtrlCHandler) {
// Minimal TUI owns Ctrl+C behavior via readline SIGINT handling.
return;
}
if (signal === 'SIGINT') {
const now = Date.now();
const isDoublePress = lastCtrlCAtMs > 0 && (now - lastCtrlCAtMs) <= ctrlCExitWindowMs;
lastCtrlCAtMs = now;
if (!isDoublePress) {
if (activeCtrlCHandler && !activeCtrlCHandler()) {
return;
}
console.log('\nPress Ctrl+C again to quit (or use /quit).');
return;
}
}
console.log(`\nReceived ${signal}; shutting down TUI...`);
void cleanup()
.then(() => process.exit(0))
@@ -313,8 +336,10 @@ export function registerTuiCommand(program: Command): void {
tui.stop(true);
},
});
activeCtrlCHandler = () => tui.handleCtrlCPress();
await tui.start();
activeCtrlCHandler = null;
if (switchingToFullscreen) {
console.clear();
+17
View File
@@ -77,6 +77,7 @@ export function App({
onTransfer,
onExit,
}: AppProps): React.ReactElement {
const ctrlCExitWindowMs = 1_500;
const { exit } = useApp();
const [input, setInput] = useState('');
const [messages, setMessages] = useState<Message[]>(session.getHistory());
@@ -91,6 +92,7 @@ export function App({
});
const abortRef = useRef(false);
const lastCtrlCAtRef = useRef(0);
const toolLinesRef = useRef<string[]>([]);
const confirmResolveRef = useRef<((result: HookResult) => void) | null>(null);
@@ -177,6 +179,21 @@ export function App({
return;
}
if (key.ctrl && inputChar.toLowerCase() === 'c') {
const now = Date.now();
const shouldExit = lastCtrlCAtRef.current > 0 && (now - lastCtrlCAtRef.current) <= ctrlCExitWindowMs;
lastCtrlCAtRef.current = now;
if (shouldExit) {
onExit?.();
exit();
return;
}
if (input.length > 0) {
setInput('');
}
return;
}
// Tab completion for slash commands
if (key.tab && !isStreaming) {
const completions = getCommandCompletions(input);
+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();
}
});
});
+35 -1
View File
@@ -72,6 +72,7 @@ export interface MinimalTuiConfig {
}
export class MinimalTui {
private static readonly CTRL_C_EXIT_WINDOW_MS = 1_500;
private rl: readline.Interface | null = null;
private running = false;
private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 };
@@ -81,6 +82,7 @@ export class MinimalTui {
private activeOperationCancel: (() => void) | null = null;
private keypressHandler: ((char: string, key: readline.Key) => void) | null = null;
private verbose = false;
private lastCtrlCAtMs = 0;
constructor(private config: MinimalTuiConfig) {}
@@ -218,6 +220,12 @@ export class MinimalTui {
},
});
this.rl.on('SIGINT', () => {
if (this.handleCtrlCPress()) {
this.stop();
}
});
// In minimal TUI we can prompt inline for tool confirmations.
// This avoids deadlocks when hooks are configured to require confirmation
// (e.g. shell.exec) and the tool loop is awaiting a decision.
@@ -265,13 +273,39 @@ export class MinimalTui {
console.log(getColoredBanner());
console.log(`\n${colors.orange}${colors.bold}Flynn TUI${colors.reset} ${colors.dim}(minimal mode)${colors.reset}`);
console.log(`${colors.gray}Type /help for commands, /fullscreen for panel mode, Esc to cancel prompts or running responses${colors.reset}\n`);
console.log(`${colors.gray}Type /help for commands, /fullscreen for panel mode, Esc to cancel prompts/runs, Ctrl+C to clear input (press twice to quit)${colors.reset}\n`);
await this.promptLoop();
}
handleCtrlCPress(nowMs = Date.now()): boolean {
if (!this.rl) {
return true;
}
const shouldExit = this.lastCtrlCAtMs > 0
&& (nowMs - this.lastCtrlCAtMs) <= MinimalTui.CTRL_C_EXIT_WINDOW_MS;
this.lastCtrlCAtMs = nowMs;
if (shouldExit) {
return true;
}
try {
this.rl.write(null, { ctrl: true, name: 'u' });
} catch {
// ignore
}
console.log(`${colors.gray}Press Ctrl+C again to quit (or use /quit).${colors.reset}`);
if (this.running) {
this.rl.prompt();
}
return false;
}
private async promptLoop(): Promise<void> {
while (this.running && this.rl) {
this.lastCtrlCAtMs = 0;
this.lastLine = '';
this.currentHint = '';
const input = await this.prompt(formatPrompt('default'));