From 67d235ebf5f879a37d6ae0c700b668545269878f Mon Sep 17 00:00:00 2001 From: William Valentin Date: Wed, 18 Feb 2026 11:38:21 -0800 Subject: [PATCH] feat(tui): single ctrl+c clears input, double ctrl+c exits --- README.md | 2 ++ docs/plans/state.json | 15 +++++++++ src/cli/tui.ts | 25 +++++++++++++++ src/frontends/tui/components/App.tsx | 17 ++++++++++ src/frontends/tui/minimal.test.ts | 47 ++++++++++++++++++++++++++++ src/frontends/tui/minimal.ts | 36 ++++++++++++++++++++- 6 files changed, 141 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6bd9698..16ccf08 100644 --- a/README.md +++ b/README.md @@ -531,6 +531,8 @@ pnpm tui:fs | `/skill ` | In-chat skill discovery/install (`list`, `search `, `install `) | | `/quit` | Exit | +TUI keyboard controls: `Esc` cancels active prompt/running response. `Ctrl+C` clears the current input; press `Ctrl+C` twice quickly to exit. + #### Runtime Model Switching Switch providers and models on the fly without editing config or restarting: diff --git a/docs/plans/state.json b/docs/plans/state.json index 12441a5..e5922bd 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5438,6 +5438,21 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/gateway/session-bridge.test.ts src/gateway/server.test.ts src/models/router.test.ts src/models/retry.test.ts + pnpm typecheck passing" + }, + "tui-double-ctrlc-exit-single-ctrlc-clear": { + "status": "completed", + "date": "2026-02-18", + "updated": "2026-02-18", + "summary": "Adjusted TUI keyboard behavior to keep /quit while making Ctrl+C safer: in minimal mode first Ctrl+C clears input and second quick Ctrl+C exits; fullscreen mode mirrors double-press Ctrl+C exit semantics while preserving Esc cancellation. Updated docs and regression tests.", + "files_modified": [ + "src/frontends/tui/minimal.ts", + "src/frontends/tui/minimal.test.ts", + "src/frontends/tui/components/App.tsx", + "src/cli/tui.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/frontends/tui/minimal.test.ts src/frontends/tui/commands.test.ts + pnpm typecheck passing" } }, "overall_progress": { diff --git a/src/cli/tui.ts b/src/cli/tui.ts index 519fae3..c4fe7c6 100644 --- a/src/cli/tui.ts +++ b/src/cli/tui.ts @@ -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(); diff --git a/src/frontends/tui/components/App.tsx b/src/frontends/tui/components/App.tsx index 7ee68f9..3218923 100644 --- a/src/frontends/tui/components/App.tsx +++ b/src/frontends/tui/components/App.tsx @@ -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(session.getHistory()); @@ -91,6 +92,7 @@ export function App({ }); const abortRef = useRef(false); + const lastCtrlCAtRef = useRef(0); const toolLinesRef = useRef([]); 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); diff --git a/src/frontends/tui/minimal.test.ts b/src/frontends/tui/minimal.test.ts index b4d4e57..0708f9b 100644 --- a/src/frontends/tui/minimal.test.ts +++ b/src/frontends/tui/minimal.test.ts @@ -38,15 +38,18 @@ function minimalTuiPrivates(value: MinimalTui): { handleToolEvent: (event: unknown) => void; handleCommand: (command: unknown) => Promise; handleEscapeAction: () => boolean; + handleCtrlCPress: (nowMs?: number) => boolean; prompt: (text: string) => Promise; 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; @@ -56,15 +59,18 @@ function minimalTuiPrivates(value: MinimalTui): { handleToolEvent: (event: unknown) => void; handleCommand: (command: unknown) => Promise; handleEscapeAction: () => boolean; + handleCtrlCPress: (nowMs?: number) => boolean; prompt: (text: string) => Promise; 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(); + } + }); }); diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index 15f579e..584c449 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -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 { while (this.running && this.rl) { + this.lastCtrlCAtMs = 0; this.lastLine = ''; this.currentHint = ''; const input = await this.prompt(formatPrompt('default'));