markdown.engineering
Lesson 33

The Keybindings System

From raw terminal bytes to typed actions — how Claude Code turns keystrokes into commands, supports chord sequences, loads user overrides, and guards reserved shortcuts.

01 Overview

Every key you press in Claude Code travels through a layered pipeline before anything happens on screen. Raw terminal bytes arrive as escape sequences, get decoded into ParsedKey objects, matched against a context-sensitive binding table, and finally dispatched to a React handler. The entire system is configurable: users can override default bindings, add chord sequences, bind slash commands, or null-unbind unwanted keys — all through a single ~/.claude/keybindings.json file.

Source files covered
keybindings/defaultBindings.tskeybindings/parser.tskeybindings/match.tskeybindings/resolver.tskeybindings/useKeybinding.tskeybindings/KeybindingContext.tsxkeybindings/loadUserBindings.tskeybindings/validate.tskeybindings/reservedShortcuts.tsink/parse-keypress.ts

The pipeline has five conceptual stages:

Stage 1

Terminal Decode

parse-keypress.ts — escape sequences, CSI u, kitty protocol, SGR mouse → ParsedKey

Stage 2

Binding Config

defaultBindings.ts + loadUserBindings.ts — context blocks merged, parsed to ParsedBinding[]

Stage 3

Key Matching

match.ts — normalise modifiers, map Ink flags to ParsedKeystroke for comparison

Stage 4

Chord Resolution

resolver.ts — single keys and multi-step chords, context priority, last-wins override

Stage 5

React Dispatch

useKeybinding.ts + KeybindingContext.tsx — hooks consume resolved actions, stop propagation

02 Stage 1 — Terminal Byte Decode

