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.
keybindings/defaultBindings.ts →
keybindings/parser.ts →
keybindings/match.ts →
keybindings/resolver.ts →
keybindings/useKeybinding.ts →
keybindings/KeybindingContext.tsx →
keybindings/loadUserBindings.ts →
keybindings/validate.ts →
keybindings/reservedShortcuts.ts →
ink/parse-keypress.ts
The pipeline has five conceptual stages:
Terminal Decode
parse-keypress.ts — escape sequences, CSI u, kitty protocol, SGR mouse → ParsedKey
Binding Config
defaultBindings.ts + loadUserBindings.ts — context blocks merged, parsed to ParsedBinding[]
Key Matching
match.ts — normalise modifiers, map Ink flags to ParsedKeystroke for comparison
Chord Resolution
resolver.ts — single keys and multi-step chords, context priority, last-wins override
React Dispatch
useKeybinding.ts + KeybindingContext.tsx — hooks consume resolved actions, stop propagation
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
\x1b[1;5D = Ctrl+Left.
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.
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.
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
| Context | When active |
|---|---|
| Global | Always active |
| Chat | Chat input focused |
| Autocomplete | Autocomplete menu visible |
| Confirmation | Permission / confirm dialog shown |
| Help | Help overlay open |
| Transcript | Viewing the transcript |
| HistorySearch | Ctrl+R history search |
| Task | Agent/bash task running in foreground |
| ThemePicker | Theme picker open |
| Settings | Settings menu open |
| Tabs | Tab navigation active |
| Attachments | Image attachment select dialog |
| Footer | Footer indicator focused |
| MessageSelector | Rewind message selector open |
| DiffDialog | Diff dialog open |
| ModelPicker | Model picker open |
| Select | Select / list component focused |
| Plugin | Plugin dialog open |
Selected default bindings — Chat context
| Key | Action | Note |
|---|---|---|
| enter | chat:submit | Send message |
| escape | chat:cancel | Cancel streaming |
| shift+tab | chat:cycleMode | Windows fallback: meta+m |
| ctrl+x ctrl+k | chat:killAgents | Chord — avoids shadowing readline |
| ctrl+x ctrl+e | chat:externalEditor | Readline-native edit binding |
| ctrl+_ | chat:undo | Legacy terminal ctrl+shift+- |
| ctrl+shift+- | chat:undo | Kitty protocol version |
| ctrl+s | chat:stash | Stash current draft |
| ctrl+v / alt+v | chat:imagePaste | Platform-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('KAIROS'), feature('QUICK_SEARCH'),
feature('VOICE_MODE'), etc.). If a flag is off, the binding simply
does not appear in DEFAULT_BINDINGS at all.
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, … }]
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)
}
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 type | Meaning |
|---|---|
| match | A binding fired. action field carries the action ID. |
| none | No binding matched. Let the event propagate. |
| unbound | Key is explicitly null-unbound. Swallow the event. |
| chord_started | This keystroke begins a chord. Store pending array and wait. |
| chord_cancelled | Chord 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 }
}
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.
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
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>
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
| Type | Example trigger |
|---|---|
| parse_error | Missing context field, empty + in key string |
| duplicate | Same key listed twice in one context block |
| reserved | Trying to bind ctrl+c or ctrl+z |
| invalid_context | Unknown context name like "Input" |
| invalid_action | Malformed 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:
ctrl+c, ctrl+d, ctrl+m
Hardcoded in Claude Code. ctrl+m is identical to Enter in all terminals (both send CR).
ctrl+z, ctrl+\
Unix SIGTSTP and SIGQUIT — intercepted by the kernel before the process sees them.
cmd+c/v/x/q/w/tab/space
OS-level shortcuts intercepted by macOS before the terminal app. Only shown on macOS.
ctrl+s for the stash feature and the comment in reservedShortcuts.ts
explicitly documents the decision to leave it out of the reserved list.
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:
- Starts from
DEFAULT_BINDINGS - Filters out
NON_REBINDABLEkeys so the template would pass/doctor - Wraps the result in the
{ "$schema", "$docs", "bindings": [] }envelope
Key Takeaways
- The keybindings stack has five distinct layers: terminal decode, config parsing, key matching, chord resolution, and React dispatch.
parse-keypress.tshandles 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_KEYandMODE_CYCLE_KEY— to work around Windows Terminal VT mode limitations. - Chord sequences like
ctrl+x ctrl+kare 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;
nullas a value explicitly unbinds a key. chokidarwatcheskeybindings.jsonwith 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
meta modifier specifically when the key being matched is escape?keybindings.json file?ctrl+s to null in their keybindings.json. What happens when they press Ctrl+S?