Code Reader
首页
帮助
设计文档
首页
帮助
设计文档
  • 输入处理系统深度分析

输入处理系统深度分析

覆盖 src/keybindings/(14 文件,~3159 行)和 src/vim/(5 文件,~1513 行)

架构总览

Claude Code 的输入处理由两个完全独立但协同工作的子系统组成:

┌─────────────────────────────────────────────────────────────┐
│                     Ink 终端输入流                            │
│              (stdin → input-event.ts → useInput)             │
├────────────────────────┬────────────────────────────────────┤
│   Keybinding System    │         Vim Mode                   │
│   18 上下文 + 和弦支持   │   可切换的 NORMAL/INSERT 模式       │
│   配置驱动 (JSON)       │   纯函数状态机                     │
│   热重载 (chokidar)    │   Cursor 对象操作                  │
├────────────────────────┴────────────────────────────────────┤
│              React 组件层 (useKeybinding / useInput)         │
└─────────────────────────────────────────────────────────────┘

关键设计决策: 快捷键系统是配置驱动的(~/.claude/keybindings.json),而 Vim 模式是硬编码的状态机。两者通过 Ink 的 useInput 钩子接入同一个事件流,但 Vim 模式只在聊天输入聚焦时激活,快捷键系统始终工作。


第一部分:快捷键系统 (Keybinding System)

1. 文件清单(14 个文件)

文件行数职责
resolver.ts244纯函数键解析,支持和弦前缀匹配
parser.ts203按键字符串解析,修饰键别名标准化
match.ts120Ink Key 对象到 ParsedKeystroke 的匹配
loadUserBindings.ts472用户配置加载 + chokidar 热重载
reservedShortcuts.ts127不可重绑定的保留键(ctrl+c/d/m)
useKeybinding.ts196React Hook:单个/多个动作绑定
KeybindingContext.tsx243React Context Provider + handler 注册
KeybindingProviderSetup.tsx308应用级 Provider 组装 + 和弦拦截器
schema.ts236Zod schema 定义 + JSON Schema 生成
defaultBindings.ts340默认绑定配置(所有上下文)
validate.ts498绑定验证(重复/保留/格式)
template.ts52模板生成(/keybindings 命令)
shortcutFormat.ts63非 React 环境的显示文本获取
useShortcutDisplay.ts59React Hook 版显示文本获取

2. 类型系统(隐式 types.ts,由 schema.ts 推导)

从代码引用推断,types.ts 中定义的核心类型为:

// 18 个命名上下文
type KeybindingContextName =
  | 'Global' | 'Chat' | 'Autocomplete' | 'Confirmation'
  | 'Help' | 'Transcript' | 'HistorySearch' | 'Task'
  | 'ThemePicker' | 'Settings' | 'Tabs' | 'Attachments'
  | 'Footer' | 'MessageSelector' | 'DiffDialog' | 'ModelPicker'
  | 'Select' | 'Plugin'

// 单次按键的解析结果
type ParsedKeystroke = {
  key: string
  ctrl: boolean
  alt: boolean
  shift: boolean
  meta: boolean
  super: boolean
}

// 和弦 = 多个按键的序列(如 "ctrl+k ctrl+s")
type Chord = ParsedKeystroke[]

// 解析后的绑定
type ParsedBinding = {
  chord: Chord
  action: string | null  // null = 解除绑定
  context: KeybindingContextName
}

// JSON 配置块
type KeybindingBlock = {
  context: KeybindingContextName
  bindings: Record<string, string | null>
}

3. 上下文优先级系统

18 个上下文在 schema.ts:12-32 定义,每个有明确的语义描述(schema.ts:37-59):

Global         → 全局生效,无论焦点
Chat           → 聊天输入框聚焦时
Autocomplete   → 自动补全菜单可见时
Confirmation   → 确认/权限对话框显示时
Help           → 帮助覆盖层打开时
Transcript     → 查看对话记录时
HistorySearch  → 搜索命令历史时(ctrl+r)
Task           → 前台任务运行时
ThemePicker    → 主题选择器打开时
Settings       → 设置菜单打开时
Tabs           → Tab 导航激活时
Attachments    → 图片附件选择时
Footer         → 底部指示器聚焦时
MessageSelector→ 消息选择器(回滚)打开时
DiffDialog     → Diff 对话框打开时
ModelPicker    → 模型选择器打开时
Select         → 选择/列表组件聚焦时
Plugin         → 插件对话框打开时

