feat(tui): single ctrl+c clears input, double ctrl+c exits
This commit is contained in:
@@ -238,7 +238,30 @@ export function registerTuiCommand(program: Command): void {
|
||||
return cleanupPromise;
|
||||
};
|
||||
|
||||
let activeCtrlCHandler: (() => boolean) | null = null;
|
||||
let lastCtrlCAtMs = 0;
|
||||
const ctrlCExitWindowMs = 1_500;
|
||||
|
||||
const signalHandler = (signal: NodeJS.Signals) => {
|
||||
if (signal === 'SIGINT' && activeCtrlCHandler) {
|
||||
// Minimal TUI owns Ctrl+C behavior via readline SIGINT handling.
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal === 'SIGINT') {
|
||||
const now = Date.now();
|
||||
const isDoublePress = lastCtrlCAtMs > 0 && (now - lastCtrlCAtMs) <= ctrlCExitWindowMs;
|
||||
lastCtrlCAtMs = now;
|
||||
|
||||
if (!isDoublePress) {
|
||||
if (activeCtrlCHandler && !activeCtrlCHandler()) {
|
||||
return;
|
||||
}
|
||||
console.log('\nPress Ctrl+C again to quit (or use /quit).');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nReceived ${signal}; shutting down TUI...`);
|
||||
void cleanup()
|
||||
.then(() => process.exit(0))
|
||||
@@ -313,8 +336,10 @@ export function registerTuiCommand(program: Command): void {
|
||||
tui.stop(true);
|
||||
},
|
||||
});
|
||||
activeCtrlCHandler = () => tui.handleCtrlCPress();
|
||||
|
||||
await tui.start();
|
||||
activeCtrlCHandler = null;
|
||||
|
||||
if (switchingToFullscreen) {
|
||||
console.clear();
|
||||
|
||||
@@ -77,6 +77,7 @@ export function App({
|
||||
onTransfer,
|
||||
onExit,
|
||||
}: AppProps): React.ReactElement {
|
||||
const ctrlCExitWindowMs = 1_500;
|
||||
const { exit } = useApp();
|
||||
const [input, setInput] = useState('');
|
||||
const [messages, setMessages] = useState<Message[]>(session.getHistory());
|
||||
@@ -91,6 +92,7 @@ export function App({
|
||||
});
|
||||
|
||||
const abortRef = useRef(false);
|
||||
const lastCtrlCAtRef = useRef(0);
|
||||
const toolLinesRef = useRef<string[]>([]);
|
||||
|
||||
const confirmResolveRef = useRef<((result: HookResult) => void) | null>(null);
|
||||
@@ -177,6 +179,21 @@ export function App({
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.ctrl && inputChar.toLowerCase() === 'c') {
|
||||
const now = Date.now();
|
||||
const shouldExit = lastCtrlCAtRef.current > 0 && (now - lastCtrlCAtRef.current) <= ctrlCExitWindowMs;
|
||||
lastCtrlCAtRef.current = now;
|
||||
if (shouldExit) {
|
||||
onExit?.();
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
if (input.length > 0) {
|
||||
setInput('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab completion for slash commands
|
||||
if (key.tab && !isStreaming) {
|
||||
const completions = getCommandCompletions(input);
|
||||
|
||||
@@ -38,15 +38,18 @@ function minimalTuiPrivates(value: MinimalTui): {
|
||||
handleToolEvent: (event: unknown) => void;
|
||||
handleCommand: (command: unknown) => Promise<void>;
|
||||
handleEscapeAction: () => boolean;
|
||||
handleCtrlCPress: (nowMs?: number) => boolean;
|
||||
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;
|
||||
prompt: () => void;
|
||||
};
|
||||
activePromptCancel: (() => void) | null;
|
||||
activeOperationCancel: (() => void) | null;
|
||||
running: boolean;
|
||||
} {
|
||||
return value as unknown as {
|
||||
handleBackendCommand: (provider: string) => Promise<void>;
|
||||
@@ -56,15 +59,18 @@ function minimalTuiPrivates(value: MinimalTui): {
|
||||
handleToolEvent: (event: unknown) => void;
|
||||
handleCommand: (command: unknown) => Promise<void>;
|
||||
handleEscapeAction: () => boolean;
|
||||
handleCtrlCPress: (nowMs?: number) => boolean;
|
||||
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;
|
||||
prompt: () => void;
|
||||
};
|
||||
activePromptCancel: (() => void) | null;
|
||||
activeOperationCancel: (() => void) | null;
|
||||
running: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -423,6 +429,7 @@ describe('MinimalTui prompt cancellation', () => {
|
||||
onAnswer = cb;
|
||||
}),
|
||||
write,
|
||||
prompt: vi.fn(),
|
||||
};
|
||||
|
||||
const promptPromise = minimalTuiPrivates(tui).prompt('Confirm? ');
|
||||
@@ -457,4 +464,44 @@ describe('MinimalTui prompt cancellation', () => {
|
||||
expect(minimalTuiPrivates(tui).handleEscapeAction()).toBe(true);
|
||||
expect(cancelRunningOperation).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('treats first Ctrl+C as clear-input and second as exit intent', () => {
|
||||
const mockSession = {
|
||||
id: 'test',
|
||||
getHistory: () => [],
|
||||
addMessage: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
replaceHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const write = vi.fn();
|
||||
const prompt = vi.fn();
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
try {
|
||||
const tui = new MinimalTui({
|
||||
session: asSession(mockSession),
|
||||
modelClient: asRouter({}),
|
||||
systemPrompt: 'test',
|
||||
});
|
||||
minimalTuiPrivates(tui).rl = {
|
||||
once: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
question: vi.fn(),
|
||||
write,
|
||||
prompt,
|
||||
};
|
||||
minimalTuiPrivates(tui).running = true;
|
||||
|
||||
const first = minimalTuiPrivates(tui).handleCtrlCPress(1000);
|
||||
const second = minimalTuiPrivates(tui).handleCtrlCPress(2000);
|
||||
|
||||
expect(first).toBe(false);
|
||||
expect(second).toBe(true);
|
||||
expect(write).toHaveBeenCalledWith(null, { ctrl: true, name: 'u' });
|
||||
expect(prompt).toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Press Ctrl+C again to quit'));
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface MinimalTuiConfig {
|
||||
}
|
||||
|
||||
export class MinimalTui {
|
||||
private static readonly CTRL_C_EXIT_WINDOW_MS = 1_500;
|
||||
private rl: readline.Interface | null = null;
|
||||
private running = false;
|
||||
private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 };
|
||||
@@ -81,6 +82,7 @@ export class MinimalTui {
|
||||
private activeOperationCancel: (() => void) | null = null;
|
||||
private keypressHandler: ((char: string, key: readline.Key) => void) | null = null;
|
||||
private verbose = false;
|
||||
private lastCtrlCAtMs = 0;
|
||||
|
||||
constructor(private config: MinimalTuiConfig) {}
|
||||
|
||||
@@ -218,6 +220,12 @@ export class MinimalTui {
|
||||
},
|
||||
});
|
||||
|
||||
this.rl.on('SIGINT', () => {
|
||||
if (this.handleCtrlCPress()) {
|
||||
this.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// In minimal TUI we can prompt inline for tool confirmations.
|
||||
// This avoids deadlocks when hooks are configured to require confirmation
|
||||
// (e.g. shell.exec) and the tool loop is awaiting a decision.
|
||||
@@ -265,13 +273,39 @@ 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 or running responses${colors.reset}\n`);
|
||||
console.log(`${colors.gray}Type /help for commands, /fullscreen for panel mode, Esc to cancel prompts/runs, Ctrl+C to clear input (press twice to quit)${colors.reset}\n`);
|
||||
|
||||
await this.promptLoop();
|
||||
}
|
||||
|
||||
handleCtrlCPress(nowMs = Date.now()): boolean {
|
||||
if (!this.rl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const shouldExit = this.lastCtrlCAtMs > 0
|
||||
&& (nowMs - this.lastCtrlCAtMs) <= MinimalTui.CTRL_C_EXIT_WINDOW_MS;
|
||||
this.lastCtrlCAtMs = nowMs;
|
||||
|
||||
if (shouldExit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
this.rl.write(null, { ctrl: true, name: 'u' });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
console.log(`${colors.gray}Press Ctrl+C again to quit (or use /quit).${colors.reset}`);
|
||||
if (this.running) {
|
||||
this.rl.prompt();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async promptLoop(): Promise<void> {
|
||||
while (this.running && this.rl) {
|
||||
this.lastCtrlCAtMs = 0;
|
||||
this.lastLine = '';
|
||||
this.currentHint = '';
|
||||
const input = await this.prompt(formatPrompt('default'));
|
||||
|
||||
Reference in New Issue
Block a user