From 527602fd8ab14795ce51766cf01a66f1fbd418f8 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 11:42:25 -0800 Subject: [PATCH] feat(tui): let Esc cancel active minimal-mode response generation --- src/frontends/tui/minimal.test.ts | 26 ++++++++++++ src/frontends/tui/minimal.ts | 68 +++++++++++++++++++++++++------ 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/frontends/tui/minimal.test.ts b/src/frontends/tui/minimal.test.ts index e7e9cc4..9b02c64 100644 --- a/src/frontends/tui/minimal.test.ts +++ b/src/frontends/tui/minimal.test.ts @@ -29,6 +29,7 @@ function asAgent(value: unknown): NativeAgent { function minimalTuiPrivates(value: MinimalTui): { handleBackendCommand: (provider: string) => Promise; handleModelCommand: (tier: string, providerModel?: string) => void; + handleEscapeAction: () => boolean; prompt: (text: string) => Promise; rl: { once: (event: string, cb: () => void) => void; @@ -37,10 +38,12 @@ function minimalTuiPrivates(value: MinimalTui): { write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void; }; activePromptCancel: (() => void) | null; + activeOperationCancel: (() => void) | null; } { return value as unknown as { handleBackendCommand: (provider: string) => Promise; handleModelCommand: (tier: string, providerModel?: string) => void; + handleEscapeAction: () => boolean; prompt: (text: string) => Promise; rl: { once: (event: string, cb: () => void) => void; @@ -49,6 +52,7 @@ function minimalTuiPrivates(value: MinimalTui): { write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void; }; activePromptCancel: (() => void) | null; + activeOperationCancel: (() => void) | null; }; } @@ -329,4 +333,26 @@ describe('MinimalTui prompt cancellation', () => { expect(write).toHaveBeenCalledWith(null, { name: 'return' }); expect(minimalTuiPrivates(tui).activePromptCancel).toBeNull(); }); + + it('uses Esc to cancel active running operation', () => { + 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', + }); + + const cancelRunningOperation = vi.fn(); + minimalTuiPrivates(tui).activeOperationCancel = cancelRunningOperation; + + expect(minimalTuiPrivates(tui).handleEscapeAction()).toBe(true); + expect(cancelRunningOperation).toHaveBeenCalledOnce(); + }); }); diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index c24fc87..a8c8a7e 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -76,6 +76,7 @@ export class MinimalTui { private currentHint = ''; private lastLine = ''; private activePromptCancel: (() => void) | null = null; + private activeOperationCancel: (() => void) | null = null; private keypressHandler: ((char: string, key: readline.Key) => void) | null = null; constructor(private config: MinimalTuiConfig) {} @@ -84,6 +85,29 @@ export class MinimalTui { return key?.name === 'escape' || key?.sequence === '\x1b' || char === '\x1b'; } + private handleEscapeAction(): boolean { + if (this.activePromptCancel) { + this.activePromptCancel(); + return true; + } + + if (this.activeOperationCancel) { + this.activeOperationCancel(); + return true; + } + + if (this.rl) { + try { + this.rl.write(null, { ctrl: true, name: 'u' }); + } catch { + // ignore + } + return true; + } + + return false; + } + private showHint(line: string): void { if (!line.startsWith('/')) { this.clearHint(); @@ -164,17 +188,7 @@ export class MinimalTui { // Listen for line changes to show hints this.keypressHandler = (char: string, key: readline.Key) => { if (this.isEscapeKey(char, key)) { - if (this.activePromptCancel) { - this.activePromptCancel(); - return; - } - if (this.rl) { - try { - this.rl.write(null, { ctrl: true, name: 'u' }); - } catch { - // ignore - } - } + this.handleEscapeAction(); return; } @@ -198,7 +212,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, Esc to cancel prompts${colors.reset}\n`); + console.log(`${colors.gray}Type /help for commands, /fullscreen for panel mode, Esc to cancel prompts or running responses${colors.reset}\n`); await this.promptLoop(); } @@ -917,7 +931,17 @@ export class MinimalTui { try { // Use agent if available (supports tool loop) if (this.config.agent) { + let cancelRequested = false; + this.activeOperationCancel = () => { + if (cancelRequested) { + return; + } + cancelRequested = true; + this.config.agent?.cancel(); + console.log(`\n${colors.gray}Cancelling...${colors.reset}`); + }; const response = await this.config.agent.process(content); + this.activeOperationCancel = null; const rendered = renderMarkdown(response); console.log(rendered); console.log(); @@ -930,11 +954,23 @@ export class MinimalTui { // Try streaming if available if (this.config.modelClient.chatStream) { let fullContent = ''; + let cancelRequested = false; + this.activeOperationCancel = () => { + if (cancelRequested) { + return; + } + cancelRequested = true; + console.log(`\n${colors.gray}Cancelling...${colors.reset}`); + }; for await (const event of this.config.modelClient.chatStream({ messages: this.config.session.getHistory(), system: this.config.systemPrompt, })) { + if (cancelRequested) { + fullContent += '\n\n[interrupted]'; + break; + } if (event.type === 'content' && event.content) { process.stdout.write(event.content); fullContent += event.content; @@ -950,16 +986,21 @@ export class MinimalTui { throw event.error ?? new Error('Stream error'); } } + this.activeOperationCancel = null; console.log('\n'); this.config.session.addMessage({ role: 'assistant', content: fullContent }); } else { + this.activeOperationCancel = () => { + console.log(`\n${colors.gray}Cancellation is not available for non-streaming responses.${colors.reset}`); + }; // Fallback to non-streaming const response = await this.config.modelClient.chat({ messages: this.config.session.getHistory(), system: this.config.systemPrompt, }); + this.activeOperationCancel = null; const rendered = renderMarkdown(response.content); console.log(rendered); @@ -973,12 +1014,15 @@ export class MinimalTui { } catch (error) { console.error('Error:', error instanceof Error ? error.message : error); console.log(); + } finally { + this.activeOperationCancel = null; } } stop(preserveStdin = false): void { this.running = false; this.activePromptCancel = null; + this.activeOperationCancel = null; if (this.rl) { if (preserveStdin) { // Remove readline listeners but don't close stdin