优先级机制(useKeybinding.ts:54-60):

const contextsToCheck: KeybindingContextName[] = [
  ...keybindingContext.activeContexts,  // 动态注册的上下文(最高优先)
  context,                               // 组件声明的上下文
  'Global',                              // 全局兜底
]
const uniqueContexts = [...new Set(contextsToCheck)]  // 去重保留首次出现

组件通过 useRegisterKeybindingContext('ThemePicker') 在 mount 时注册上下文。activeContexts 使用 useRef 而非 useState,确保输入处理器能同步看到最新值,无需等待 React 渲染周期。

匹配规则(resolver.ts:38-50):遍历所有绑定,最后一个匹配者获胜(last-one-wins),这样用户绑定(在默认绑定之后追加)自然覆盖默认值。

4. 和弦状态机 (Chord State Machine)

                    ┌──────────────┐
         初始状态    │   无待定键    │
                    └──────┬───────┘
                           │
              按下 "ctrl+k" │ (有更长的和弦前缀匹配)
                           ▼
                    ┌──────────────┐
                    │ chord_started│ ← pending = [ctrl+k]
                    │   等待第二键  │    启动 1s 超时
                    └──────┬───────┘
                           │
            ┌──────────────┼──────────────┐
            │              │              │
     按下 "ctrl+s"   按下 Escape    按下其他键 / 超时
            │              │              │
            ▼              ▼              ▼
     ┌──────────┐  ┌────────────┐  ┌──────────────┐
     │  match   │  │ chord_     │  │ chord_       │
     │ action=X │  │ cancelled  │  │ cancelled    │
     └──────────┘  └────────────┘  └──────────────┘

核心逻辑(resolver.ts:166-244):

  1. Escape 取消:如果 Escape 按下且有待定和弦,返回 chord_cancelled
  2. 构建当前按键:buildKeystroke() 从 Ink 的 input + Key 构造 ParsedKeystroke
  3. 前缀检测:检查当前按键序列是否是任何绑定的前缀。关键细节:只统计 action !== null 的前缀匹配(chordWinners Map),null 覆盖不会触发和弦等待
  4. 精确匹配:如果无更长的和弦,检查精确匹配
  5. 和弦取消:如果在和弦中但无匹配,返回 chord_cancelled

和弦超时(KeybindingProviderSetup.tsx:30):1000ms,通过 setTimeout 实现。超时时同时清除 ref(用于同步访问)和 state(用于触发重渲染)。

Escape 与 meta 的 quirk(resolver.ts:87-89,match.ts:96-102):

// Ink 在按下 Escape 时设置 key.meta=true(终端遗留行为)
// 必须忽略这个 meta,否则 "escape" 绑定永远不会匹配
const effectiveMeta = key.escape ? false : key.meta

5. 按键解析器(Parser)

修饰键别名标准化(parser.ts:25-46):

用户输入标准化为
ctrl / controlctrl
alt / opt / optionalt
shiftshift
metameta
cmd / command / super / winsuper

特殊按键名(parser.ts:47-67):

用户输入内部名
escescape
returnenter
space
↑ ↓ ← →up down left right

和弦解析(parser.ts:80-84):空格分隔的按键序列。特例:单独的 " " 字符解析为 space 键。

"ctrl+k ctrl+s"  →  [ParsedKeystroke{ctrl:true,key:'k'}, ParsedKeystroke{ctrl:true,key:'s'}]
" "              →  [ParsedKeystroke{key:' '}]  // space

平台感知显示(parser.ts:157-176):

// macOS:  "opt+k", "cmd+k"
// 其他:   "alt+k", "super+k"

6. Ink 匹配层(match.ts)

按键名提取(match.ts:29-47):将 Ink 的布尔标志(key.escape、key.return 等)映射为字符串名。优先级顺序确保特殊键不会被错误地当作普通字符。

修饰键匹配(match.ts:60-79):

// alt 和 meta 在终端中不可区分 → 合并检查
const targetNeedsMeta = target.alt || target.meta
if (inkMods.meta !== targetNeedsMeta) return false

// super (cmd/win) 是独立的 → 仅 kitty 键盘协议支持
if (inkMods.super !== target.super) return false

