[BUG] Vi mode escape key has 50ms hardcoded delay in tmux — kitty keyboard protocol negotiation silently swallowed

Resolved 💬 4 comments Opened Feb 26, 2026 by wrizvi Closed Mar 28, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

When using vim keybindings inside tmux, pressing Escape to exit insert mode incurs a 50ms delay (NORMAL_TIMEOUT=50 in Claude Code's modified Ink input layer) before the key is recognized. This makes rapid esc + motion key chaining (e.g., esc then b to move back a word) noticeably slower than outside tmux, where the kitty keyboard protocol encodes Escape as the unambiguous \033[27u sequence that is parsed instantly with 0ms delay.

The root cause is that tmux always sends bare \033 for unmodified Escape to inner programs, regardless of extended-keys or extended-keys-format settings. Claude Code's tokenizer receives bare \033, marks the parse state as "incomplete" (it could be the start of an escape sequence), and waits NORMAL_TIMEOUT (50ms) before flushing it as a standalone Escape.

This 50ms penalty occurs regardless of how Claude Code detects the terminal:

Case 1 — default-terminal "xterm-ghostty": Claude Code's U_D() terminal detection function matches process.env.TERM === "xterm-ghostty" and returns "ghostty", which is in the sB_ allow-list ["iTerm.app","kitty","WezTerm","ghostty"]. Claude Code enables the kitty keyboard protocol by writing \033[>1u to stdout. However, tmux has no handler for this CSI sequence — its input_csi_table only maps { 'u', "", INPUT_CSI_RCP } (no > intermediate). The enable sequence is silently dropped. Ghostty never enters kitty keyboard mode for this pane. tmux sends bare \033 for Escape. Claude Code waits 50ms.

Case 2 — default-terminal "tmux-256color": Claude Code's U_D() falls through to process.env.TMUX and returns "tmux", which is NOT in the sB_ allow-list. Kitty protocol is never enabled. tmux sends bare \033 for Escape. Claude Code waits 50ms.

Why this doesn't happen outside tmux: When Claude Code runs directly in Ghostty (or iTerm2, kitty, WezTerm), the terminal receives \033[>1u, enters kitty keyboard mode, and encodes Escape as \033[27u. Claude Code's tokenizer matches it against the CSI u regex ^\x1b\[(\d+)(?:;(\d+))?u, resolves codepoint 27 as "escape", and emits the key immediately with 0ms delay.

Why other vim-mode apps in tmux don't have this lag: zsh vi-mode has a configurable KEYTIMEOUT (typically 1 = 10ms). Neovim has a configurable ttimeoutlen. Claude Code's NORMAL_TIMEOUT=50 is hardcoded and not configurable via any setting, environment variable, or config file.

What Should Happen?

Escape should be recognized with minimal delay inside tmux, comparable to the experience outside tmux. Possible fixes:

  1. Make NORMAL_TIMEOUT configurable via an environment variable (e.g., CLAUDE_CODE_ESCAPE_TIMEOUT=10). This is the simplest fix and lets users choose their own trade-off.
  2. Reduce the default NORMAL_TIMEOUT from 50ms to 10-15ms. Modern terminals and tmux with escape-time 0 deliver complete escape sequences well within 10ms.
  3. Detect tmux and use DCS passthrough to negotiate kitty keyboard protocol with the outer terminal, similar to how Neovim handles this.

Error Messages/Logs

# No error messages. The issue is a 50ms input latency, not a crash.
# Relevant code extracted from the compiled binary (~/.local/share/claude/versions/2.1.59):

# Hardcoded timeout in modified Ink InternalApp class:
NORMAL_TIMEOUT = 50;  # 50ms escape disambiguation
PASTE_TIMEOUT = 500;

# Terminal allow-list for kitty keyboard protocol:
sB_ = ["iTerm.app", "kitty", "WezTerm", "ghostty"];

# Terminal detection — TERM=xterm-ghostty triggers "ghostty" even inside tmux:
if (process.env.TERM === "xterm-ghostty") return "ghostty";

# Kitty protocol enable sequence (swallowed by tmux):
i9_ = $G(">1u")  # resolves to \033[>1u

# Input processing with escape timeout:
processInput = (T) => {
  let [R, A] = dB_(this.keyParseState, T);
  if (this.keyParseState.incomplete) {
    this.incompleteEscapeTimer = setTimeout(
      this.flushIncomplete,
      this.keyParseState.mode === "IN_PASTE"
        ? this.PASTE_TIMEOUT   # 500ms
        : this.NORMAL_TIMEOUT  # 50ms  <-- the delay
    );
  }
};

Steps to Reproduce

  1. Configure tmux: set -s escape-time 0, set -s extended-keys always, set -s extended-keys-format csi-u
  2. Launch Claude Code inside a tmux pane (any terminal emulator — Ghostty, iTerm2, kitty, etc.)
  3. Type some text, then rapidly press Escape followed by b (within ~40ms)
  4. Expected: Exits insert mode and moves cursor back one word
  5. Actual: esc + b is interpreted as Alt+b or the motion doesn't register because Escape hasn't been recognized yet
  6. Compare with running Claude Code directly in the terminal (no tmux) — the same rapid esc + b chain works flawlessly

Claude Model

Not sure / Multiple models

Is this a regression?

No, this never worked (probably? It's never worked for me)

Last Working Version

N/A

Claude Code Version

2.1.59

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Ghostty + tmux

Additional Information

Environment details:

  • macOS Darwin 25.2.0 (arm64)
  • Ghostty 1.2.3
  • tmux 3.6a
  • xterm-ghostty terminfo installed (ncurses 6.5+)

tmux config (relevant settings):

set -s escape-time 0
set -s default-terminal "xterm-ghostty"
set -s extended-keys always
set -s extended-keys-format csi-u

Why tmux can't send \033[27u for bare Escape:

  • tmux's input_key() in input-keys.c has a "trivial case" fast path that sends bare \033 for C0_ESC before reaching the extended key encoding logic
  • Even if that fast path were bypassed, input_key_extended() rejects unmodified keys — the modifier switch has default: return (-1), so keys without Shift/Ctrl/Meta are not CSI u encoded
  • tmux's tty_keys_extended_key() input parser requires %u;%u format (key + modifier), so bare \033[27u (no modifier) from the outer terminal would also fail to parse

Related issues: #6072, #16738 (auto-closed, never fixed), #27868

View original on GitHub ↗

This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