feat(tui): single ctrl+c clears input, double ctrl+c exits
This commit is contained in:
@@ -531,6 +531,8 @@ pnpm tui:fs
|
|||||||
| `/skill <list|search|install>` | In-chat skill discovery/install (`list`, `search <term>`, `install <registry-id>`) |
|
| `/skill <list|search|install>` | In-chat skill discovery/install (`list`, `search <term>`, `install <registry-id>`) |
|
||||||
| `/quit` | Exit |
|
| `/quit` | Exit |
|
||||||
|
|
||||||
|
TUI keyboard controls: `Esc` cancels active prompt/running response. `Ctrl+C` clears the current input; press `Ctrl+C` twice quickly to exit.
|
||||||
|
|
||||||
#### Runtime Model Switching
|
#### Runtime Model Switching
|
||||||
|
|
||||||
Switch providers and models on the fly without editing config or restarting:
|
Switch providers and models on the fly without editing config or restarting:
|
||||||
|
|||||||
@@ -5438,6 +5438,21 @@
|
|||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/gateway/session-bridge.test.ts src/gateway/server.test.ts src/models/router.test.ts src/models/retry.test.ts + pnpm typecheck passing"
|
"test_status": "pnpm test:run src/gateway/session-bridge.test.ts src/gateway/server.test.ts src/models/router.test.ts src/models/retry.test.ts + pnpm typecheck passing"
|
||||||
|
},
|
||||||
|
"tui-double-ctrlc-exit-single-ctrlc-clear": {
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-18",
|
||||||
|
"updated": "2026-02-18",
|
||||||
|
"summary": "Adjusted TUI keyboard behavior to keep /quit while making Ctrl+C safer: in minimal mode first Ctrl+C clears input and second quick Ctrl+C exits; fullscreen mode mirrors double-press Ctrl+C exit semantics while preserving Esc cancellation. Updated docs and regression tests.",
|
||||||
|
"files_modified": [
|
||||||
|
"src/frontends/tui/minimal.ts",
|
||||||
|
"src/frontends/tui/minimal.test.ts",
|
||||||
|
"src/frontends/tui/components/App.tsx",
|
||||||
|
"src/cli/tui.ts",
|
||||||
|
"README.md",
|
||||||
|
"docs/plans/state.json"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm test:run src/frontends/tui/minimal.test.ts src/frontends/tui/commands.test.ts + pnpm typecheck passing"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
|
|||||||
@@ -238,7 +238,30 @@ export function registerTuiCommand(program: Command): void {
|
|||||||
return cleanupPromise;
|
return cleanupPromise;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let activeCtrlCHandler: (() => boolean) | null = null;
|
||||||
|
let lastCtrlCAtMs = 0;
|
||||||
|
const ctrlCExitWindowMs = 1_500;
|
||||||
|
|
||||||
const signalHandler = (signal: NodeJS.Signals) => {
|
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...`);
|
console.log(`\nReceived ${signal}; shutting down TUI...`);
|
||||||
void cleanup()
|
void cleanup()
|
||||||
.then(() => process.exit(0))
|
.then(() => process.exit(0))
|
||||||
@@ -313,8 +336,10 @@ export function registerTuiCommand(program: Command): void {
|
|||||||
tui.stop(true);
|
tui.stop(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
activeCtrlCHandler = () => tui.handleCtrlCPress();
|
||||||
|
|
||||||
await tui.start();
|
await tui.start();
|
||||||
|
activeCtrlCHandler = null;
|
||||||
|
|
||||||
if (switchingToFullscreen) {
|
if (switchingToFullscreen) {
|
||||||
console.clear();
|
console.clear();
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export function App({
|
|||||||
onTransfer,
|
onTransfer,
|
||||||
onExit,
|
onExit,
|
||||||
}: AppProps): React.ReactElement {
|
}: AppProps): React.ReactElement {
|
||||||
|
const ctrlCExitWindowMs = 1_500;
|
||||||
const { exit } = useApp();
|
const { exit } = useApp();
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [messages, setMessages] = useState<Message[]>(session.getHistory());
|
const [messages, setMessages] = useState<Message[]>(session.getHistory());
|
||||||
@@ -91,6 +92,7 @@ export function App({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const abortRef = useRef(false);
|
const abortRef = useRef(false);
|
||||||
|
const lastCtrlCAtRef = useRef(0);
|
||||||
const toolLinesRef = useRef<string[]>([]);
|
const toolLinesRef = useRef<string[]>([]);
|
||||||
|
|
||||||
const confirmResolveRef = useRef<((result: HookResult) => void) | null>(null);
|
const confirmResolveRef = useRef<((result: HookResult) => void) | null>(null);
|
||||||
@@ -177,6 +179,21 @@ export function App({
|
|||||||
return;
|
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
|
// Tab completion for slash commands
|
||||||
if (key.tab && !isStreaming) {
|
if (key.tab && !isStreaming) {
|
||||||
const completions = getCommandCompletions(input);
|
const completions = getCommandCompletions(input);
|
||||||
|
|||||||
@@ -38,15 +38,18 @@ function minimalTuiPrivates(value: MinimalTui): {
|
|||||||
handleToolEvent: (event: unknown) => void;
|
handleToolEvent: (event: unknown) => void;
|
||||||
handleCommand: (command: unknown) => Promise<void>;
|
handleCommand: (command: unknown) => Promise<void>;
|
||||||
handleEscapeAction: () => boolean;
|
handleEscapeAction: () => boolean;
|
||||||
|
handleCtrlCPress: (nowMs?: number) => boolean;
|
||||||
prompt: (text: string) => Promise<string>;
|
prompt: (text: string) => Promise<string>;
|
||||||
rl: {
|
rl: {
|
||||||
once: (event: string, cb: () => void) => void;
|
once: (event: string, cb: () => void) => void;
|
||||||
removeListener: (event: string, cb: () => void) => void;
|
removeListener: (event: string, cb: () => void) => void;
|
||||||
question: (text: string, cb: (answer: string) => void) => void;
|
question: (text: string, cb: (answer: string) => void) => void;
|
||||||
write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void;
|
write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void;
|
||||||
|
prompt: () => void;
|
||||||
};
|
};
|
||||||
activePromptCancel: (() => void) | null;
|
activePromptCancel: (() => void) | null;
|
||||||
activeOperationCancel: (() => void) | null;
|
activeOperationCancel: (() => void) | null;
|
||||||
|
running: boolean;
|
||||||
} {
|
} {
|
||||||
return value as unknown as {
|
return value as unknown as {
|
||||||
handleBackendCommand: (provider: string) => Promise<void>;
|
handleBackendCommand: (provider: string) => Promise<void>;
|
||||||
@@ -56,15 +59,18 @@ function minimalTuiPrivates(value: MinimalTui): {
|
|||||||
handleToolEvent: (event: unknown) => void;
|
handleToolEvent: (event: unknown) => void;
|
||||||
handleCommand: (command: unknown) => Promise<void>;
|
handleCommand: (command: unknown) => Promise<void>;
|
||||||
handleEscapeAction: () => boolean;
|
handleEscapeAction: () => boolean;
|
||||||
|
handleCtrlCPress: (nowMs?: number) => boolean;
|
||||||
prompt: (text: string) => Promise<string>;
|
prompt: (text: string) => Promise<string>;
|
||||||
rl: {
|
rl: {
|
||||||
once: (event: string, cb: () => void) => void;
|
once: (event: string, cb: () => void) => void;
|
||||||
removeListener: (event: string, cb: () => void) => void;
|
removeListener: (event: string, cb: () => void) => void;
|
||||||
question: (text: string, cb: (answer: string) => void) => void;
|
question: (text: string, cb: (answer: string) => void) => void;
|
||||||
write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void;
|
write: (data: string | null, key?: { ctrl?: boolean; name?: string }) => void;
|
||||||
|
prompt: () => void;
|
||||||
};
|
};
|
||||||
activePromptCancel: (() => void) | null;
|
activePromptCancel: (() => void) | null;
|
||||||
activeOperationCancel: (() => void) | null;
|
activeOperationCancel: (() => void) | null;
|
||||||
|
running: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,6 +429,7 @@ describe('MinimalTui prompt cancellation', () => {
|
|||||||
onAnswer = cb;
|
onAnswer = cb;
|
||||||
}),
|
}),
|
||||||
write,
|
write,
|
||||||
|
prompt: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const promptPromise = minimalTuiPrivates(tui).prompt('Confirm? ');
|
const promptPromise = minimalTuiPrivates(tui).prompt('Confirm? ');
|
||||||
@@ -457,4 +464,44 @@ describe('MinimalTui prompt cancellation', () => {
|
|||||||
expect(minimalTuiPrivates(tui).handleEscapeAction()).toBe(true);
|
expect(minimalTuiPrivates(tui).handleEscapeAction()).toBe(true);
|
||||||
expect(cancelRunningOperation).toHaveBeenCalledOnce();
|
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 {
|
export class MinimalTui {
|
||||||
|
private static readonly CTRL_C_EXIT_WINDOW_MS = 1_500;
|
||||||
private rl: readline.Interface | null = null;
|
private rl: readline.Interface | null = null;
|
||||||
private running = false;
|
private running = false;
|
||||||
private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 };
|
private totalUsage: TokenUsage = { inputTokens: 0, outputTokens: 0 };
|
||||||
@@ -81,6 +82,7 @@ export class MinimalTui {
|
|||||||
private activeOperationCancel: (() => void) | null = null;
|
private activeOperationCancel: (() => void) | null = null;
|
||||||
private keypressHandler: ((char: string, key: readline.Key) => void) | null = null;
|
private keypressHandler: ((char: string, key: readline.Key) => void) | null = null;
|
||||||
private verbose = false;
|
private verbose = false;
|
||||||
|
private lastCtrlCAtMs = 0;
|
||||||
|
|
||||||
constructor(private config: MinimalTuiConfig) {}
|
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.
|
// In minimal TUI we can prompt inline for tool confirmations.
|
||||||
// This avoids deadlocks when hooks are configured to require confirmation
|
// This avoids deadlocks when hooks are configured to require confirmation
|
||||||
// (e.g. shell.exec) and the tool loop is awaiting a decision.
|
// (e.g. shell.exec) and the tool loop is awaiting a decision.
|
||||||
@@ -265,13 +273,39 @@ 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, 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();
|
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> {
|
private async promptLoop(): Promise<void> {
|
||||||
while (this.running && this.rl) {
|
while (this.running && this.rl) {
|
||||||
|
this.lastCtrlCAtMs = 0;
|
||||||
this.lastLine = '';
|
this.lastLine = '';
|
||||||
this.currentHint = '';
|
this.currentHint = '';
|
||||||
const input = await this.prompt(formatPrompt('default'));
|
const input = await this.prompt(formatPrompt('default'));
|
||||||
|
|||||||
Reference in New Issue
Block a user