7. 热重载机制(loadUserBindings.ts)

加载路径:~/.claude/keybindings.json

配置格式:

{
  "$schema": "https://www.schemastore.org/claude-code-keybindings.json",
  "bindings": [
    {
      "context": "Chat",
      "bindings": {
        "ctrl+k": "chat:cancel",
        "ctrl+shift+p": null  // 解除绑定
      }
    }
  ]
}

热重载流程:

~/.claude/keybindings.json 修改
         │
    chokidar 检测 (稳定性阈值 500ms, 轮询间隔 200ms)
         │
    handleChange() 被调用
         │
    loadKeybindings() 异步重新加载
         │
    更新 cachedBindings + cachedWarnings
         │
    keybindingsChanged.emit(result)
         │
    subscribeToKeybindingChanges 回调触发
         │
    KeybindingSetup.setState({ bindings, warnings })
         │
    通知用户 (如果验证有警告)

特性开关:受 tengu_keybinding_customization_release GrowthBook 特性门控,当前仅 Anthropic 内部员工可用。外部用户始终使用默认绑定。

加载策略:

  • loadKeybindingsSync():同步加载,用于初始 React 渲染(useState 初始化器)
  • loadKeybindings():异步加载,用于热重载
  • 默认绑定先加载,用户绑定追加在后 → "last wins" 覆盖机制

8. 保留快捷键(reservedShortcuts.ts)

不可重绑定(NON_REBINDABLE,severity: error):

快捷键原因
ctrl+c中断/退出(硬编码)
ctrl+d退出(硬编码)
ctrl+m终端中与 Enter 完全相同(都发送 CR)

终端保留(TERMINAL_RESERVED):

快捷键原因
ctrl+zUnix 进程挂起 (SIGTSTP)
ctrl+\\终端退出信号 (SIGQUIT)

macOS 保留(MACOS_RESERVED):cmd+c/v/x/q/w/tab/space

注意:ctrl+s (XOFF) 和 ctrl+q (XON) 未被保留,因为现代终端默认禁用流控。ctrl+s 被用于 stash 功能。

9. React Hook 集成

useKeybinding(useKeybinding.ts:33-97):单动作绑定

useKeybinding('app:toggleTodos', () => {
  setShowTodos(prev => !prev)
}, { context: 'Global' })

useKeybindings(useKeybinding.ts:113-196):多动作绑定(减少 useInput 调用)

useKeybindings({
  'chat:submit': () => handleSubmit(),
  'chat:cancel': () => handleCancel(),
}, { context: 'Chat' })

事件传播控制:

  • event.stopImmediatePropagation():匹配成功后阻止其他处理器
  • handler 返回 false:不消耗事件,允许传播(用于 fall-through 模式)

ChordInterceptor(KeybindingProviderSetup.tsx:226-307):

注册为第一个 useInput 处理器,在子组件之前拦截和弦序列的第二键。流程:

  1. 收集所有已注册 handler 的上下文
  2. 调用 resolveKeyWithChordState() 解析
  3. 如果是 chord_started,阻止传播 → 子组件看不到这个按键
  4. 如果是 match 且在和弦中,调用 handler 并阻止传播

10. 验证系统(validate.ts)

验证类型:

类型severity说明
parse_errorerrorJSON 格式错误
duplicatewarning同一上下文中重复键
reservederror/warning使用保留键
invalid_contexterror未知上下文名
invalid_actionwarning无效动作/命令格式

JSON 重复键检测(checkDuplicateKeysInJson):JSON.parse 静默丢弃重复键的前者值,所以直接用正则扫描原始 JSON 文本。

11. 默认绑定(defaultBindings.ts)

平台特定处理:

// Windows: alt+v(ctrl+v 是系统粘贴)
// 其他:    ctrl+v
const IMAGE_PASTE_KEY = getPlatform() === 'windows' ? 'alt+v' : 'ctrl+v'

// Windows 无 VT 模式: meta+m(shift+tab 不可靠)
// 其他: shift+tab
const MODE_CYCLE_KEY = SUPPORTS_TERMINAL_VT_MODE ? 'shift+tab' : 'meta+m'

VT 模式检测(defaultBindings.ts:21-25):

// Node 24.2.0+ / 22.17.0+ 或 Bun 1.2.23+ 支持 VT 模式
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'))

