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): {
|
function minimalTuiPrivates(value: MinimalTui): {
|
||||||
handleBackendCommand: (provider: string) => Promise<void>;
|
handleBackendCommand: (provider: string) => Promise<void>;
|
||||||
handleModelCommand: (tier: string, providerModel?: string) => 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 {
|
return value as unknown as {
|
||||||
handleBackendCommand: (provider: string) => Promise<void>;
|
handleBackendCommand: (provider: string) => Promise<void>;
|
||||||
handleModelCommand: (tier: string, providerModel?: string) => 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 totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 };
|
||||||
private currentHint = '';
|
private currentHint = '';
|
||||||
private lastLine = '';
|
private lastLine = '';
|
||||||
|
private activePromptCancel: (() => void) | null = null;
|
||||||
|
private keypressHandler: ((char: string, key: readline.Key) => void) | null = null;
|
||||||
|
|
||||||
constructor(private config: MinimalTuiConfig) {}
|
constructor(private config: MinimalTuiConfig) {}
|
||||||
|
|
||||||
@@ -156,7 +158,12 @@ export class MinimalTui {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Listen for line changes to show hints
|
// 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
|
// Small delay to let readline update the line
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
if (this.rl) {
|
if (this.rl) {
|
||||||
@@ -167,7 +174,8 @@ export class MinimalTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
process.stdin.on('keypress', this.keypressHandler);
|
||||||
|
|
||||||
// Enable keypress events
|
// Enable keypress events
|
||||||
if (process.stdin.isTTY) {
|
if (process.stdin.isTTY) {
|
||||||
@@ -176,7 +184,7 @@ export class MinimalTui {
|
|||||||
|
|
||||||
console.log(getColoredBanner());
|
console.log(getColoredBanner());
|
||||||
console.log(`\n${colors.orange}${colors.bold}Flynn TUI${colors.reset} ${colors.dim}(minimal mode)${colors.reset}`);
|
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();
|
await this.promptLoop();
|
||||||
}
|
}
|
||||||
@@ -203,11 +211,35 @@ export class MinimalTui {
|
|||||||
resolve('');
|
resolve('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const onClose = () => resolve('');
|
|
||||||
this.rl.once('close', onClose);
|
let settled = false;
|
||||||
this.rl.question(promptText, (answer) => {
|
const finish = (answer: string) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
this.activePromptCancel = null;
|
||||||
this.rl?.removeListener('close', onClose);
|
this.rl?.removeListener('close', onClose);
|
||||||
resolve(answer);
|
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 {
|
stop(preserveStdin = false): void {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
|
this.activePromptCancel = null;
|
||||||
if (this.rl) {
|
if (this.rl) {
|
||||||
if (preserveStdin) {
|
if (preserveStdin) {
|
||||||
// Remove readline listeners but don't close stdin
|
// Remove readline listeners but don't close stdin
|
||||||
this.rl.removeAllListeners();
|
this.rl.removeAllListeners();
|
||||||
process.stdin.removeAllListeners('keypress');
|
if (this.keypressHandler) {
|
||||||
|
process.stdin.removeListener('keypress', this.keypressHandler);
|
||||||
|
}
|
||||||
// Pause stdin so readline releases it
|
// Pause stdin so readline releases it
|
||||||
process.stdin.pause();
|
process.stdin.pause();
|
||||||
}
|
}
|
||||||
@@ -862,6 +897,9 @@ export class MinimalTui {
|
|||||||
this.rl = null;
|
this.rl = null;
|
||||||
}
|
}
|
||||||
// Clean up keypress listener
|
// 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