Daeseon Yoo
Back to project
·Troubleshoot·3 min

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:

  1. preventDefault() runs only when idx < startups.length. Press Ctrl+3 with fewer than 3 startups → no preventDefault → the chord falls straight through to xterm.
  2. 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.