From c34ae9b75be8e1495ace3c3044efc4a9d40db152 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 11:31:17 -0800 Subject: [PATCH] feat(tui): add Esc key to cancel active prompt without exiting --- src/frontends/tui/minimal.test.ts | 60 +++++++++++++++++++++++++++++++ src/frontends/tui/minimal.ts | 54 +++++++++++++++++++++++----- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/frontends/tui/minimal.test.ts b/src/frontends/tui/minimal.test.ts index b584d48..e7e9cc4 100644 --- a/src/frontends/tui/minimal.test.ts +++ b/src/frontends/tui/minimal.test.ts @@ -29,10 +29,26 @@ function asAgent(value: unknown): NativeAgent { function minimalTuiPrivates(value: MinimalTui): { handleBackendCommand: (provider: string) => Promise; handleModelCommand: (tier: string, providerModel?: string) => void; + 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; + }; + activePromptCancel: (() => void) | null; } { return value as unknown as { handleBackendCommand: (provider: string) => Promise; handleModelCommand: (tier: string, providerModel?: string) => void; + 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; + }; + activePromptCancel: (() => void) | null; }; } @@ -270,3 +286,47 @@ describe('MinimalTui backend command', () => { } }); }); + +describe('MinimalTui prompt cancellation', () => { + it('cancels an active prompt without closing the TUI', async () => { + const mockSession = { + id: 'test', + getHistory: () => [], + addMessage: vi.fn(), + clear: vi.fn(), + replaceHistory: vi.fn(), + }; + + const tui = new MinimalTui({ + session: asSession(mockSession), + modelClient: asRouter({}), + systemPrompt: 'test', + }); + + let onAnswer: ((answer: string) => void) | undefined; + const write = vi.fn((_: string | null, key?: { ctrl?: boolean; name?: string }) => { + if (key?.name === 'return') { + onAnswer?.(''); + } + }); + + minimalTuiPrivates(tui).rl = { + once: vi.fn(), + removeListener: vi.fn(), + question: vi.fn((_text: string, cb: (answer: string) => void) => { + onAnswer = cb; + }), + write, + }; + + const promptPromise = minimalTuiPrivates(tui).prompt('Confirm? '); + expect(minimalTuiPrivates(tui).activePromptCancel).toBeTypeOf('function'); + + minimalTuiPrivates(tui).activePromptCancel?.(); + + await expect(promptPromise).resolves.toBe(''); + expect(write).toHaveBeenCalledWith(null, { ctrl: true, name: 'u' }); + expect(write).toHaveBeenCalledWith(null, { name: 'return' }); + expect(minimalTuiPrivates(tui).activePromptCancel).toBeNull(); + }); +}); diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index af0d56e..1cc26df 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -75,6 +75,8 @@ export class MinimalTui { private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 }; private currentHint = ''; private lastLine = ''; + private activePromptCancel: (() => void) | null = null; + private keypressHandler: ((char: string, key: readline.Key) => void) | null = null; constructor(private config: MinimalTuiConfig) {} @@ -156,7 +158,12 @@ export class MinimalTui { } // Listen for line changes to show hints - process.stdin.on('keypress', () => { + this.keypressHandler = (_char: string, key: readline.Key) => { + if (key?.name === 'escape' && this.activePromptCancel) { + this.activePromptCancel(); + return; + } + // Small delay to let readline update the line setImmediate(() => { if (this.rl) { @@ -167,7 +174,8 @@ export class MinimalTui { } } }); - }); + }; + process.stdin.on('keypress', this.keypressHandler); // Enable keypress events if (process.stdin.isTTY) { @@ -176,7 +184,7 @@ 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${colors.reset}\n`); + console.log(`${colors.gray}Type /help for commands, /fullscreen for panel mode, Esc to cancel prompts${colors.reset}\n`); await this.promptLoop(); } @@ -203,11 +211,35 @@ export class MinimalTui { resolve(''); return; } - const onClose = () => resolve(''); - this.rl.once('close', onClose); - this.rl.question(promptText, (answer) => { + + let settled = false; + const finish = (answer: string) => { + if (settled) { + return; + } + settled = true; + this.activePromptCancel = null; this.rl?.removeListener('close', onClose); resolve(answer); + }; + + const onClose = () => finish(''); + this.rl.once('close', onClose); + this.activePromptCancel = () => { + if (!this.rl) { + finish(''); + return; + } + try { + this.rl.write(null, { ctrl: true, name: 'u' }); + this.rl.write(null, { name: 'return' }); + } catch { + finish(''); + } + }; + + this.rl.question(promptText, (answer) => { + finish(answer); }); }); } @@ -850,11 +882,14 @@ export class MinimalTui { stop(preserveStdin = false): void { this.running = false; + this.activePromptCancel = null; if (this.rl) { if (preserveStdin) { // Remove readline listeners but don't close stdin this.rl.removeAllListeners(); - process.stdin.removeAllListeners('keypress'); + if (this.keypressHandler) { + process.stdin.removeListener('keypress', this.keypressHandler); + } // Pause stdin so readline releases it process.stdin.pause(); } @@ -862,6 +897,9 @@ export class MinimalTui { this.rl = null; } // Clean up keypress listener - process.stdin.removeAllListeners('keypress'); + if (this.keypressHandler) { + process.stdin.removeListener('keypress', this.keypressHandler); + this.keypressHandler = null; + } } }