feat(tui): add Esc key to cancel active prompt without exiting
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user