和弦绑定示例:

  • ctrl+x ctrl+k:终止代理(避免遮蔽 readline 编辑键)
  • ctrl+x ctrl+e:外部编辑器(readline 原生绑定)
  • ctrl+_ / ctrl+shift+-:撤销(兼容传统终端和 kitty 协议)

12. 与更广泛 UI 的交互

useInput (Ink)
    │
    ├── ChordInterceptor (最先注册,拦截和弦)
    │       │
    │       ├── resolveKeyWithChordState()
    │       │       │
    │       │       ├── chord_started → setPendingChord, stopPropagation
    │       │       ├── match (in chord) → invoke handler, stopPropagation
    │       │       ├── chord_cancelled → setPendingChord(null)
    │       │       ├── unbound → setPendingChord(null), stopPropagation
    │       │       └── none → (不处理,继续传播)
    │       │
    │       └── handlerRegistry (Map<action, Set<HandlerRegistration>>)
    │
    ├── useKeybinding('app:toggleTodos', handler)
    │       │
    │       ├── 注册 handler 到 registry
    │       ├── 构建上下文列表
    │       ├── 调用 resolve()
    │       └── 匹配则调用 handler + stopImmediatePropagation
    │
    └── 其他 useInput 处理器 (Vim 模式等)

第二部分:Vim 模式

1. 文件清单(5 个文件)

文件行数职责
types.ts199状态机类型定义 + 常量集合
transitions.ts490状态转换逻辑表
motions.ts82纯函数光标移动
operators.ts556操作符执行(删除/修改/复制)
textObjects.ts186文本对象边界查找

2. VimState 判别联合类型

type VimState =
  | { mode: 'INSERT'; insertedText: string }
  | { mode: 'NORMAL'; command: CommandState }

INSERT 模式:跟踪输入的文本(用于点重复 dot-repeat) NORMAL 模式:运行 CommandState 状态机

3. CommandState 11 变体

                    ┌──────────────────────────────┐
                    │         idle                  │
                    │    (等待第一个按键)            │
                    └───────┬──────────────────────┘
                            │
        ┌───────────────────┼───────────────────────┐
        │                   │                       │
   [1-9] 输入          [d/c/y]                [f/F/t/T]
        │                   │                       │
        ▼                   ▼                       ▼
 ┌─────────────┐   ┌──────────────┐   ┌─────────────────┐
 │   count     │   │  operator    │   │     find        │
 │ {digits:"3"}│   │ {op:'delete'}│   │ {find:'f',cnt:1}│
 └──────┬──────┘   └──────┬───────┘   └─────────────────┘
        │                  │
   非数字键            ┌────┼──────┬──────────┐
        │              │    │      │          │
        ▼         [motion] [i/a] [f/F/t/T]  [0-9]
   继续 normal   ┌──────┘   │      │          │
   输入处理      │    ┌─────┘      │          │
                 │    │    ┌───────┘          │
                 │    │    │    ┌─────────────┘
                 ▼    ▼    ▼    ▼
              执行  operator  operator  operator
              操作  TextObj   Find      Count

11 个状态(types.ts:59-75):

状态等待输入说明
idle任意键空闲状态
count数字累积数字前缀(如 3dd)
operator动作/文本对象已输入 d/c/y,等待动作
operatorCount数字操作符后的数字(如 d3w)
operatorFind字符操作符+查找(如 dt,)
operatorTextObj对象类型操作符+文本对象(如 diw)
find字符查找命令(f/F/t/T)
gj/k/g已输入 g,等待第二个键
operatorGj/k/g操作符+g(如 dgg)
replace字符替换命令(r)
indent>/<缩进命令(>>/<<)

最大计数上限(types.ts:182):MAX_VIM_COUNT = 10000

4. 持久状态(跨命令记忆)

type PersistentState = {
  lastChange: RecordedChange | null    // 点重复
  lastFind: { type: FindType; char: string } | null  // ;/, 重复查找
  register: string                     // 寄存器内容
  registerIsLinewise: boolean          // 是否行模式
}

5. 纯函数架构

所有运动和操作函数都是纯函数,通过 OperatorContext 接口与外部交互:

