Skip to content

Commit bb6c2e2

Browse files
committed
fix(ui): Ctrl-C aborts; double-tap exits regardless of state
Before: a single Ctrl-C while the agent was busy aborted the turn but never armed the exit window, so users had no fast way out during a long generation. They had to wait for the abort to complete, land in idle, then press Ctrl-C twice. Frustrating when you want to bail. Now: every Ctrl-C arms a 1s double-tap window. While busy, the first press also aborts (silent — the abort is its own feedback). While idle, the first press shows the 'press again to exit' hint. The second press within 1s exits in either case. 'Twice real fast = quit' is the standard CLI convention.
1 parent 73f65db commit bb6c2e2

2 files changed

Lines changed: 22 additions & 13 deletions

File tree

src/ui/App.tsx

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -347,25 +347,34 @@ function ChatApp({ bundle, onExit }: ChatAppProps) {
347347
}
348348
};
349349

350-
// Double-tap-to-exit: first Ctrl-C when idle posts a hint, second
351-
// within 2s actually exits. While busy, Ctrl-C cancels the turn (one
352-
// press) — the user already meant to interrupt and shouldn't have
353-
// to confirm. The hint is a status line that times out on its own
354-
// so the user doesn't end up with a stale message stuck below.
350+
// Ctrl-C semantics:
351+
// • While the agent is busy: abort the turn. Stays in the app.
352+
// • A second Ctrl-C within DOUBLE_TAP_MS exits, regardless of
353+
// whether the previous press aborted or just landed a hint.
354+
// "Twice real fast" is universally understood as "I want out."
355+
// • While idle: first press posts a hint + arms the exit window.
356+
//
357+
// 1000ms is "real fast" — wide enough that intentional double-taps
358+
// register, tight enough that mashing Ctrl-C twice while flustered
359+
// doesn't accidentally exit. Tunable here if testers want it longer.
355360
const exitTimerRef = useMemo(() => ({ deadline: 0 }), []);
356361
const handleAbort = () => {
357-
if (busy) {
358-
bundle.agent.abort();
359-
dispatch({ type: "abort" });
360-
return;
361-
}
362+
const DOUBLE_TAP_MS = 1000;
362363
const now = Date.now();
363364
if (now < exitTimerRef.deadline) {
364365
onExit();
365366
return;
366367
}
367-
exitTimerRef.deadline = now + 2000;
368-
appendStatus("Press Ctrl-C again within 2s to exit.");
368+
exitTimerRef.deadline = now + DOUBLE_TAP_MS;
369+
if (busy) {
370+
bundle.agent.abort();
371+
dispatch({ type: "abort" });
372+
// No hint here — the abort itself is the feedback. The
373+
// exit window is set silently so a quick second tap still
374+
// gets the user out without confirmation theater.
375+
return;
376+
}
377+
appendStatus("Press Ctrl-C again to exit.");
369378
};
370379

371380
return (

src/ui/Input.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ function pickPlaceholder(hasHistory: boolean): string {
8282
* Ctrl-W kill word before cursor
8383
* Ctrl-Y yank (paste from kill ring)
8484
* Ctrl-Z undo
85-
* Ctrl-C abort (busy → cancel turn, idle → exit app)
85+
* Ctrl-C busy → cancel turn (stay in app); double-tap → exit
8686
*/
8787
export function Input({ disabled, onSubmit, onAbort, commands, history, cwd }: InputProps) {
8888
const [state, setState] = useState(initialInputState());

0 commit comments

Comments
 (0)