输入处理系统深度分析
覆盖
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.ts | 244 | 纯函数键解析,支持和弦前缀匹配 |
parser.ts | 203 | 按键字符串解析,修饰键别名标准化 |
match.ts | 120 | Ink Key 对象到 ParsedKeystroke 的匹配 |
loadUserBindings.ts | 472 | 用户配置加载 + chokidar 热重载 |
reservedShortcuts.ts | 127 | 不可重绑定的保留键(ctrl+c/d/m) |
useKeybinding.ts | 196 | React Hook:单个/多个动作绑定 |
KeybindingContext.tsx | 243 | React Context Provider + handler 注册 |
KeybindingProviderSetup.tsx | 308 | 应用级 Provider 组装 + 和弦拦截器 |
schema.ts | 236 | Zod schema 定义 + JSON Schema 生成 |
defaultBindings.ts | 340 | 默认绑定配置(所有上下文) |
validate.ts | 498 | 绑定验证(重复/保留/格式) |
template.ts | 52 | 模板生成(/keybindings 命令) |
shortcutFormat.ts | 63 | 非 React 环境的显示文本获取 |
useShortcutDisplay.ts | 59 | React 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):
- Escape 取消:如果 Escape 按下且有待定和弦,返回
chord_cancelled - 构建当前按键:
buildKeystroke()从 Ink 的input+Key构造ParsedKeystroke - 前缀检测:检查当前按键序列是否是任何绑定的前缀。关键细节:只统计
action !== null的前缀匹配(chordWinnersMap),null覆盖不会触发和弦等待 - 精确匹配:如果无更长的和弦,检查精确匹配
- 和弦取消:如果在和弦中但无匹配,返回
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 / control | ctrl |
alt / opt / option | alt |
shift | shift |
meta | meta |
cmd / command / super / win | super |
特殊按键名(parser.ts:47-67):
| 用户输入 | 内部名 |
|---|---|
esc | escape |
return | enter |
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+z | Unix 进程挂起 (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 处理器,在子组件之前拦截和弦序列的第二键。流程:
- 收集所有已注册 handler 的上下文
- 调用
resolveKeyWithChordState()解析 - 如果是
chord_started,阻止传播 → 子组件看不到这个按键 - 如果是
match且在和弦中,调用 handler 并阻止传播
10. 验证系统(validate.ts)
验证类型:
| 类型 | severity | 说明 |
|---|---|---|
parse_error | error | JSON 格式错误 |
duplicate | warning | 同一上下文中重复键 |
reserved | error/warning | 使用保留键 |
invalid_context | error | 未知上下文名 |
invalid_action | warning | 无效动作/命令格式 |
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.ts | 199 | 状态机类型定义 + 常量集合 |
transitions.ts | 490 | 状态转换逻辑表 |
motions.ts | 82 | 纯函数光标移动 |
operators.ts | 556 | 操作符执行(删除/修改/复制) |
textObjects.ts | 186 | 文本对象边界查找 |
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) |
g | j/k/g | 已输入 g,等待第二个键 |
operatorG | j/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()