type OperatorContext = {
  cursor: Cursor                        // 当前光标(不可变)
  text: string                          // 文本内容
  setText: (text: string) => void       // 修改文本
  setOffset: (offset: number) => void   // 移动光标
  enterInsert: (offset: number) => void // 进入插入模式
  getRegister: () => string             // 读取寄存器
  setRegister: (content: string, linewise: boolean) => void
  getLastFind: () => { type: FindType; char: string } | null
  setLastFind: (type: FindType, char: string) => void
  recordChange: (change: RecordedChange) => void  // 记录点重复
}

运动函数(motions.ts)是完全的纯函数,接收 Cursor 返回新的 Cursor:

function resolveMotion(key: string, cursor: Cursor, count: number): Cursor

6. Grapheme 感知

Vim 模式使用 Intl.Segmenter 进行 grapheme 级别的字符处理,正确处理组合字符和 emoji:

// operators.ts:177-180 - x 命令按 grapheme 删除
let endCursor = ctx.cursor
for (let i = 0; i < count && !endCursor.isAtEnd(); i++) {
  endCursor = endCursor.right()  // Cursor.right() 按 grapheme 移动
}

// operators.ts:208 - r 命令替换
const graphemeLen = firstGrapheme(newText.slice(offset)).length || 1

// textObjects.ts:67-69 - 文本对象使用 grapheme 分段
for (const { segment, index } of getGraphemeSegmenter().segment(text)) {
  graphemes.push({ segment, index })
}

7. 点重复(Dot-Repeat)机制

RecordedChange 是一个判别联合,捕获每种操作的完整回放信息(types.ts:92-119):

type RecordedChange =
  | { type: 'insert'; text: string }
  | { type: 'operator'; op: Operator; motion: string; count: number }
  | { type: 'operatorTextObj'; op: Operator; objType: string; scope: TextObjScope; count: number }
  | { type: 'operatorFind'; op: Operator; find: FindType; char: string; count: number }
  | { type: 'replace'; char: string; count: number }
  | { type: 'x'; count: number }
  | { type: 'toggleCase'; count: number }
  | { type: 'indent'; dir: '>' | '<'; count: number }
  | { type: 'openLine'; direction: 'above' | 'below' }
  | { type: 'join'; count: number }

每个操作函数在执行完毕后调用 ctx.recordChange() 记录自身。按下 . 键时触发 ctx.onDotRepeat() 回放。

8. 寄存器系统

单个无名寄存器(PersistentState.register),带有行模式标志:

// 行模式内容以 '\n' 结尾 → paste 时插入新行
ctx.setRegister(content, true)   // dd, yy → 行模式
ctx.setRegister(deleted, false)  // x, dw → 字符模式

Paste 时检测 register.endsWith('\n') 决定是否行模式粘贴(operators.ts:302-303)。

9. 操作符与动作的组合

操作符范围计算(operators.ts:429-475):

