本章总览
先看总流程:keybinding-registry 负责把“配置里的按键声明”变成“运行时可执行动作”,核心链路是解析、匹配、上下文过滤、handler 调用。对应文件分别承担 Context 契约、Provider 编排、resolver 纯函数和用户配置热加载。
学完本章你应该能
- 描述 KeybindingProvider 的 value 字段职责
- 说明 useKeybinding 的 switch(result.type) 分支
- 理解 resolveKeyWithChordState 与 pendingChordRef
- 知道 registerHandler 与 invokeAction 的分工
- 解释 loadKeybindingsSyncWithWarnings 合并顺序
- 能在 REPL 定位 KeybindingSetup 挂载点
核心概念(先读懂这些)
Handler 留在组件,Binding 来自配置
useKeybinding 注释强调 React 方式:handler 闭包在组件内,action 字符串从配置解析。多个组件可注册同一 action(不同 context),invokeAction 按 activeContexts 选第一个匹配 handler。
Chord 双通道:ref + state
pendingChordRef 供 resolver 同步读;pendingChord state 触发 UI( chord 提示)。CHORD_TIMEOUT_MS=1000 超时取消。ChordInterceptor 用 useInput 抢在所有 handler 之前,有意 disable prefer-use-keybindings lint。
GrowthBook 门控自定义
isKeybindingCustomizationEnabled() 查 tengu_keybinding_customization_release。未开启时 loadUserBindings 仍 parse 默认表,用户 JSON 可能被忽略或 Doctor 提示——与注释「仅 ANT 员工」历史文档需对照 GrowthBook 现状。
建议学习步骤
- 阅读 KeybindingContextValue 类型
- 跟踪 useKeybinding handleInput 分支
- 阅读 resolveKey 的 last-wins 循环
- 打开 KeybindingProviderSetup ChordInterceptor
- 阅读 loadUserBindings initializeKeybindingWatcher
- 对照 parser.ts parseBindings 输出 ParsedBinding
常见误区
注意
handler 返回 false 时不 stopPropagation
注意
unbound 动作会吞键——voice:pushToTalk 与 space 冲突注释在 defaultBindings
注意
无 KeybindingContext 时 useKeybinding 静默 no-op
注意
useKeybindings 减少多个 useInput 订阅,但共享同一 isActive
KeybindingContext API
KeybindingContextValue 暴露:
| 方法/字段 | 作用 |
|---|---|
| resolve(input, key, contexts) | ChordResolveResult |
| setPendingChord | 更新 pending 序列 |
| getDisplayText(action, context) | 快捷键展示 ctrl+t |
| bindings | 全量 ParsedBinding[] |
| activeContexts | Set,子组件 register |
| registerHandler | useKeybinding 挂载 |
| invokeAction | ChordInterceptor 回调 |
useOptionalKeybindingContext 无 Provider 返回 null——Doctor 等可选树不 crash。
源码引用: src/keybindings/KeybindingContext.tsx · 第 7–43 行(共 226 行)
7| } from 'react'
8| import type { Key } from '../ink.js'
9| import {
10| type ChordResolveResult,
11| getBindingDisplayText,
12| resolveKeyWithChordState,
13| } from './resolver.js'
14| import type {
15| KeybindingContextName,
16| ParsedBinding,
17| ParsedKeystroke,
18| } from './types.js'
19|
20| /** Handler registration for action callbacks */
21| type HandlerRegistration = {
22| action: string
23| context: KeybindingContextName
24| handler: () => void
25| }
26|
27| type KeybindingContextValue = {
28| /** Resolve a key input to an action name (with chord support) */
29| resolve: (
30| input: string,
31| key: Key,
32| activeContexts: KeybindingContextName[],
33| ) => ChordResolveResult
34|
35| /** Update the pending chord state */
36| setPendingChord: (pending: ParsedKeystroke[] | null) => void
37|
38| /** Get display text for an action (e.g., "ctrl+t") */
39| getDisplayText: (
40| action: string,
41| context: KeybindingContextName,
42| ) => string | undefined
43|
源码引用: src/keybindings/KeybindingContext.tsx · 第 134–160 行(共 226 行)
134| }
135| return false
136| }
137|
138| return {
139| // Use ref for immediate access to pending chord, avoiding React state delay
140| // This is critical for chord sequences where the second key might be pressed
141| // before React re-renders with the updated pendingChord state
142| resolve: (input, key, contexts) =>
143| resolveKeyWithChordState(
144| input,
145| key,
146| contexts,
147| bindings,
148| pendingChordRef.current,
149| ),
150| setPendingChord,
151| getDisplayText: getDisplay,
152| bindings,
153| pendingChord,
154| activeContexts,
155| registerActiveContext,
156| unregisterActiveContext,
157| registerHandler,
158| invokeAction,
159| }
160| }, [
useKeybinding 实现要点
useEffect 注册 handler 到 context;useInput 回调里:
- 组装 contextsToCheck = activeContexts + context + Global(去重保序)
- resolve → switch type
- match 且 action 相等 → handler() !== false 则 stopImmediatePropagation
- chord_started → setPendingChord + stop
- unbound → 清 chord + stop(显式禁用键)
- none → 放行
useKeybindings 批量版,减少 REPL 内 useInput 注册次数。
源码引用: src/keybindings/useKeybinding.ts · 第 33–97 行(共 197 行)
33| export function useKeybinding(
34| action: string,
35| handler: () => void | false | Promise<void>,
36| options: Options = {},
37| ): void {
38| const { context = 'Global', isActive = true } = options
39| const keybindingContext = useOptionalKeybindingContext()
40|
41| // Register handler with the context for ChordInterceptor to invoke
42| useEffect(() => {
43| if (!keybindingContext || !isActive) return
44| return keybindingContext.registerHandler({ action, context, handler })
45| }, [action, context, handler, keybindingContext, isActive])
46|
47| const handleInput = useCallback(
48| (input: string, key: Key, event: InputEvent) => {
49| // If no keybinding context available, skip resolution
50| if (!keybindingContext) return
51|
52| // Build context list: registered active contexts + this context + Global
53| // More specific contexts (registered ones) take precedence over Global
54| const contextsToCheck: KeybindingContextName[] = [
55| ...keybindingContext.activeContexts,
56| context,
57| 'Global',
58| ]
59| // Deduplicate while preserving order (first occurrence wins for priority)
60| const uniqueContexts = [...new Set(contextsToCheck)]
61|
62| const result = keybindingContext.resolve(input, key, uniqueContexts)
63|
64| switch (result.type) {
65| case 'match':
66| // Chord completed (if any) - clear pending state
67| keybindingContext.setPendingChord(null)
68| if (result.action === action) {
69| if (handler() !== false) {
70| event.stopImmediatePropagation()
71| }
72| }
73| break
74| case 'chord_started':
75| // User started a chord sequence - update pending state
76| keybindingContext.setPendingChord(result.pending)
77| event.stopImmediatePropagation()
78| break
79| case 'chord_cancelled':
80| // Chord was cancelled (escape or invalid key)
81| keybindingContext.setPendingChord(null)
82| break
83| case 'unbound':
84| // Explicitly unbound - clear any pending chord
85| keybindingContext.setPendingChord(null)
86| event.stopImmediatePropagation()
87| break
88| case 'none':
89| // No match - let other handlers try
90| break
91| }
92| },
93| [action, context, handler, keybindingContext],
94| )
95|
96| useInput(handleInput, { isActive })
97| }
源码引用: src/keybindings/useKeybinding.ts · 第 99–150 行(共 197 行)
99| /**
100| * Handle multiple keybindings in one hook (reduces useInput calls).
101| *
102| * Supports chord sequences. When a chord is started, the hook will
103| * manage the pending state automatically.
104| *
105| * @example
106| * ```tsx
107| * useKeybindings({
108| * 'chat:submit': () => handleSubmit(),
109| * 'chat:cancel': () => handleCancel(),
110| * }, { context: 'Chat' })
111| * ```
112| */
113| export function useKeybindings(
114| // Handler returning `false` means "not consumed" — the event propagates
115| // to later useInput/useKeybindings handlers. Useful for fall-through:
116| // e.g. ScrollKeybindingHandler's scroll:line* returns false when the
117| // ScrollBox content fits (scroll is a no-op), letting a child component's
118| // handler take the wheel event for list navigation instead. Promise<void>
119| // is allowed for fire-and-forget async handlers (the `!== false` check
120| // only skips propagation for a sync `false`, not a pending Promise).
121| handlers: Record<string, () => void | false | Promise<void>>,
122| options: Options = {},
123| ): void {
124| const { context = 'Global', isActive = true } = options
125| const keybindingContext = useOptionalKeybindingContext()
126|
127| // Register all handlers with the context for ChordInterceptor to invoke
128| useEffect(() => {
129| if (!keybindingContext || !isActive) return
130|
131| const unregisterFns: Array<() => void> = []
132| for (const [action, handler] of Object.entries(handlers)) {
133| unregisterFns.push(
134| keybindingContext.registerHandler({ action, context, handler }),
135| )
136| }
137|
138| return () => {
139| for (const unregister of unregisterFns) {
140| unregister()
141| }
142| }
143| }, [context, handlers, keybindingContext, isActive])
144|
145| const handleInput = useCallback(
146| (input: string, key: Key, event: InputEvent) => {
147| // If no keybinding context available, skip resolution
148| if (!keybindingContext) return
149|
150| // Build context list: registered active contexts + this context + Global
resolver 纯函数
resolveKey:单键 binding,context 集合过滤,last match wins。
resolveKeyWithChordState:pending prefix + 下一键 → match | chord_started | chord_cancelled。
getBindingDisplayText:findLast 同 action+context,chordToString 格式化。
keystrokesEqual:alt/meta 合并——legacy 终端无法区分。
buildKeystroke:escape 时 effectiveMeta=false(Ink quirk)。
源码引用: src/keybindings/resolver.ts · 第 32–61 行(共 245 行)
32| export function resolveKey(
33| input: string,
34| key: Key,
35| activeContexts: KeybindingContextName[],
36| bindings: ParsedBinding[],
37| ): ResolveResult {
38| // Find matching bindings (last one wins for user overrides)
39| let match: ParsedBinding | undefined
40| const ctxSet = new Set(activeContexts)
41|
42| for (const binding of bindings) {
43| // Phase 1: Only single-keystroke bindings
44| if (binding.chord.length !== 1) continue
45| if (!ctxSet.has(binding.context)) continue
46|
47| if (matchesBinding(input, key, binding)) {
48| match = binding
49| }
50| }
51|
52| if (!match) {
53| return { type: 'none' }
54| }
55|
56| if (match.action === null) {
57| return { type: 'unbound' }
58| }
59|
60| return { type: 'match', action: match.action }
61| }
源码引用: src/keybindings/resolver.ts · 第 82–118 行(共 245 行)
82| function buildKeystroke(input: string, key: Key): ParsedKeystroke | null {
83| const keyName = getKeyName(input, key)
84| if (!keyName) return null
85|
86| // QUIRK: Ink sets key.meta=true when escape is pressed (see input-event.ts).
87| // This is legacy terminal behavior - we should NOT record this as a modifier
88| // for the escape key itself, otherwise chord matching will fail.
89| const effectiveMeta = key.escape ? false : key.meta
90|
91| return {
92| key: keyName,
93| ctrl: key.ctrl,
94| alt: effectiveMeta,
95| shift: key.shift,
96| meta: effectiveMeta,
97| super: key.super,
98| }
99| }
100|
101| /**
102| * Compare two ParsedKeystrokes for equality. Collapses alt/meta into
103| * one logical modifier — legacy terminals can't distinguish them (see
104| * match.ts modifiersMatch), so "alt+k" and "meta+k" are the same key.
105| * Super (cmd/win) is distinct — only arrives via kitty keyboard protocol.
106| */
107| export function keystrokesEqual(
108| a: ParsedKeystroke,
109| b: ParsedKeystroke,
110| ): boolean {
111| return (
112| a.key === b.key &&
113| a.ctrl === b.ctrl &&
114| a.shift === b.shift &&
115| (a.alt || a.meta) === (b.alt || b.meta) &&
116| a.super === b.super
117| )
118| }
源码引用: src/keybindings/match.ts · 第 1–50 行(共 121 行)
1| import type { Key } from '../ink.js'
2| import type { ParsedBinding, ParsedKeystroke } from './types.js'
3|
4| /**
5| * Modifier keys from Ink's Key type that we care about for matching.
6| * Note: `fn` from Key is intentionally excluded as it's rarely used and
7| * not commonly configurable in terminal applications.
8| */
9| type InkModifiers = Pick<Key, 'ctrl' | 'shift' | 'meta' | 'super'>
10|
11| /**
12| * Extract modifiers from an Ink Key object.
13| * This function ensures we're explicitly extracting the modifiers we care about.
14| */
15| function getInkModifiers(key: Key): InkModifiers {
16| return {
17| ctrl: key.ctrl,
18| shift: key.shift,
19| meta: key.meta,
20| super: key.super,
21| }
22| }
23|
24| /**
25| * Extract the normalized key name from Ink's Key + input.
26| * Maps Ink's boolean flags (key.escape, key.return, etc.) to string names
27| * that match our ParsedKeystroke.key format.
28| */
29| export function getKeyName(input: string, key: Key): string | null {
30| if (key.escape) return 'escape'
31| if (key.return) return 'enter'
32| if (key.tab) return 'tab'
33| if (key.backspace) return 'backspace'
34| if (key.delete) return 'delete'
35| if (key.upArrow) return 'up'
36| if (key.downArrow) return 'down'
37| if (key.leftArrow) return 'left'
38| if (key.rightArrow) return 'right'
39| if (key.pageUp) return 'pageup'
40| if (key.pageDown) return 'pagedown'
41| if (key.wheelUp) return 'wheelup'
42| if (key.wheelDown) return 'wheeldown'
43| if (key.home) return 'home'
44| if (key.end) return 'end'
45| if (input.length === 1) return input.toLowerCase()
46| return null
47| }
48|
49| /**
50| * Check if all modifiers match between Ink Key and ParsedKeystroke.
KeybindingProviderSetup
KeybindingSetup 职责:
- initializeKeybindingWatcher + subscribeToKeybindingChanges
- loadKeybindingsSyncWithWarnings 初始 load
- pendingChordRef + useState pendingChord
- handlerRegistryRef: Map<action, Set<HandlerRegistration>>
- ChordInterceptor:全局 useInput,chord 未完成时吞键
- useKeybindingWarnings → notifications
REPL.tsx 结构:AppStateProvider → KeybindingSetup → GlobalKeybindingHandlers + CommandKeybindingHandlers。
源码引用: src/keybindings/KeybindingProviderSetup.tsx · 第 26–54 行(共 382 行)
26| import { resolveKeyWithChordState } from './resolver.js'
27| import type {
28| KeybindingContextName,
29| ParsedBinding,
30| ParsedKeystroke,
31| } from './types.js'
32| import type { KeybindingWarning } from './validate.js'
33|
34| /**
35| * Timeout for chord sequences in milliseconds.
36| * If the user doesn't complete the chord within this time, it's cancelled.
37| */
38| const CHORD_TIMEOUT_MS = 1000
39|
40| type Props = {
41| children: React.ReactNode
42| }
43|
44| /**
45| * Keybinding provider with default + user bindings and hot-reload support.
46| *
47| * Usage: Wrap your app with this provider to enable keybinding support.
48| *
49| * ```tsx
50| * <AppStateProvider>
51| * <KeybindingSetup>
52| * <REPL ... />
53| * </KeybindingSetup>
54| * </AppStateProvider>
源码引用: src/keybindings/KeybindingProviderSetup.tsx · 第 100–180 行(共 382 行)
100| // Keep visible for 60 seconds like settings errors
101| timeoutMs: 60000,
102| })
103| }, [warnings, isReload, addNotification, removeNotification])
104| }
105|
106| export function KeybindingSetup({ children }: Props): React.ReactNode {
107| // Load bindings synchronously for initial render
108| const [{ bindings, warnings }, setLoadResult] =
109| useState<KeybindingsLoadResult>(() => {
110| const result = loadKeybindingsSyncWithWarnings()
111| logForDebugging(
112| `[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`,
113| )
114| return result
115| })
116|
117| // Track if this is a reload (not initial load)
118| const [isReload, setIsReload] = useState(false)
119|
120| // Display warnings via notifications
121| useKeybindingWarnings(warnings, isReload)
122|
123| // Chord state management - use ref for immediate access, state for re-renders
124| // The ref is used by resolve() to get the current value without waiting for re-render
125| // The state is used to trigger re-renders when needed (e.g., for UI updates)
126| const pendingChordRef = useRef<ParsedKeystroke[] | null>(null)
127| const [pendingChord, setPendingChordState] = useState<
128| ParsedKeystroke[] | null
129| >(null)
130| const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null)
131|
132| // Handler registry for action callbacks (used by ChordInterceptor to invoke handlers)
133| const handlerRegistryRef = useRef(
134| new Map<
135| string,
136| Set<{
137| action: string
138| context: KeybindingContextName
139| handler: () => void
140| }>
141| >(),
142| )
143|
144| // Active context tracking for keybinding priority resolution
145| // Using a ref instead of state for synchronous updates - input handlers need
146| // to see the current value immediately, not after a React render cycle.
147| const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set())
148|
149| const registerActiveContext = useCallback(
150| (context: KeybindingContextName) => {
151| activeContextsRef.current.add(context)
152| },
153| [],
154| )
155|
156| const unregisterActiveContext = useCallback(
157| (context: KeybindingContextName) => {
158| activeContextsRef.current.delete(context)
159| },
160| [],
161| )
162|
163| // Clear chord timeout when component unmounts or chord changes
164| const clearChordTimeout = useCallback(() => {
165| if (chordTimeoutRef.current) {
166| clearTimeout(chordTimeoutRef.current)
167| chordTimeoutRef.current = null
168| }
169| }, [])
170|
171| // Wrapper for setPendingChord that manages timeout and syncs ref+state
172| const setPendingChord = useCallback(
173| (pending: ParsedKeystroke[] | null) => {
174| clearChordTimeout()
175|
176| if (pending !== null) {
177| // Set timeout to cancel chord if not completed
178| chordTimeoutRef.current = setTimeout(
179| (pendingChordRef, setPendingChordState) => {
180| logForDebugging('[keybindings] Chord timeout - cancelling')
loadUserBindings 热加载
KeybindingsLoadResult = { bindings, warnings }。
流程:读 keybindings.json → jsonParse → validateBindings + checkDuplicateKeysInJson → parseBindings 合并 DEFAULT_BINDINGS 之后。
chokidar 监听 FILE_STABILITY_THRESHOLD_MS=500 防抖。Telemetry:custom bindings 每日最多 log 一次。
parseBindings(parser.ts)把 KeybindingBlock[] 转为 ParsedBinding[],支持 null action 表示 unbind。
源码引用: src/keybindings/loadUserBindings.ts · 第 41–64 行(共 473 行)
41| export function isKeybindingCustomizationEnabled(): boolean {
42| return getFeatureValue_CACHED_MAY_BE_STALE(
43| 'tengu_keybinding_customization_release',
44| false,
45| )
46| }
47|
48| /**
49| * Time in milliseconds to wait for file writes to stabilize.
50| */
51| const FILE_STABILITY_THRESHOLD_MS = 500
52|
53| /**
54| * Polling interval for checking file stability.
55| */
56| const FILE_STABILITY_POLL_INTERVAL_MS = 200
57|
58| /**
59| * Result of loading keybindings, including any validation warnings.
60| */
61| export type KeybindingsLoadResult = {
62| bindings: ParsedBinding[]
63| warnings: KeybindingWarning[]
64| }
源码引用: src/keybindings/loadUserBindings.ts · 第 100–180 行(共 473 行)
100| typeof b.bindings === 'object' &&
101| b.bindings !== null
102| )
103| }
104|
105| /**
106| * Type guard to check if an array contains only valid KeybindingBlocks.
107| */
108| function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] {
109| return Array.isArray(arr) && arr.every(isKeybindingBlock)
110| }
111|
112| /**
113| * Get the path to the user keybindings file.
114| */
115| export function getKeybindingsPath(): string {
116| return join(getClaudeConfigHomeDir(), 'keybindings.json')
117| }
118|
119| /**
120| * Parse default bindings (cached for performance).
121| */
122| function getDefaultParsedBindings(): ParsedBinding[] {
123| return parseBindings(DEFAULT_BINDINGS)
124| }
125|
126| /**
127| * Load and parse keybindings from user config file.
128| * Returns merged default + user bindings along with validation warnings.
129| *
130| * For external users, always returns default bindings only.
131| * User customization is currently gated to Anthropic employees.
132| */
133| export async function loadKeybindings(): Promise<KeybindingsLoadResult> {
134| const defaultBindings = getDefaultParsedBindings()
135|
136| // Skip user config loading for external users
137| if (!isKeybindingCustomizationEnabled()) {
138| return { bindings: defaultBindings, warnings: [] }
139| }
140|
141| const userPath = getKeybindingsPath()
142|
143| try {
144| const content = await readFile(userPath, 'utf-8')
145| const parsed: unknown = jsonParse(content)
146|
147| // Extract bindings array from object wrapper format: { "bindings": [...] }
148| let userBlocks: unknown
149| if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) {
150| userBlocks = (parsed as { bindings: unknown }).bindings
151| } else {
152| // Invalid format - missing bindings property
153| const errorMessage = 'keybindings.json must have a "bindings" array'
154| const suggestion = 'Use format: { "bindings": [ ... ] }'
155| logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`)
156| return {
157| bindings: defaultBindings,
158| warnings: [
159| {
160| type: 'parse_error',
161| severity: 'error',
162| message: errorMessage,
163| suggestion,
164| },
165| ],
166| }
167| }
168|
169| // Validate structure - bindings must be an array of valid keybinding blocks
170| if (!isKeybindingBlockArray(userBlocks)) {
171| const errorMessage = !Array.isArray(userBlocks)
172| ? '"bindings" must be an array'
173| : 'keybindings.json contains invalid block structure'
174| const suggestion = !Array.isArray(userBlocks)
175| ? 'Set "bindings" to an array of keybinding blocks'
176| : 'Each block must have "context" (string) and "bindings" (object)'
177| logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`)
178| return {
179| bindings: defaultBindings,
180| warnings: [
源码引用: src/keybindings/parser.ts · 第 1–60 行(共 204 行)
1| import type {
2| Chord,
3| KeybindingBlock,
4| ParsedBinding,
5| ParsedKeystroke,
6| } from './types.js'
7|
8| /**
9| * Parse a keystroke string like "ctrl+shift+k" into a ParsedKeystroke.
10| * Supports various modifier aliases (ctrl/control, alt/opt/option/meta,
11| * cmd/command/super/win).
12| */
13| export function parseKeystroke(input: string): ParsedKeystroke {
14| const parts = input.split('+')
15| const keystroke: ParsedKeystroke = {
16| key: '',
17| ctrl: false,
18| alt: false,
19| shift: false,
20| meta: false,
21| super: false,
22| }
23| for (const part of parts) {
24| const lower = part.toLowerCase()
25| switch (lower) {
26| case 'ctrl':
27| case 'control':
28| keystroke.ctrl = true
29| break
30| case 'alt':
31| case 'opt':
32| case 'option':
33| keystroke.alt = true
34| break
35| case 'shift':
36| keystroke.shift = true
37| break
38| case 'meta':
39| keystroke.meta = true
40| break
41| case 'cmd':
42| case 'command':
43| case 'super':
44| case 'win':
45| keystroke.super = true
46| break
47| case 'esc':
48| keystroke.key = 'escape'
49| break
50| case 'return':
51| keystroke.key = 'enter'
52| break
53| case 'space':
54| keystroke.key = ' '
55| break
56| case '↑':
57| keystroke.key = 'up'
58| break
59| case '↓':
60| keystroke.key = 'down'
validate 与 schema
validate.ts 产出 KeybindingWarning:reserved key 冲突、未知 action、duplicate chord。
reservedShortcuts.ts 禁止用户覆盖 ctrl+c、ctrl+d 等。
schema.ts + template.ts 生成默认 JSON 模板供 /doctor 或文档引用。
注册链路调试:/doctor keybindings 段 → warnings 列表 → 对照 bindings 数组 find action。
源码引用: src/keybindings/validate.ts · 第 1–60 行(共 499 行)
1| import { plural } from '../utils/stringUtils.js'
2| import { chordToString, parseChord, parseKeystroke } from './parser.js'
3| import {
4| getReservedShortcuts,
5| normalizeKeyForComparison,
6| } from './reservedShortcuts.js'
7| import type {
8| KeybindingBlock,
9| KeybindingContextName,
10| ParsedBinding,
11| } from './types.js'
12|
13| /**
14| * Types of validation issues that can occur with keybindings.
15| */
16| export type KeybindingWarningType =
17| | 'parse_error'
18| | 'duplicate'
19| | 'reserved'
20| | 'invalid_context'
21| | 'invalid_action'
22|
23| /**
24| * A warning or error about a keybinding configuration issue.
25| */
26| export type KeybindingWarning = {
27| type: KeybindingWarningType
28| severity: 'error' | 'warning'
29| message: string
30| key?: string
31| context?: string
32| action?: string
33| suggestion?: string
34| }
35|
36| /**
37| * Type guard to check if an object is a valid KeybindingBlock.
38| */
39| function isKeybindingBlock(obj: unknown): obj is KeybindingBlock {
40| if (typeof obj !== 'object' || obj === null) return false
41| const b = obj as Record<string, unknown>
42| return (
43| typeof b.context === 'string' &&
44| typeof b.bindings === 'object' &&
45| b.bindings !== null
46| )
47| }
48|
49| /**
50| * Type guard to check if an array contains only valid KeybindingBlocks.
51| */
52| function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] {
53| return Array.isArray(arr) && arr.every(isKeybindingBlock)
54| }
55|
56| /**
57| * Valid context names for keybindings.
58| * Must match KeybindingContextName in types.ts
59| */
60| const VALID_CONTEXTS: KeybindingContextName[] = [
源码引用: src/keybindings/reservedShortcuts.ts · 第 1–40 行(共 128 行)
1| import { getPlatform } from '../utils/platform.js'
2|
3| /**
4| * Shortcuts that are typically intercepted by the OS, terminal, or shell
5| * and will likely never reach the application.
6| */
7| export type ReservedShortcut = {
8| key: string
9| reason: string
10| severity: 'error' | 'warning'
11| }
12|
13| /**
14| * Shortcuts that cannot be rebound - they are hardcoded in Claude Code.
15| */
16| export const NON_REBINDABLE: ReservedShortcut[] = [
17| {
18| key: 'ctrl+c',
19| reason: 'Cannot be rebound - used for interrupt/exit (hardcoded)',
20| severity: 'error',
21| },
22| {
23| key: 'ctrl+d',
24| reason: 'Cannot be rebound - used for exit (hardcoded)',
25| severity: 'error',
26| },
27| {
28| key: 'ctrl+m',
29| reason:
30| 'Cannot be rebound - identical to Enter in terminals (both send CR)',
31| severity: 'error',
32| },
33| ]
34|
35| /**
36| * Terminal control shortcuts that are intercepted by the terminal/OS.
37| * These will likely never reach the application.
38| *
39| * Note: ctrl+s (XOFF) and ctrl+q (XON) are NOT included here because:
40| * - Most modern terminals disable flow control by default
registerActiveContext 模式
Settings Dialog、Transcript 搜索等子树在 mount 时 registerActiveContext('Settings'),unmount unregister。useKeybinding 的 context 参数声明「本 handler 所属 context」,resolver 要求 binding.context 落在 activeContexts 并集内。
Transcript 打开时 activeContexts 含 Transcript,Global 的 ctrl+o 可能与 Transcript 专用 binding 共存——具体以 defaultBindings Transcript 块为准。
为什么用 ref 管同步键盘状态
KeybindingProviderSetup 对 pendingChord、handlerRegistry、activeContexts 都偏向 ref,是因为终端键盘事件需要在同一个 useInput 回调内立刻读到最新状态。pendingChordRef 让 resolver 判断“上一键是否已经启动 chord”,pendingChord state 只负责让 UI 重渲染显示提示;activeContextsRef 让 Dialog 刚 mount 后的第一个按键就进入正确 context;handlerRegistryRef 让 ChordInterceptor 能在子组件 handler 注册后直接 invoke action,而不用等待 React state 批处理完成。
ChordInterceptor 的位置也很重要:它渲染在 provider 内、children 前,注释明确它必须先于 PromptInput 捕获 chord 序列。否则 ctrl+x ctrl+e 这类两段键第二段可能被文本输入框当普通字符吃掉。调试快捷键时,应先看 resolveKeyWithChordState 的结果是否是 match/chord_started/unbound,再看 registry 中是否有 action handler,最后看 handler 所属 context 是否已 active。这个顺序能区分“配置没有解析到 action”“action 解析了但没有 handler”“handler 存在但 context 不生效”三类问题。
Provider 还承担热加载隔离。loadUserBindings 重新读文件后只替换 bindings 与 warnings,组件注册的 handler 闭包仍留在 registry;这意味着用户改键位不需要重新 mount PromptInput 或 Dialog。相反,如果某个功能没有调用 useKeybinding 或 useKeybindings 注册 action,即使用户 JSON 里写了合法 action,resolver 也只能产生命中结果,最终不会执行任何业务逻辑。因此 registry 章节要和 default-bindings、command-bindings 一起读,才能看清配置、解析、注册、调用四步。
源码引用: src/keybindings/KeybindingProviderSetup.tsx · 第 138–187 行(共 382 行)
138| context: KeybindingContextName
139| handler: () => void
140| }>
141| >(),
142| )
143|
144| // Active context tracking for keybinding priority resolution
145| // Using a ref instead of state for synchronous updates - input handlers need
146| // to see the current value immediately, not after a React render cycle.
147| const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set())
148|
149| const registerActiveContext = useCallback(
150| (context: KeybindingContextName) => {
151| activeContextsRef.current.add(context)
152| },
153| [],
154| )
155|
156| const unregisterActiveContext = useCallback(
157| (context: KeybindingContextName) => {
158| activeContextsRef.current.delete(context)
159| },
160| [],
161| )
162|
163| // Clear chord timeout when component unmounts or chord changes
164| const clearChordTimeout = useCallback(() => {
165| if (chordTimeoutRef.current) {
166| clearTimeout(chordTimeoutRef.current)
167| chordTimeoutRef.current = null
168| }
169| }, [])
170|
171| // Wrapper for setPendingChord that manages timeout and syncs ref+state
172| const setPendingChord = useCallback(
173| (pending: ParsedKeystroke[] | null) => {
174| clearChordTimeout()
175|
176| if (pending !== null) {
177| // Set timeout to cancel chord if not completed
178| chordTimeoutRef.current = setTimeout(
179| (pendingChordRef, setPendingChordState) => {
180| logForDebugging('[keybindings] Chord timeout - cancelling')
181| pendingChordRef.current = null
182| setPendingChordState(null)
183| },
184| CHORD_TIMEOUT_MS,
185| pendingChordRef,
186| setPendingChordState,
187| )
源码引用: src/keybindings/KeybindingProviderSetup.tsx · 第 211–253 行(共 382 行)
211| )
212| })
213|
214| return () => {
215| unsubscribe()
216| clearChordTimeout()
217| }
218| }, [clearChordTimeout])
219|
220| return (
221| <KeybindingProvider
222| bindings={bindings}
223| pendingChordRef={pendingChordRef}
224| pendingChord={pendingChord}
225| setPendingChord={setPendingChord}
226| activeContexts={activeContextsRef.current}
227| registerActiveContext={registerActiveContext}
228| unregisterActiveContext={unregisterActiveContext}
229| handlerRegistryRef={handlerRegistryRef}
230| >
231| <ChordInterceptor
232| bindings={bindings}
233| pendingChordRef={pendingChordRef}
234| setPendingChord={setPendingChord}
235| activeContexts={activeContextsRef.current}
236| handlerRegistryRef={handlerRegistryRef}
237| />
238| {children}
239| </KeybindingProvider>
240| )
241| }
242|
243| /**
244| * Global chord interceptor that registers useInput FIRST (before children).
245| *
246| * This component intercepts keystrokes that are part of chord sequences and
247| * stops propagation before other handlers (like PromptInput) can see them.
248| *
249| * Without this, the second key of a chord (e.g., 'r' in "ctrl+c r") would be
250| * captured by PromptInput and added to the input field before the keybinding
251| * system could recognize it as completing a chord.
252| */
253| type HandlerRegistration = {
源码引用: src/keybindings/KeybindingContext.tsx · 第 13–43 行(共 226 行)
13| } from './resolver.js'
14| import type {
15| KeybindingContextName,
16| ParsedBinding,
17| ParsedKeystroke,
18| } from './types.js'
19|
20| /** Handler registration for action callbacks */
21| type HandlerRegistration = {
22| action: string
23| context: KeybindingContextName
24| handler: () => void
25| }
26|
27| type KeybindingContextValue = {
28| /** Resolve a key input to an action name (with chord support) */
29| resolve: (
30| input: string,
31| key: Key,
32| activeContexts: KeybindingContextName[],
33| ) => ChordResolveResult
34|
35| /** Update the pending chord state */
36| setPendingChord: (pending: ParsedKeystroke[] | null) => void
37|
38| /** Get display text for an action (e.g., "ctrl+t") */
39| getDisplayText: (
40| action: string,
41| context: KeybindingContextName,
42| ) => string | undefined
43|
排障最短路径:配置到执行四跳
遇到“按键没反应”时,优先按四跳排障:第一跳看 loadUserBindings 是否把 JSON 解析成 ParsedBinding;第二跳看 resolver 是否在当前 contexts 下得到 match/chord_started;第三跳看 handlerRegistryRef 是否注册了该 action;第四跳看 handler 返回值是否阻止了继续传播。这个顺序能快速定位是配置问题、匹配问题、注册问题还是业务处理问题。
实践里最常见误区是直接怀疑 useInput 顺序,结果忽略了 action 根本没注册。因为 registry 与 bindings 是解耦的:bindings 只定义“应该触发哪个 action”,是否执行要看对应功能是否调用 useKeybinding/useKeybindings 注册过 handler。
源码引用: src/keybindings/loadUserBindings.ts · 第 100–180 行(共 473 行)
100| typeof b.bindings === 'object' &&
101| b.bindings !== null
102| )
103| }
104|
105| /**
106| * Type guard to check if an array contains only valid KeybindingBlocks.
107| */
108| function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] {
109| return Array.isArray(arr) && arr.every(isKeybindingBlock)
110| }
111|
112| /**
113| * Get the path to the user keybindings file.
114| */
115| export function getKeybindingsPath(): string {
116| return join(getClaudeConfigHomeDir(), 'keybindings.json')
117| }
118|
119| /**
120| * Parse default bindings (cached for performance).
121| */
122| function getDefaultParsedBindings(): ParsedBinding[] {
123| return parseBindings(DEFAULT_BINDINGS)
124| }
125|
126| /**
127| * Load and parse keybindings from user config file.
128| * Returns merged default + user bindings along with validation warnings.
129| *
130| * For external users, always returns default bindings only.
131| * User customization is currently gated to Anthropic employees.
132| */
133| export async function loadKeybindings(): Promise<KeybindingsLoadResult> {
134| const defaultBindings = getDefaultParsedBindings()
135|
136| // Skip user config loading for external users
137| if (!isKeybindingCustomizationEnabled()) {
138| return { bindings: defaultBindings, warnings: [] }
139| }
140|
141| const userPath = getKeybindingsPath()
142|
143| try {
144| const content = await readFile(userPath, 'utf-8')
145| const parsed: unknown = jsonParse(content)
146|
147| // Extract bindings array from object wrapper format: { "bindings": [...] }
148| let userBlocks: unknown
149| if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) {
150| userBlocks = (parsed as { bindings: unknown }).bindings
151| } else {
152| // Invalid format - missing bindings property
153| const errorMessage = 'keybindings.json must have a "bindings" array'
154| const suggestion = 'Use format: { "bindings": [ ... ] }'
155| logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`)
156| return {
157| bindings: defaultBindings,
158| warnings: [
159| {
160| type: 'parse_error',
161| severity: 'error',
162| message: errorMessage,
163| suggestion,
164| },
165| ],
166| }
167| }
168|
169| // Validate structure - bindings must be an array of valid keybinding blocks
170| if (!isKeybindingBlockArray(userBlocks)) {
171| const errorMessage = !Array.isArray(userBlocks)
172| ? '"bindings" must be an array'
173| : 'keybindings.json contains invalid block structure'
174| const suggestion = !Array.isArray(userBlocks)
175| ? 'Set "bindings" to an array of keybinding blocks'
176| : 'Each block must have "context" (string) and "bindings" (object)'
177| logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`)
178| return {
179| bindings: defaultBindings,
180| warnings: [
源码引用: src/keybindings/resolver.ts · 第 32–61 行(共 245 行)
32| export function resolveKey(
33| input: string,
34| key: Key,
35| activeContexts: KeybindingContextName[],
36| bindings: ParsedBinding[],
37| ): ResolveResult {
38| // Find matching bindings (last one wins for user overrides)
39| let match: ParsedBinding | undefined
40| const ctxSet = new Set(activeContexts)
41|
42| for (const binding of bindings) {
43| // Phase 1: Only single-keystroke bindings
44| if (binding.chord.length !== 1) continue
45| if (!ctxSet.has(binding.context)) continue
46|
47| if (matchesBinding(input, key, binding)) {
48| match = binding
49| }
50| }
51|
52| if (!match) {
53| return { type: 'none' }
54| }
55|
56| if (match.action === null) {
57| return { type: 'unbound' }
58| }
59|
60| return { type: 'match', action: match.action }
61| }
源码引用: src/keybindings/KeybindingProviderSetup.tsx · 第 145–187 行(共 382 行)
145| // Using a ref instead of state for synchronous updates - input handlers need
146| // to see the current value immediately, not after a React render cycle.
147| const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set())
148|
149| const registerActiveContext = useCallback(
150| (context: KeybindingContextName) => {
151| activeContextsRef.current.add(context)
152| },
153| [],
154| )
155|
156| const unregisterActiveContext = useCallback(
157| (context: KeybindingContextName) => {
158| activeContextsRef.current.delete(context)
159| },
160| [],
161| )
162|
163| // Clear chord timeout when component unmounts or chord changes
164| const clearChordTimeout = useCallback(() => {
165| if (chordTimeoutRef.current) {
166| clearTimeout(chordTimeoutRef.current)
167| chordTimeoutRef.current = null
168| }
169| }, [])
170|
171| // Wrapper for setPendingChord that manages timeout and syncs ref+state
172| const setPendingChord = useCallback(
173| (pending: ParsedKeystroke[] | null) => {
174| clearChordTimeout()
175|
176| if (pending !== null) {
177| // Set timeout to cancel chord if not completed
178| chordTimeoutRef.current = setTimeout(
179| (pendingChordRef, setPendingChordState) => {
180| logForDebugging('[keybindings] Chord timeout - cancelling')
181| pendingChordRef.current = null
182| setPendingChordState(null)
183| },
184| CHORD_TIMEOUT_MS,
185| pendingChordRef,
186| setPendingChordState,
187| )
本章小结与延伸
keybinding-registry = Provider + resolver + Hook 三角。下一章 default-bindings 读 DEFAULT_BINDINGS 全貌。 继续学习: