feat(tui): add Esc key to cancel active prompt without exiting

This commit is contained in:
William Valentin
2026-02-16 11:31:17 -08:00
parent 598a77947d
commit c34ae9b75b
2 changed files with 106 additions and 8 deletions
+60
View File
@@ -29,10 +29,26 @@ function asAgent(value: unknown): NativeAgent {
function minimalTuiPrivates(value: MinimalTui): {
handleBackendCommand: (provider: string) => Promise<void>;
handleModelCommand: (tier: string, providerModel?: string) => void;
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;
};
activePromptCancel: (() => void) | null;
} {
return value as unknown as {
handleBackendCommand: (provider: string) => Promise<void>;
handleModelCommand: (tier: string, providerModel?: string) => void;
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;
};
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();
});
});
+46 -8
View File
@@ -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;
}
}
}