function getOperatorRange(cursor, target, motion, op, count): { from, to, linewise }
  • cw/cW 特殊处理:修改到单词末尾,不是下一个单词开头
  • 行模式动作(j/k/G/gg):扩展到整行(包含换行符)
  • 包含性动作(e/E/$):包含目标位置的字符
  • Image 芯片快照:cursor.snapOutOfImageRef() 确保操作不会留下部分 [Image #N] 占位符

10. 文本对象(textObjects.ts)

支持的文本对象:

类型内部 (i)围绕 (a)
w/W单词/WORD包含周围空格
" ' `引号内包含引号
( ) b括号内包含括号
[ ]方括号内包含方括号
{ } B花括号内包含花括号
< >尖括号内包含尖括号

括号匹配(findBracketObject):从光标位置向外搜索,使用深度计数器处理嵌套。只在当前行内查找引号对象(lineStart/lineEnd 限制)。

Grapheme 感知的单词对象(findWordObject):先将文本分段为 grapheme 数组,然后在 grapheme 索引上进行单词边界搜索,使用 isVimWordChar、isVimWhitespace、isVimPunctuation 分类。

11. 状态转换表(transitions.ts)

转换表是一组互相引用的函数,每个函数对应一个状态:

fromIdle        → count / operator / find / g / replace / indent / 直接执行
fromCount       → 继续累积数字 / 执行 normal 输入
fromOperator    → dd/cc/yy(行操作)/ motion / textObj / find / operatorCount
fromOperatorCount → 继续累积 / 执行带 count 的操作
fromOperatorFind  → 执行 find-based 操作
fromOperatorTextObj → 执行文本对象操作
fromFind        → 执行字符查找
fromG           → gj/gk/gg
fromOperatorG   → 操作符 + gj/gk/gg
fromReplace     → 执行替换
fromIndent      → 执行缩进

handleNormalInput 是共享逻辑,处理 idle 和 count 状态都接受的输入:操作符键、简单动作、查找键、g/r/>/<、~/x/J/p/P/D/C/Y/G/./;/,/u/i/I/a/A/o/O。

handleOperatorInput 处理操作符等待的输入:文本对象范围(i/a)、查找键(f/F/t/T)、简单动作、G、g。


系统交互

快捷键系统与 Vim 模式的关系

useInput 事件流
    │
    ├── ChordInterceptor (最快) ── 拦截和弦序列
    │
    ├── useKeybinding 处理器 ──── 解析上下文绑定
    │
    └── Vim 模式处理器 ────────── 仅在 NORMAL/INSERT 模式时处理
            │
            ├── INSERT: 字符直接插入(或记录到 insertedText)
            ├── NORMAL: transition() 状态机处理
            └── Escape: 在 NORMAL 模式中不被快捷键系统处理
                        (Chat 上下文的 escape → chat:cancel 仅在非 vim 时生效)

Vim 模式和快捷键系统通过上下文激活协同工作。Vim 模式切换时会动态注册/注销相应的上下文,确保 Escape 等键在不同模式下有正确的行为。

按键动作目录(schema.ts)

KEYBINDING_ACTIONS 定义了 ~70 个可绑定的动作(schema.ts:64-172),按命名空间分组:

  • app:* — 全局操作(中断、退出、面板切换)
  • chat:* — 聊天输入操作(提交、取消、撤销、粘贴)
  • history:* — 历史导航
  • autocomplete:* — 自动补全菜单
  • confirm:* — 确认对话框
  • tabs:* — Tab 导航
  • transcript:* — 记录查看器
  • historySearch:* — 历史搜索
  • task:* — 任务/代理
  • theme:* — 主题选择器
  • help:* — 帮助菜单
  • attachments:* — 图片附件
  • footer:* — 底部指示器
  • messageSelector:* — 消息选择器
  • diff:* — Diff 对话框
  • modelPicker:* — 模型选择器
  • select:* — 通用选择组件
  • plugin:* — 插件对话框
  • permission:* — 权限对话框
  • settings:* — 设置面板
  • voice:* — 语音输入

此外还支持 command:* 绑定(如 command:help),直接执行斜杠命令。


Mermaid 状态图

和弦解析状态机

Vim 模式状态机


关键代码路径总结

快捷键解析路径

用户按键 → Ink stdin → input-event.ts → useInput
  → ChordInterceptor.handleInput()
    → resolveKeyWithChordState(input, key, contexts, bindings, pending)
      → buildKeystroke(input, key)          // resolver.ts:82
      → chordPrefixMatches()                 // resolver.ts:123 (前缀检测)
      → chordExactlyMatches()                // resolver.ts:140 (精确匹配)
      → keystrokesEqual()                    // resolver.ts:107 (alt/meta 合并)
      → { type: 'chord_started'|'match'|... }
  → setPendingChord() / invoke handler / stopImmediatePropagation()

Vim 操作路径

用户按键 → useInput → vim handler
  → transition(state, input, ctx)            // transitions.ts:59
    → fromXxx(state, input, ctx)             // 各状态处理函数
      → resolveMotion(key, cursor, count)    // motions.ts:13
      → executeOperatorMotion(op, motion, count, ctx)  // operators.ts:42
        → getOperatorRange()                 // operators.ts:429
        → applyOperator()                    // operators.ts:493
          → ctx.setText() / ctx.setOffset() / ctx.setRegister()
        → ctx.recordChange()                 // dot-repeat 记录

用户绑定加载路径

启动 → KeybindingSetup.useState(initializer)
  → loadKeybindingsSyncWithWarnings()        // loadUserBindings.ts:259
    → readFileSync(~/.claude/keybindings.json)
    → parseBindings(blocks)                  // parser.ts:191
    → [...defaultBindings, ...userParsed]    // last wins
    → validateBindings()                     // validate.ts:425
  → initializeKeybindingWatcher()            // chokidar
  → subscribeToKeybindingChanges(callback)
    → file change → loadKeybindings() → emit → setState()