Ctrl+3 is ESC: the keybinding that cancelled Claude all day — and why I chased everything but the repro
The 'Interrupted' that plagued a whole session traced to one verified cause: the Ctrl+1..9 startup-switch handler didn't fully consume the event, so Ctrl+3 reached xterm — and Ctrl+3 maps to ESC (0x1B) in the terminal, which Claude Code's TUI reads as a cancel. The fix is one block. The lesson is that the user's own reproduction ('I press Ctrl+1/2/3 to switch screens') solved in one line what a day of speculative root-causing did not.
Symptom (literal)
Claude Code, running inside a Talkak pane, kept showing:
⎿ Interrupted · What should Claude do instead?— cancelling its answer mid-stream. The user's decisive description: "나는 지금 컨트롤 1,2,3으로 화면 이동하는데 왜 계속 esc가 눌려서 니가 대답하다 취소되냐고" — it fired when he pressed Ctrl+1 / Ctrl+2 / Ctrl+3 to switch startups.
Cause (verified in code)
apps/desktop/src/App.tsx, the window-level keydown handler (capture phase) for startup
switching:
// Ctrl + 1..9 → switch STARTUP N
if (e.ctrlKey && !e.metaKey && isDigit && !e.shiftKey) {
const idx = Number.parseInt(e.key, 10) - 1;
if (idx < startups.length) {
e.preventDefault(); // ← only on the in-range path
selectStartup(startups[idx].id);
}
return; // ← no stopPropagation, ever
}Two holes, both verified by reading the file:
preventDefault()runs only whenidx < startups.length. Press Ctrl+3 with fewer than 3 startups → no preventDefault → the chord falls straight through to xterm.- Even on the in-range path there is no
stopPropagation(), so the event still reaches xterm's own keydown handler.
And the punchline from terminal encoding: Ctrl+3 sends ESC (0x1B). (Same family: Ctrl+[ = ESC, Ctrl+2 = NUL, Ctrl+4..7 = FS/GS/RS/US.) So a leaked Ctrl+3 writes a bare ESC to the PTY → tmux → Claude's interactive TUI, which reads ESC as the cancel/interrupt key.
Fix
Consume the chord completely, unconditionally:
if (e.ctrlKey && !e.metaKey && isDigit && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const idx = Number.parseInt(e.key, 10) - 1;
if (idx < startups.length) selectStartup(startups[idx].id);
return;
}Commit 863fb5f, shipped in 0.2.12. Confirmation status: pending the user's test (press
Ctrl+1/2/3 after updating; no interrupt = confirmed). Not claimed as fixed until then.
Why I didn't find it for a whole day (honest)
I chased, in order: xterm focus-report escapes (\x1b[I/\x1b[O), a Claude Code version
regression (2.1.162→168), and zombie processes holding "version locks." I shipped a focus-escape
filter (0.2.9/0.2.10) that did not fix it — verified, because the symptom persisted on
0.2.10. Two multi-agent investigations returned high-confidence answers that were wrong or
incomplete.
What actually solved it: the user stated the exact reproduction — "I press Ctrl+1/2/3." That one sentence pointed straight at the keydown handler, and reading it confirmed the cause in minutes.
The lesson is not subtle: ask for the precise reproduction (which key, what action, when) before running speculative investigations. A repro is worth more than three workflows. I had the symptom ("Interrupted on switch") for hours and theorized about its mechanism instead of asking "switch how — which keys?" The cheapest diagnostic was the one I skipped.
Rule produced
Added to CLAUDE.md: a renderer keybinding that intercepts a chord MUST call both
preventDefault() and stopPropagation(), unconditionally for that chord (including the no-op
path), because (a) the event otherwise reaches xterm and (b) several Ctrl+<key> chords are
control characters — Ctrl+3 = ESC being the one that silently cancels an interactive TUI.