Terminals speak in escape sequences, not key names. When you press Ctrl+↑, the pty writes \x1b[1;5A into the process stdin. The job of ink/parse-keypress.ts is to turn that byte soup into a structured ParsedKey.

The ParsedKey type

export type ParsedKey = {
  kind: 'key'
  fn:   boolean         // function key (F1–F12)
  name: string | undefined  // 'enter', 'escape', 'up', 'a', …
  ctrl: boolean
  meta: boolean         // Alt/Option in legacy terminals
  shift: boolean
  option: boolean       // iTerm "option as meta" quirk
  super: boolean        // Cmd/Win — only via kitty protocol
  sequence: string | undefined
  raw:  string | undefined
  isPasted: boolean
}

Three keyboard protocols handled

Legacy VT sequences
The original xterm escape vocabulary. Arrow keys, function keys, page up/down. Modifier information is encoded as a numeric parameter inside the sequence. Example: \x1b[1;5D = Ctrl+Left.
CSI u — Kitty keyboard protocol
ESC [ codepoint ; modifier u. Carries the Unicode codepoint and a bitmask modifier. Enables previously-impossible combos like Shift+Enter, Ctrl+Space, and the super (Cmd/Win) modifier. Supported by kitty, WezTerm, ghostty, iTerm2.
xterm modifyOtherKeys
ESC [ 27 ; modifier ; keycode ~. Used by Ghostty/tmux/xterm when modifyOtherKeys=2 is active. The source contains an explicit comment explaining it must run before FN_KEY_RE to avoid partial matches.

Modifier bitmask decoding

All three modern protocols share the same XTerm modifier encoding. The decodeModifier function in parse-keypress.ts unpacks it:

// modifier = 1 + (shift?1:0) + (alt?2:0) + (ctrl?4:0) + (super?8:0)
function decodeModifier(modifier: number) {
  const m = modifier - 1
  return {
    shift: !!(m & 1),
    meta:  !!(m & 2),
    ctrl:  !!(m & 4),
    super: !!(m & 8),
  }
}

SGR mouse events

Mouse clicks and drags are parsed into a separate ParsedMouse type and never reach the keybinding system. Wheel events (bit 0x40 in the button code) are deliberately kept as ParsedKey so scroll bindings (scroll:pageUp, scroll:lineUp, etc.) work through the normal resolver path.

Paste bracketing

The parser tracks PASTE_START / PASTE_END CSI sequences. Text inside brackets is collected in a buffer and emitted as a single isPasted: true key. Even empty pastes emit a key so downstream handlers can detect clipboard image paste attempts on macOS.

03 Stage 2 — Default Binding Config

keybindings/defaultBindings.ts exports a single constant, DEFAULT_BINDINGS, which is an array of KeybindingBlock objects. Each block groups bindings by UI context.

The KeybindingBlock structure

type KeybindingBlock = {
  context: KeybindingContextName   // 'Global' | 'Chat' | 'Autocomplete' | …
  bindings: Record<string, string | null>  // key string → action ID (null = unbind)
}

All 18 keybinding contexts

ContextWhen active
GlobalAlways active
ChatChat input focused
AutocompleteAutocomplete menu visible
ConfirmationPermission / confirm dialog shown
HelpHelp overlay open
TranscriptViewing the transcript
HistorySearchCtrl+R history search
TaskAgent/bash task running in foreground
ThemePickerTheme picker open
SettingsSettings menu open
TabsTab navigation active
AttachmentsImage attachment select dialog
FooterFooter indicator focused
MessageSelectorRewind message selector open
DiffDialogDiff dialog open
ModelPickerModel picker open
SelectSelect / list component focused
PluginPlugin dialog open

Selected default bindings — Chat context

KeyActionNote
enterchat:submitSend message
escapechat:cancelCancel streaming
shift+tabchat:cycleModeWindows fallback: meta+m
ctrl+x ctrl+kchat:killAgentsChord — avoids shadowing readline
ctrl+x ctrl+echat:externalEditorReadline-native edit binding
ctrl+_chat:undoLegacy terminal ctrl+shift+-
ctrl+shift+-chat:undoKitty protocol version
ctrl+schat:stashStash current draft
ctrl+v / alt+vchat:imagePastePlatform-specific (Windows uses alt+v)

Platform-aware dynamic keys

The defaults file does not hardcode a single binding for every action. Two keys are computed at module load time based on the runtime platform and runtime version:

// Windows uses alt+v because ctrl+v is system paste
const IMAGE_PASTE_KEY = getPlatform() === 'windows' ? 'alt+v' : 'ctrl+v'

// shift+tab doesn't work on old Windows Terminal without VT mode
// Node enabled VT mode in 24.2.0 / 22.17.0; Bun in 1.2.23
const SUPPORTS_TERMINAL_VT_MODE =
  getPlatform() !== 'windows' ||
  (isRunningWithBun()
    ? satisfies(process.versions.bun, '>=1.2.23')
    : satisfies(process.versions.node, '>=22.17.0 <23.0.0 || >=24.2.0'))

const MODE_CYCLE_KEY = SUPPORTS_TERMINAL_VT_MODE ? 'shift+tab' : 'meta+m'
Feature flags in bindings
Several bindings are conditioned on GrowthBook feature flags evaluated at startup (feature('KAIROS'), feature('QUICK_SEARCH'), feature('VOICE_MODE'), etc.). If a flag is off, the binding simply does not appear in DEFAULT_BINDINGS at all.
04 Stage 2b — Parsing Key Strings

The string "ctrl+shift+k" from a JSON config block is not directly comparable to a ParsedKey. parser.ts converts it into a structured ParsedKeystroke (and an array of them for chords).

ParsedKeystroke

type ParsedKeystroke = {
  key:   string    // canonical name: 'escape', 'enter', 'up', 'k', …
  ctrl:  boolean
  alt:   boolean
  shift: boolean
  meta:  boolean   // alias for alt in legacy terminals
  super: boolean   // cmd / win
}

Modifier aliases

parseKeystroke() normalises a generous set of modifier names so users do not need to remember exact spellings:

// All of these are equivalent:
"ctrl+k"    // ctrl
"control+k" // also ctrl
"alt+k"     // alt
"opt+k"     // also alt
"option+k"  // also alt
"cmd+k"     // super
"command+k" // also super
"win+k"     // also super

// Special key aliases:
"esc"    → key: 'escape'
"return" → key: 'enter'
"space"  → key: ' '
"↑"      → key: 'up'   // unicode arrow symbols work

Chord parsing

A chord is two or more keystrokes typed in sequence, separated by a space in the config string. parseChord() splits on whitespace and maps each part through parseKeystroke(). One edge-case handled explicitly: the lone string " " (a single space character) is the space key, not an empty chord.

parseChord("ctrl+x ctrl+k")
// → [{ key:'x', ctrl:true }, { key:'k', ctrl:true }]

parseChord(" ")  // lone space = space key, not empty
// → [{ key:' ', ctrl:false, … }]
05 Stage 3 — Key Matching

match.ts bridges Ink's Key type (boolean flags like key.upArrow, key.escape) to the ParsedKeystroke format used by the binding table.

The alt/meta unification quirk

In legacy terminal encoding, the Alt/Option key is indistinguishable from the Meta modifier — both set key.meta = true in Ink. The codebase acknowledges this with an explicit comment in match.ts:

// Alt and meta both map to key.meta in Ink (terminal limitation)
// So we check if EITHER alt OR meta is required in target
const targetNeedsMeta = target.alt || target.meta
if (inkMods.meta !== targetNeedsMeta) return false

The escape/meta quirk

Pressing Escape sends \x1b. Pressing Alt+letter sends \x1b followed by the letter. Ink therefore sets key.meta = true on all escape events — a legacy artefact. Without special handling, a plain "escape" binding would never match because the resolver would also require the meta modifier. The fix is one line:

if (key.escape) {
  return modifiersMatch({ ...inkMods, meta: false }, target)
}
06 Stage 4 — Chord Resolution

resolver.ts is the brain of the system. Given an Ink keypress, the active contexts, all parsed bindings, and optional pending chord state, it returns one of five outcomes:

Result typeMeaning
matchA binding fired. action field carries the action ID.
noneNo binding matched. Let the event propagate.
unboundKey is explicitly null-unbound. Swallow the event.
chord_startedThis keystroke begins a chord. Store pending array and wait.
chord_cancelledChord aborted (Escape or dead end). Clear pending state.

Last-wins override model

Bindings are searched linearly. The last matching binding wins. Because user bindings are appended after defaults ([...defaultBindings, ...userParsed]), user entries naturally override the defaults for the same key+context pair.

Chord prefix detection

Before declaring a single-key match, the resolver checks whether any longer chord in the active context uses this keystroke as a prefix. If so, it enters chord_started state instead of firing the single-key binding — even if one exists. Only when no longer chord is possible does it fall back to exact matching.

// chordWinners maps chord strings to their action (null = unbound override)
// This ensures null-unbinding a chord doesn't leave the prefix in chord-wait
const chordWinners = new Map<string, string | null>()
for (const binding of contextBindings) {
  if (binding.chord.length > testChord.length &&
      chordPrefixMatches(testChord, binding)) {
    chordWinners.set(chordToString(binding.chord), binding.action)
  }
}
// Only enter chord-wait if at least one live (non-null) longer chord exists
let hasLongerChords = false
for (const action of chordWinners.values()) {
  if (action !== null) { hasLongerChords = true; break }
}
flowchart TD A["Key event arrives"] --> B["Build testChord\n(pending + currentKeystroke)"] B --> C{"Escape key\n+ pending?"} C -->|"Yes"| CANCEL["chord_cancelled"] C -->|"No"| D{"Any live longer\nchord prefix?"} D -->|"Yes"| WAIT["chord_started\n(store pending)"] D -->|"No"| E{"Exact chord\nmatch?"} E -->|"action = null"| UNBOUND["unbound\n(swallow event)"] E -->|"action = string"| MATCH["match\n(fire action)"] E -->|"No match"| F{"Was in chord?"} F -->|"Yes"| CANCEL2["chord_cancelled"] F -->|"No"| NONE["none\n(propagate)"]
07 Stage 5 — React Hooks & Context

Components register interest in actions through two hooks: useKeybinding for a single action, and useKeybindings for a map of actions. Both share the same resolution path.

useKeybinding

// Usage in a component:
useKeybinding('app:toggleTodos', () => {
  setShowTodos(prev => !prev)
}, { context: 'Global' })

// useKeybindings handles multiple at once:
useKeybindings({
  'chat:submit': () => handleSubmit(),
  'chat:cancel': () => handleCancel(),
}, { context: 'Chat' })

The false return convention

A handler can return false to signal "not consumed — let the event propagate further." This is used by ScrollKeybindingHandler: when the content fits entirely on screen, scrolling is a no-op, so the scroll handler returns false and child components can use the same wheel event for list navigation instead.

KeybindingContext

The React context object (KeybindingContext.tsx) is the shared bus. It holds:

type KeybindingContextValue = {
  resolve: (input, key, activeContexts) => ChordResolveResult
  setPendingChord: (pending | null) => void
  getDisplayText: (action, context) => string | undefined
  bindings:        ParsedBinding[]
  pendingChord:    ParsedKeystroke[] | null
  activeContexts:  Set<KeybindingContextName>
  registerActiveContext:   (context) => void
  unregisterActiveContext: (context) => void
  registerHandler:  (registration) => () => void  // returns cleanup fn
  invokeAction:     (action) => boolean
}

Components call registerActiveContext on mount and unregisterActiveContext on unmount, so the resolver always knows exactly which contexts are live. The invokeAction method is used by the ChordInterceptor to fire registered handlers after a chord completes without going through a second useInput call.

08 User Config & Hot-Reload

User customisation lives in ~/.claude/keybindings.json. loadUserBindings.ts handles loading, caching, and live reloading via chokidar.

File format

{
  "$schema": "https://www.schemastore.org/claude-code-keybindings.json",
  "$docs":   "https://code.claude.com/docs/en/keybindings",
  "bindings": [
    {
      "context":  "Chat",
      "bindings": {
        "ctrl+y":       "chat:submit",      // remap submit
        "enter":        null,               // unbind enter from submit
        "ctrl+shift+p": "command:compact"   // bind to slash command
      }
    }
  ]
}

Merge strategy: last-wins

User bindings are concatenated after the defaults array. The resolver scans bindings linearly and always accepts the last match — so user entries naturally supersede defaults for the same key and context.

Hot-reload pipeline

sequenceDiagram participant FS as File System participant CW as chokidar watcher participant LB as loadUserBindings participant Cache as Binding cache participant Sig as keybindingsChanged signal participant UI as React UI FS->>CW: File changed (add/change) Note over CW: awaitWriteFinish 500ms stability CW->>LB: handleChange(path) LB->>LB: loadKeybindings() async LB->>Cache: update cachedBindings + warnings LB->>Sig: emit(result) Sig->>UI: subscribeToKeybindingChanges listener UI->>UI: Re-render with new bindings FS->>CW: File deleted CW->>LB: handleDelete(path) LB->>Cache: reset to DEFAULT_BINDINGS LB->>Sig: emit(defaults)
Feature gate
The isKeybindingCustomizationEnabled() function checks the GrowthBook flag tengu_keybinding_customization_release. When false, user config loading and file watching are skipped entirely — only the default bindings are used. At time of writing this is being rolled out progressively.

Sync vs async loading

Two variants exist because React's useState initializer runs synchronously during the first render:

// Called from React useState initializer — must be sync
loadKeybindingsSync(): ParsedBinding[]

// Called by the chokidar watcher on file changes — async is fine
loadKeybindings(): Promise<KeybindingsLoadResult>
09 Validation & Reserved Shortcuts

validate.ts runs multiple passes over user config and produces typed KeybindingWarning objects with severity ('error' or 'warning') and optional suggestion text.

Five warning types

TypeExample trigger
parse_errorMissing context field, empty + in key string
duplicateSame key listed twice in one context block
reservedTrying to bind ctrl+c or ctrl+z
invalid_contextUnknown context name like "Input"
invalid_actionMalformed command: string or wrong context for command binding

Duplicate key detection in raw JSON

JSON.parse silently uses the last value when a key appears twice. checkDuplicateKeysInJson() parses the raw string with regex to catch this before the duplicate is silently lost:

// Regex locates each "bindings": { ... } block, then finds all "key": pairs
// Only warns on the second occurrence — first is already silently overwritten
const bindingsBlockPattern =
  /"bindings"\s*:\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g

Reserved shortcuts

reservedShortcuts.ts lists three categories of protected keys:

Non-rebindable (error)

ctrl+c, ctrl+d, ctrl+m

Hardcoded in Claude Code. ctrl+m is identical to Enter in all terminals (both send CR).

Terminal reserved (warn/error)

ctrl+z, ctrl+\

Unix SIGTSTP and SIGQUIT — intercepted by the kernel before the process sees them.

macOS only (error)

cmd+c/v/x/q/w/tab/space

OS-level shortcuts intercepted by macOS before the terminal app. Only shown on macOS.

ctrl+s is intentionally NOT reserved
Flow control (XON/XOFF) is disabled on most modern terminals. Claude Code uses ctrl+s for the stash feature and the comment in reservedShortcuts.ts explicitly documents the decision to leave it out of the reserved list.
10 Display Formatting & Template Generation

Platform-aware display strings

The same binding is shown differently depending on platform. parser.ts exports keystrokeToDisplayString():

// macOS: shows "opt" for alt modifier
keystrokeToDisplayString(ks, 'macos')  // → "opt+k"
// Linux/Windows: shows "alt"
keystrokeToDisplayString(ks, 'linux')  // → "alt+k"

// getBindingDisplayText() is used by KeybindingContext for the help UI
getBindingDisplayText('chat:submit', 'Chat', bindings)
// → "Enter"  (searches in reverse so user overrides show instead of defaults)

Template generator

Running /keybindings in Claude Code creates a starter ~/.claude/keybindings.json by calling generateKeybindingsTemplate() from template.ts. It:

  1. Starts from DEFAULT_BINDINGS
  2. Filters out NON_REBINDABLE keys so the template would pass /doctor
  3. Wraps the result in the { "$schema", "$docs", "bindings": [] } envelope
11 Full Pipeline Diagram
flowchart LR subgraph Terminal["Terminal (pty)"] B1["Raw bytes\n\\x1b[13;2u"] end subgraph Parse["parse-keypress.ts"] B2["CSI-u / VT / SGR\ndecodeModifier()"] B3["ParsedKey\n{name:'enter', shift:true}"] B2 --> B3 end subgraph Config["Binding Config"] C1["defaultBindings.ts\nDEFAULT_BINDINGS"] C2["loadUserBindings.ts\n~/.claude/keybindings.json"] C3["parseBindings()\nParsedBinding[]"] C1 --> C3 C2 --> C3 end subgraph Match["match.ts"] M1["getKeyName()\nnormalise Ink flags"] M2["modifiersMatch()\nalt/meta unification"] end subgraph Resolve["resolver.ts"] R1["resolveKeyWithChordState()"] R2["chord prefix check\nchordWinners map"] R3["exact match\nlast-wins scan"] R1 --> R2 --> R3 end subgraph React["React (useKeybinding.ts)"] H1["ChordResolveResult"] H2["handler()"] H3["stopImmediatePropagation()"] H1 --> H2 --> H3 end Terminal --> Parse Parse --> Match Config --> Resolve Match --> Resolve Resolve --> React

Key Takeaways

  • The keybindings stack has five distinct layers: terminal decode, config parsing, key matching, chord resolution, and React dispatch.
  • parse-keypress.ts handles three terminal keyboard protocols: legacy VT sequences, CSI u (kitty), and xterm modifyOtherKeys — each requires different parsing logic.
  • Default bindings are split into context blocks (Global, Chat, Autocomplete, etc.) so the same key can mean different things in different UI states.
  • Two bindings are computed at runtime — IMAGE_PASTE_KEY and MODE_CYCLE_KEY — to work around Windows Terminal VT mode limitations.
  • Chord sequences like ctrl+x ctrl+k are first-class: the resolver builds a pending state, and only fires when no longer chord is possible (last-wins + null-override respected).
  • User overrides use a simple append-and-last-wins strategy; null as a value explicitly unbinds a key.
  • chokidar watches keybindings.json with a 500ms write-stabilise delay and replaces the binding cache on every save.
  • Three categories of protected keys: non-rebindable (hardcoded), terminal-reserved (SIGTSTP/SIGQUIT), and macOS OS-level shortcuts — each flagged with appropriate severity.

Check Your Understanding

1. What does the resolver return when a key press is the first step of a multi-key chord that has live longer chords available?
2. Why does the key matcher ignore the meta modifier specifically when the key being matched is escape?
3. What is the correct format for a user keybindings.json file?
4. A user binds ctrl+s to null in their keybindings.json. What happens when they press Ctrl+S?
5. Which key is deliberately NOT in the terminal-reserved list, and why?