本章总览
先看本质:command-bindings 是一层动态桥接,把 keybindings 里的 command:* action 转译成真实 slash 命令提交。它不维护命令实现本身,只负责发现绑定、门控触发场景,并以 fromKeybinding 语义把命令送入统一提交管线。
学完本章你应该能
- 解释 commandActions 如何从 bindings 提取
- 说明 fromKeybinding 选项对 handlePromptSubmit 的影响
- 理解 isActive && !isModalOverlayActive 门控
- 知道 NOOP_HELPERS 为何 setCursorOffset/clearBuffer 为空
- 能在 keybindings.json 添加自定义 command:* 示例
- 区分 command:* 与 prompt 命令 registry 名称
核心概念(先读懂这些)
动态发现而非静态表
不像 app:toggleTodos 在 useGlobalKeybindings 硬编码,command:* handler 完全由用户配置驱动。新增 /foo 命令无需改 keybindings 模块——用户写 command:foo 即可,只要 command 名与 registry 一致。
immediate 语义
注释强调:keybinding 触发 treated as immediate,preserve existing input text。与普通 Enter 提交不同,用户可能正在写长 prompt 时按快捷键执行 /compact,完成后 buffer 仍在。
Chat context 专属
useKeybindings(handlers, { context: Chat, isActive })——Global 键不会误触发 slash 命令。Modal overlay 激活时 isActive false,避免与 Dialog 抢键。
建议学习步骤
- 阅读 CommandKeybindingHandlers 文件头注释
- 跟踪 commandActions Set 构建循环
- 阅读 handlers map 与 onSubmit 调用
- 在 handlePromptSubmit 搜索 fromKeybinding
- 查看 schema.ts 是否文档化 command: 前缀
- 在 REPL.tsx 搜索 CommandKeybindingHandlers 挂载
常见误区
注意
command 名大小写需与 registry name 一致
注意
local-jsx 命令(如 /memory)从 keybinding 触发仍走同一 onSubmit 路径
注意
无 keybindingContext 时 commandActions 为空 Set
注意
command:unknown 会走 submit 但可能显示 unknown command
CommandKeybindingHandlers 流程
keybindingContext.bindings
→ filter action?.startsWith('command:')
→ commandActions Set
→ for each: map[action] = () => onSubmit('/'+name, NOOP_HELPERS, undefined, { fromKeybinding: true })
→ useKeybindings(map, { context: 'Chat', isActive })
组件零 UI,必须渲染在 KeybindingSetup 内。onSubmit 类型接受 rest 参数以兼容 speculationAccept 等扩展位。
源码引用: src/hooks/useCommandKeybindings.tsx · 第 1–36 行(共 83 行)
1| /**
2| * Component that registers keybinding handlers for command bindings.
3| *
4| * Must be rendered inside KeybindingSetup to have access to the keybinding context.
5| * Reads "command:*" actions from the current keybinding configuration and registers
6| * handlers that invoke the corresponding slash command via onSubmit.
7| *
8| * Commands triggered via keybinding are treated as "immediate" - they execute right
9| * away and preserve the user's existing input text (the prompt is not cleared).
10| */
11| import { useMemo } from 'react'
12| import { useIsModalOverlayActive } from '../context/overlayContext.js'
13| import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'
14| import { useKeybindings } from '../keybindings/useKeybinding.js'
15| import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js'
16|
17| type Props = {
18| // onSubmit accepts additional parameters beyond what we pass here,
19| // so we use a rest parameter to allow any additional args
20| onSubmit: (
21| input: string,
22| helpers: PromptInputHelpers,
23| ...rest: [
24| speculationAccept?: undefined,
25| options?: { fromKeybinding?: boolean },
26| ]
27| ) => void
28| /** Set to false to disable command keybindings (e.g., when a dialog is open) */
29| isActive?: boolean
30| }
31|
32| const NOOP_HELPERS: PromptInputHelpers = {
33| setCursorOffset: () => {},
34| clearBuffer: () => {},
35| resetHistory: () => {},
36| }
源码引用: src/hooks/useCommandKeybindings.tsx · 第 37–83 行(共 83 行)
37|
38| /**
39| * Registers keybinding handlers for all "command:*" actions found in the
40| * user's keybinding configuration. When triggered, each handler submits
41| * the corresponding slash command (e.g., "command:commit" submits "/commit").
42| */
43| export function CommandKeybindingHandlers({
44| onSubmit,
45| isActive = true,
46| }: Props): null {
47| const keybindingContext = useOptionalKeybindingContext()
48| const isModalOverlayActive = useIsModalOverlayActive()
49|
50| // Extract command actions from parsed bindings
51| const commandActions = useMemo(() => {
52| if (!keybindingContext) return new Set<string>()
53| const actions = new Set<string>()
54| for (const binding of keybindingContext.bindings) {
55| if (binding.action?.startsWith('command:')) {
56| actions.add(binding.action)
57| }
58| }
59| return actions
60| }, [keybindingContext])
61|
62| // Build handler map for all command actions
63| const handlers = useMemo(() => {
64| const map: Record<string, () => void> = {}
65| for (const action of commandActions) {
66| const commandName = action.slice('command:'.length)
67| map[action] = () => {
68| onSubmit(`/${commandName}`, NOOP_HELPERS, undefined, {
69| fromKeybinding: true,
70| })
71| }
72| }
73| return map
74| }, [commandActions, onSubmit])
75|
76| useKeybindings(handlers, {
77| context: 'Chat',
78| isActive: isActive && !isModalOverlayActive,
79| })
80|
81| return null
82| }
83|
NOOP_HELPERS 设计
PromptInputHelpers 正常提供 setCursorOffset、clearBuffer、resetHistory。Command keybinding 路径传入 no-op 函数——因为 immediate 模式 不应 清空或移动光标。
若未来某 command 需要 manip buffer,应显式扩展 fromKeybinding 分支而非默认 clearBuffer。
源码引用: src/hooks/useCommandKeybindings.tsx · 第 26–30 行(共 83 行)
26| ]
27| ) => void
28| /** Set to false to disable command keybindings (e.g., when a dialog is open) */
29| isActive?: boolean
30| }
Modal overlay 门控
useIsModalOverlayActive 来自 overlayContext。当 /memory、权限 Dialog 等 modal 打开时,command keybindings 暂停,防止 ctrl+ 绑定的 /commit 在 Dialog 内误触。
GlobalKeybindingHandlers 有类似 isActive 模式;Command 层额外检查 modal 因 Chat context 在 modal 下 technically 仍 active。
源码引用: src/hooks/useCommandKeybindings.tsx · 第 93–83 行(共 83 行)
源码引用: src/context/overlayContext.tsx · 第 1–40 行(共 110 行)
1| /**
2| * Overlay tracking for Escape key coordination.
3| *
4| * This solves the problem of escape key handling when overlays (like Select with onCancel)
5| * are open. The CancelRequestHandler needs to know when an overlay is active so it doesn't
6| * cancel requests when the user just wants to dismiss the overlay.
7| *
8| * Usage:
9| * 1. Call useRegisterOverlay() in any overlay component to automatically register it
10| * 2. Call useIsOverlayActive() to check if any overlay is currently active
11| *
12| * The hook automatically registers on mount and unregisters on unmount,
13| * so no manual cleanup or state management is needed.
14| */
15| import { useContext, useEffect, useLayoutEffect } from 'react'
16| import instances from '../ink/instances.js'
17| import { AppStoreContext, useAppState } from '../state/AppState.js'
18|
19| // Non-modal overlays that shouldn't disable TextInput focus
20| const NON_MODAL_OVERLAYS = new Set(['autocomplete'])
21|
22| /**
23| * Hook to register a component as an active overlay.
24| * Automatically registers on mount and unregisters on unmount.
25| *
26| * @param id - Unique identifier for this overlay (e.g., 'select', 'multi-select')
27| * @param enabled - Whether to register (default: true). Use this to conditionally register
28| * based on component props, e.g., only register when onCancel is provided.
29| *
30| * @example
31| * // Conditional registration based on whether cancel is supported
32| * function useSelectInput({ state }) {
33| * useRegisterOverlay('select', !!state.onCancel)
34| * // ...
35| * }
36| */
37| export function useRegisterOverlay(id: string, enabled = true): void {
38| // Use context directly so this is a no-op when rendered outside AppStateProvider
39| // (e.g., in isolated component tests that don't need the full app state tree).
40| const store = useContext(AppStoreContext)
handlePromptSubmit 与 fromKeybinding
handlePromptSubmit(utils/handlePromptSubmit.ts)解析 leading slash,查 commands registry,区分 prompt/local/local-jsx。
options.fromKeybinding 标记来源 analytics(tengu_keybinding_command_invoke)并可能跳过某些 readline 前置逻辑。immediate flag 确保不等待 prompt 清空再 dispatch。
与 CommandKeybindingHandlers 配合:键 → slash+命令名 字符串 → 与普通 slash 相同 pipeline。
源码引用: src/utils/handlePromptSubmit.ts · 第 1–60 行(共 611 行)
1| import type { UUID } from 'crypto'
2| import { logEvent } from 'src/services/analytics/index.js'
3| import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/metadata.js'
4| import { type Command, getCommandName, isCommandEnabled } from '../commands.js'
5| import { selectableUserMessagesFilter } from '../components/MessageSelector.js'
6| import type { SpinnerMode } from '../components/Spinner/types.js'
7| import type { QuerySource } from '../constants/querySource.js'
8| import { expandPastedTextRefs, parseReferences } from '../history.js'
9| import type { CanUseToolFn } from '../hooks/useCanUseTool.js'
10| import type { IDESelection } from '../hooks/useIdeSelection.js'
11| import type { AppState } from '../state/AppState.js'
12| import type { SetToolJSXFn } from '../Tool.js'
13| import type { LocalJSXCommandOnDone } from '../types/command.js'
14| import type { Message } from '../types/message.js'
15| import {
16| isValidImagePaste,
17| type PromptInputMode,
18| type QueuedCommand,
19| } from '../types/textInputTypes.js'
20| import { createAbortController } from './abortController.js'
21| import type { PastedContent } from './config.js'
22| import { logForDebugging } from './debug.js'
23| import type { EffortValue } from './effort.js'
24| import type { FileHistoryState } from './fileHistory.js'
25| import { fileHistoryEnabled, fileHistoryMakeSnapshot } from './fileHistory.js'
26| import { gracefulShutdownSync } from './gracefulShutdown.js'
27| import { enqueue } from './messageQueueManager.js'
28| import { resolveSkillModelOverride } from './model/model.js'
29| import type { ProcessUserInputContext } from './processUserInput/processUserInput.js'
30| import { processUserInput } from './processUserInput/processUserInput.js'
31| import type { QueryGuard } from './QueryGuard.js'
32| import { queryCheckpoint, startQueryProfile } from './queryProfiler.js'
33| import { runWithWorkload } from './workloadContext.js'
34|
35| function exit(): void {
36| gracefulShutdownSync(0)
37| }
38|
39| type BaseExecutionParams = {
40| queuedCommands?: QueuedCommand[]
41| messages: Message[]
42| mainLoopModel: string
43| ideSelection: IDESelection | undefined
44| querySource: QuerySource
45| commands: Command[]
46| queryGuard: QueryGuard
47| /**
48| * True when external loading (remote session, foregrounded background task)
49| * is active. These don't route through queryGuard, so the queue check must
50| * account for them separately. Omit (defaults to false) for the dequeue path
51| * (executeQueuedInput) — dequeued items were already queued past this check.
52| */
53| isExternalLoading?: boolean
54| setToolJSX: SetToolJSXFn
55| getToolUseContext: (
56| messages: Message[],
57| newMessages: Message[],
58| abortController: AbortController,
59| mainLoopModel: string,
60| ) => ProcessUserInputContext
源码引用: src/utils/handlePromptSubmit.ts · 第 100–160 行(共 611 行)
100| React.SetStateAction<Record<number, PastedContent>>
101| >
102| abortController?: AbortController | null
103| addNotification?: (notification: {
104| key: string
105| text: string
106| priority: 'low' | 'medium' | 'high' | 'immediate'
107| }) => void
108| setMessages?: (updater: (prev: Message[]) => Message[]) => void
109| streamMode?: SpinnerMode
110| hasInterruptibleToolInProgress?: boolean
111| uuid?: UUID
112| /**
113| * When true, input starting with `/` is treated as plain text.
114| * Used for remotely-received messages (bridge/CCR) that should not
115| * trigger local slash commands or skills.
116| */
117| skipSlashCommands?: boolean
118| }
119|
120| export async function handlePromptSubmit(
121| params: HandlePromptSubmitParams,
122| ): Promise<void> {
123| const {
124| helpers,
125| queryGuard,
126| isExternalLoading = false,
127| commands,
128| onInputChange,
129| setPastedContents,
130| setToolJSX,
131| getToolUseContext,
132| messages,
133| mainLoopModel,
134| ideSelection,
135| setUserInputOnProcessing,
136| setAbortController,
137| onQuery,
138| setAppState,
139| onBeforeQuery,
140| canUseTool,
141| queuedCommands,
142| uuid,
143| skipSlashCommands,
144| } = params
145|
146| const { setCursorOffset, clearBuffer, resetHistory } = helpers
147|
148| // Queue processor path: commands are pre-validated and ready to execute.
149| // Skip all input validation, reference parsing, and queuing logic.
150| if (queuedCommands?.length) {
151| startQueryProfile()
152| await executeUserInput({
153| queuedCommands,
154| messages,
155| mainLoopModel,
156| ideSelection,
157| querySource: params.querySource,
158| commands,
159| queryGuard,
160| setToolJSX,
schema 与用户配置示例
schema.ts 描述 keybindings.json 结构:context 块 + bindings 对象。action 字符串可任意,validate 阶段检查 known app:/chat: prefix;command:* 通常 不 白名单校验(开放 ended)。
示例:
{
"context": "Chat",
"bindings": {
"ctrl+shift+m": "command:memory",
"alt+c": "command:compact"
}
}
合并顺序:DEFAULT_BINDINGS 先 parse,用户块后 parse,同 context 同 chord 用户覆盖。
源码引用: src/keybindings/schema.ts · 第 1–50 行(共 237 行)
1| /**
2| * Zod schema for keybindings.json configuration.
3| * Used for validation and JSON schema generation.
4| */
5|
6| import { z } from 'zod/v4'
7| import { lazySchema } from '../utils/lazySchema.js'
8|
9| /**
10| * Valid context names where keybindings can be applied.
11| */
12| export const KEYBINDING_CONTEXTS = [
13| 'Global',
14| 'Chat',
15| 'Autocomplete',
16| 'Confirmation',
17| 'Help',
18| 'Transcript',
19| 'HistorySearch',
20| 'Task',
21| 'ThemePicker',
22| 'Settings',
23| 'Tabs',
24| // New contexts for keybindings migration
25| 'Attachments',
26| 'Footer',
27| 'MessageSelector',
28| 'DiffDialog',
29| 'ModelPicker',
30| 'Select',
31| 'Plugin',
32| ] as const
33|
34| /**
35| * Human-readable descriptions for each keybinding context.
36| */
37| export const KEYBINDING_CONTEXT_DESCRIPTIONS: Record<
38| (typeof KEYBINDING_CONTEXTS)[number],
39| string
40| > = {
41| Global: 'Active everywhere, regardless of focus',
42| Chat: 'When the chat input is focused',
43| Autocomplete: 'When autocomplete menu is visible',
44| Confirmation: 'When a confirmation/permission dialog is shown',
45| Help: 'When the help overlay is open',
46| Transcript: 'When viewing the transcript',
47| HistorySearch: 'When searching command history (ctrl+r)',
48| Task: 'When a task/agent is running in the foreground',
49| ThemePicker: 'When the theme picker is open',
50| Settings: 'When the settings menu is open',
源码引用: src/keybindings/template.ts · 第 1–40 行(共 53 行)
1| /**
2| * Keybindings template generator.
3| * Generates a well-documented template file for ~/.claude/keybindings.json
4| */
5|
6| import { jsonStringify } from '../utils/slowOperations.js'
7| import { DEFAULT_BINDINGS } from './defaultBindings.js'
8| import {
9| NON_REBINDABLE,
10| normalizeKeyForComparison,
11| } from './reservedShortcuts.js'
12| import type { KeybindingBlock } from './types.js'
13|
14| /**
15| * Filter out reserved shortcuts that cannot be rebound.
16| * These would cause /doctor to warn, so we exclude them from the template.
17| */
18| function filterReservedShortcuts(blocks: KeybindingBlock[]): KeybindingBlock[] {
19| const reservedKeys = new Set(
20| NON_REBINDABLE.map(r => normalizeKeyForComparison(r.key)),
21| )
22|
23| return blocks
24| .map(block => {
25| const filteredBindings: Record<string, string | null> = {}
26| for (const [key, action] of Object.entries(block.bindings)) {
27| if (!reservedKeys.has(normalizeKeyForComparison(key))) {
28| filteredBindings[key] = action
29| }
30| }
31| return { context: block.context, bindings: filteredBindings }
32| })
33| .filter(block => Object.keys(block.bindings).length > 0)
34| }
35|
36| /**
37| * Generate a template keybindings.json file content.
38| * Creates a fully valid JSON file with all default bindings that users can customize.
39| */
40| export function generateKeybindingsTemplate(): string {
defaultBindings 无 command:*
开箱 defaultBindings 不包含 command:* 条目——避免把 /commit 等绑死到固定键,留给用户 customization。
产品可在文档推荐 binding,但不进 DEFAULT_BINDINGS 除非成为 universal UX(类似 ctrl+t todos)。
loadUserBindings 解析后 bindings 数组同时含 app:* 与 command:*,CommandKeybindingHandlers 只消费后者 subset。
源码引用: src/keybindings/defaultBindings.ts · 第 32–62 行(共 341 行)
32| export const DEFAULT_BINDINGS: KeybindingBlock[] = [
33| {
34| context: 'Global',
35| bindings: {
36| // ctrl+c and ctrl+d use special time-based double-press handling.
37| // They ARE defined here so the resolver can find them, but they
38| // CANNOT be rebound by users - validation in reservedShortcuts.ts
39| // will show an error if users try to override these keys.
40| 'ctrl+c': 'app:interrupt',
41| 'ctrl+d': 'app:exit',
42| 'ctrl+l': 'app:redraw',
43| 'ctrl+t': 'app:toggleTodos',
44| 'ctrl+o': 'app:toggleTranscript',
45| ...(feature('KAIROS') || feature('KAIROS_BRIEF')
46| ? { 'ctrl+shift+b': 'app:toggleBrief' as const }
47| : {}),
48| 'ctrl+shift+o': 'app:toggleTeammatePreview',
49| 'ctrl+r': 'history:search',
50| // File navigation. cmd+ bindings only fire on kitty-protocol terminals;
51| // ctrl+shift is the portable fallback.
52| ...(feature('QUICK_SEARCH')
53| ? {
54| 'ctrl+shift+f': 'app:globalSearch' as const,
55| 'cmd+shift+f': 'app:globalSearch' as const,
56| 'ctrl+shift+p': 'app:quickOpen' as const,
57| 'cmd+shift+p': 'app:quickOpen' as const,
58| }
59| : {}),
60| ...(feature('TERMINAL_PANEL') ? { 'meta+j': 'app:toggleTerminal' } : {}),
61| },
62| },
源码引用: src/keybindings/loadUserBindings.ts · 第 180–240 行(共 473 行)
180| warnings: [
181| {
182| type: 'parse_error',
183| severity: 'error',
184| message: errorMessage,
185| suggestion,
186| },
187| ],
188| }
189| }
190|
191| const userParsed = parseBindings(userBlocks)
192| logForDebugging(
193| `[keybindings] Loaded ${userParsed.length} user bindings from ${userPath}`,
194| )
195|
196| // User bindings come after defaults, so they override
197| const mergedBindings = [...defaultBindings, ...userParsed]
198|
199| logCustomBindingsLoadedOncePerDay(userParsed.length)
200|
201| // Run validation on user config
202| // First check for duplicate keys in raw JSON (JSON.parse silently drops earlier values)
203| const duplicateKeyWarnings = checkDuplicateKeysInJson(content)
204| const warnings = [
205| ...duplicateKeyWarnings,
206| ...validateBindings(userBlocks, mergedBindings),
207| ]
208|
209| if (warnings.length > 0) {
210| logForDebugging(
211| `[keybindings] Found ${warnings.length} validation issue(s)`,
212| )
213| }
214|
215| return { bindings: mergedBindings, warnings }
216| } catch (error) {
217| // File doesn't exist - use defaults (user can run /keybindings to create)
218| if (isENOENT(error)) {
219| return { bindings: defaultBindings, warnings: [] }
220| }
221|
222| // Other error - log and return defaults with warning
223| logForDebugging(
224| `[keybindings] Error loading ${userPath}: ${errorMessage(error)}`,
225| )
226| return {
227| bindings: defaultBindings,
228| warnings: [
229| {
230| type: 'parse_error',
231| severity: 'error',
232| message: `Failed to parse keybindings.json: ${errorMessage(error)}`,
233| },
234| ],
235| }
236| }
237| }
238|
239| /**
240| * Load keybindings synchronously (for initial render).
REPL 挂载与调试
REPL.tsx 在 KeybindingSetup 内并列:
<GlobalKeybindingHandlers ... />
<CommandKeybindingHandlers onSubmit={handleSubmit} isActive={...} />
调试「command 快捷键无效」:
- keybindings.json 是否 parse 成功(Doctor warnings)
- isModalOverlayActive
- chord 是否未完成 pending
- command 名是否与 commands/registry 一致
hooks/input-keybindings 子章节提供全局键盘 UX 上下文。
源码引用: src/screens/REPL.tsx · 第 1–50 行(共 7050 行)
1| // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
2| import { feature } from 'bun:bundle'
3| import { spawnSync } from 'child_process'
4| import {
5| snapshotOutputTokensForTurn,
6| getCurrentTurnTokenBudget,
7| getTurnOutputTokens,
8| getBudgetContinuationCount,
9| getTotalInputTokens,
10| } from '../bootstrap/state.js'
11| import { parseTokenBudget } from '../utils/tokenBudget.js'
12| import { count } from '../utils/array.js'
13| import { dirname, join } from 'path'
14| import { tmpdir } from 'os'
15| import figures from 'figures'
16| // eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler
17| import { useInput } from '../ink.js'
18| import { useSearchInput } from '../hooks/useSearchInput.js'
19| import { useTerminalSize } from '../hooks/useTerminalSize.js'
20| import { useSearchHighlight } from '../ink/hooks/use-search-highlight.js'
21| import type { JumpHandle } from '../components/VirtualMessageList.js'
22| import { renderMessagesToPlainText } from '../utils/exportRenderer.js'
23| import { openFileInExternalEditor } from '../utils/editor.js'
24| import { writeFile } from 'fs/promises'
25| import {
26| Box,
27| Text,
28| useStdin,
29| useTheme,
30| useTerminalFocus,
31| useTerminalTitle,
32| useTabStatus,
33| } from '../ink.js'
34| import type { TabStatusKind } from '../ink/hooks/use-tab-status.js'
35| import { CostThresholdDialog } from '../components/CostThresholdDialog.js'
36| import { IdleReturnDialog } from '../components/IdleReturnDialog.js'
37| import * as React from 'react'
38| import {
39| useEffect,
40| useMemo,
41| useRef,
42| useState,
43| useCallback,
44| useDeferredValue,
45| useLayoutEffect,
46| type RefObject,
47| } from 'react'
48| import { useNotifications } from '../context/notifications.js'
49| import { sendNotification } from '../services/notifier.js'
50| import {
fromKeybinding 的真正影响
command:* 最容易误解的一点是:它不是把某个 React handler 直接绑到键上,而是合成一条 slash command 输入交给 onSubmit。CommandKeybindingHandlers 只从已解析 bindings 中收集 action.startsWith("command:") 的项,取 command: 后面的名字拼成 /name,再传入 NOOP_HELPERS 和 { fromKeybinding: true }。NOOP_HELPERS 保证当前 prompt 光标、buffer、历史不被这个合成输入破坏;Chat context 和 modal overlay 门控保证它只在主输入可用时触发。
REPL 侧收到 fromKeybinding 后,会把匹配命令视作 immediate 候选:如果命令是 local-jsx 且 queryGuard active,就直接打开对应 Dialog,同时保留用户原本正在输入的 prompt。代码还跳过 keybinding 触发命令的 history 记录,并在提交清空逻辑里保留输入内容。也就是说,alt+m 触发 command:memory 与用户手动输入 /memory 共享命令 registry 和 local-jsx 实现,但交互语义不同:前者是“旁路打开命令,不污染当前草稿”,后者是“用户显式提交一条 slash 命令”。排查问题时应顺着 bindings → CommandKeybindingHandlers → REPL immediate branch,而不是只在 defaultBindings 里找内置条目。
command:* 还有一个有意保留的开放性:它不要求 defaultBindings 预先列出所有 slash 命令。用户、插件或未来内置命令只要进入命令 registry,就可以被 keybindings.json 通过 command:name 触发。代价是校验阶段无法完全判断 command:unknown 是否会失败,真正的错误可能要到 handlePromptSubmit 查 registry 时才出现。文档应把它描述为“动态命令桥”,不是稳定 action 白名单;推荐配置时也要提醒命令名、别名、local-jsx 是否支持当前会话模式以及 modal 是否打开。
这个设计也解释了为什么 command-bindings 章节要引用 schema 和 defaultBindings,却不能把它们当主要实现。schema 只描述 keybindings.json 的结构,defaultBindings 只是默认表;真正决定某个 command:* 能否工作的是当前解析后的 bindings、命令 registry、REPL onSubmit 分支和 overlay 状态。若用户把 command:compact 绑定到 Chat context,按键先被 resolver 解析成 action,再由 CommandKeybindingHandlers 变成 /compact;后续 compact 是否立即执行、是否显示 local-jsx、是否清空输入,都由命令自身和 REPL 分支决定。
源码引用: src/hooks/useCommandKeybindings.tsx · 第 37–83 行(共 83 行)
37|
38| /**
39| * Registers keybinding handlers for all "command:*" actions found in the
40| * user's keybinding configuration. When triggered, each handler submits
41| * the corresponding slash command (e.g., "command:commit" submits "/commit").
42| */
43| export function CommandKeybindingHandlers({
44| onSubmit,
45| isActive = true,
46| }: Props): null {
47| const keybindingContext = useOptionalKeybindingContext()
48| const isModalOverlayActive = useIsModalOverlayActive()
49|
50| // Extract command actions from parsed bindings
51| const commandActions = useMemo(() => {
52| if (!keybindingContext) return new Set<string>()
53| const actions = new Set<string>()
54| for (const binding of keybindingContext.bindings) {
55| if (binding.action?.startsWith('command:')) {
56| actions.add(binding.action)
57| }
58| }
59| return actions
60| }, [keybindingContext])
61|
62| // Build handler map for all command actions
63| const handlers = useMemo(() => {
64| const map: Record<string, () => void> = {}
65| for (const action of commandActions) {
66| const commandName = action.slice('command:'.length)
67| map[action] = () => {
68| onSubmit(`/${commandName}`, NOOP_HELPERS, undefined, {
69| fromKeybinding: true,
70| })
71| }
72| }
73| return map
74| }, [commandActions, onSubmit])
75|
76| useKeybindings(handlers, {
77| context: 'Chat',
78| isActive: isActive && !isModalOverlayActive,
79| })
80|
81| return null
82| }
83|
源码引用: src/screens/REPL.tsx · 第 3170–3205 行(共 7050 行)
3170| // the closure captured at render time. Also doubles as refreshTools()
3171| // for mid-query tool list updates.
3172| const computeTools = () => {
3173| const state = store.getState()
3174| const assembled = assembleToolPool(
3175| state.toolPermissionContext,
3176| state.mcp.tools,
3177| )
3178| const merged = mergeAndFilterTools(
3179| combinedInitialTools,
3180| assembled,
3181| state.toolPermissionContext.mode,
3182| )
3183| if (!mainThreadAgentDefinition) return merged
3184| return resolveAgentTools(mainThreadAgentDefinition, merged, false, true)
3185| .resolvedTools
3186| }
3187|
3188| return {
3189| abortController,
3190| options: {
3191| commands,
3192| tools: computeTools(),
3193| debug,
3194| verbose: s.verbose,
3195| mainLoopModel,
3196| thinkingConfig:
3197| s.thinkingEnabled !== false ? thinkingConfig : { type: 'disabled' },
3198| // Merge fresh from store rather than closing over useMergedClients'
3199| // memoized output. initialMcpClients is a prop (session-constant).
3200| mcpClients: mergeClients(initialMcpClients, s.mcp.clients),
3201| mcpResources: s.mcp.resources,
3202| ideInstallationStatus: ideInstallationStatus,
3203| isNonInteractiveSession: false,
3204| dynamicMcpConfig,
3205| theme,
源码引用: src/screens/REPL.tsx · 第 3313–3353 行(共 7050 行)
3313| dynamicMcpConfig,
3314| theme,
3315| allowedAgentTypes,
3316| store,
3317| setAppState,
3318| reverify,
3319| addNotification,
3320| setMessages,
3321| onChangeDynamicMcpConfig,
3322| resume,
3323| requestPrompt,
3324| disabled,
3325| customSystemPrompt,
3326| appendSystemPrompt,
3327| setConversationId,
3328| ],
3329| )
3330|
3331| // Session backgrounding (Ctrl+B to background/foreground)
3332| const handleBackgroundQuery = useCallback(() => {
3333| // Stop the foreground query so the background one takes over
3334| abortController?.abort('background')
3335| // Aborting subagents may produce task-completed notifications.
3336| // Clear task notifications so the queue processor doesn't immediately
3337| // start a new foreground query; forward them to the background session.
3338| const removedNotifications = removeByFilter(
3339| cmd => cmd.mode === 'task-notification',
3340| )
3341|
3342| void (async () => {
3343| const toolUseContext = getToolUseContext(
3344| messagesRef.current,
3345| [],
3346| new AbortController(),
3347| mainLoopModel,
3348| )
3349|
3350| const [defaultSystemPrompt, userContext, systemContext] =
3351| await Promise.all([
3352| getSystemPrompt(
3353| toolUseContext.options.tools,
为什么选择动态扫描而非静态注册
command:* 采用“从已解析 bindings 动态扫描”而不是“代码里维护命令白名单”,核心收益是解耦:命令系统可独立演进,键位系统只认前缀协议。这样新增 slash 命令或插件命令时,不必同步改 keybindings 模块。代价是配置阶段无法完全静态校验命令名,unknown command 要到提交路径再报错。
这也是 command-bindings 与 app:* 的根本区别:app:* 是产品内建行为,追求可预测与强校验;command:* 是用户驱动入口,追求扩展性与低耦合。
源码引用: src/hooks/useCommandKeybindings.tsx · 第 50–83 行(共 83 行)
50| // Extract command actions from parsed bindings
51| const commandActions = useMemo(() => {
52| if (!keybindingContext) return new Set<string>()
53| const actions = new Set<string>()
54| for (const binding of keybindingContext.bindings) {
55| if (binding.action?.startsWith('command:')) {
56| actions.add(binding.action)
57| }
58| }
59| return actions
60| }, [keybindingContext])
61|
62| // Build handler map for all command actions
63| const handlers = useMemo(() => {
64| const map: Record<string, () => void> = {}
65| for (const action of commandActions) {
66| const commandName = action.slice('command:'.length)
67| map[action] = () => {
68| onSubmit(`/${commandName}`, NOOP_HELPERS, undefined, {
69| fromKeybinding: true,
70| })
71| }
72| }
73| return map
74| }, [commandActions, onSubmit])
75|
76| useKeybindings(handlers, {
77| context: 'Chat',
78| isActive: isActive && !isModalOverlayActive,
79| })
80|
81| return null
82| }
83|
源码引用: src/utils/handlePromptSubmit.ts · 第 100–160 行(共 611 行)
100| React.SetStateAction<Record<number, PastedContent>>
101| >
102| abortController?: AbortController | null
103| addNotification?: (notification: {
104| key: string
105| text: string
106| priority: 'low' | 'medium' | 'high' | 'immediate'
107| }) => void
108| setMessages?: (updater: (prev: Message[]) => Message[]) => void
109| streamMode?: SpinnerMode
110| hasInterruptibleToolInProgress?: boolean
111| uuid?: UUID
112| /**
113| * When true, input starting with `/` is treated as plain text.
114| * Used for remotely-received messages (bridge/CCR) that should not
115| * trigger local slash commands or skills.
116| */
117| skipSlashCommands?: boolean
118| }
119|
120| export async function handlePromptSubmit(
121| params: HandlePromptSubmitParams,
122| ): Promise<void> {
123| const {
124| helpers,
125| queryGuard,
126| isExternalLoading = false,
127| commands,
128| onInputChange,
129| setPastedContents,
130| setToolJSX,
131| getToolUseContext,
132| messages,
133| mainLoopModel,
134| ideSelection,
135| setUserInputOnProcessing,
136| setAbortController,
137| onQuery,
138| setAppState,
139| onBeforeQuery,
140| canUseTool,
141| queuedCommands,
142| uuid,
143| skipSlashCommands,
144| } = params
145|
146| const { setCursorOffset, clearBuffer, resetHistory } = helpers
147|
148| // Queue processor path: commands are pre-validated and ready to execute.
149| // Skip all input validation, reference parsing, and queuing logic.
150| if (queuedCommands?.length) {
151| startQueryProfile()
152| await executeUserInput({
153| queuedCommands,
154| messages,
155| mainLoopModel,
156| ideSelection,
157| querySource: params.querySource,
158| commands,
159| queryGuard,
160| setToolJSX,
源码引用: src/keybindings/defaultBindings.ts · 第 32–62 行(共 341 行)
32| export const DEFAULT_BINDINGS: KeybindingBlock[] = [
33| {
34| context: 'Global',
35| bindings: {
36| // ctrl+c and ctrl+d use special time-based double-press handling.
37| // They ARE defined here so the resolver can find them, but they
38| // CANNOT be rebound by users - validation in reservedShortcuts.ts
39| // will show an error if users try to override these keys.
40| 'ctrl+c': 'app:interrupt',
41| 'ctrl+d': 'app:exit',
42| 'ctrl+l': 'app:redraw',
43| 'ctrl+t': 'app:toggleTodos',
44| 'ctrl+o': 'app:toggleTranscript',
45| ...(feature('KAIROS') || feature('KAIROS_BRIEF')
46| ? { 'ctrl+shift+b': 'app:toggleBrief' as const }
47| : {}),
48| 'ctrl+shift+o': 'app:toggleTeammatePreview',
49| 'ctrl+r': 'history:search',
50| // File navigation. cmd+ bindings only fire on kitty-protocol terminals;
51| // ctrl+shift is the portable fallback.
52| ...(feature('QUICK_SEARCH')
53| ? {
54| 'ctrl+shift+f': 'app:globalSearch' as const,
55| 'cmd+shift+f': 'app:globalSearch' as const,
56| 'ctrl+shift+p': 'app:quickOpen' as const,
57| 'cmd+shift+p': 'app:quickOpen' as const,
58| }
59| : {}),
60| ...(feature('TERMINAL_PANEL') ? { 'meta+j': 'app:toggleTerminal' } : {}),
61| },
62| },
最小可用示例与回滚
实践上推荐先给 Chat context 增加一条低风险绑定(如 alt+m → command:memory),验证 modal 门控、草稿保留和命令可达后,再批量增加其它 command:*。若出现误触发,优先在 keybindings.json 删除对应 action 即可快速回滚,不需要改命令实现。
源码引用: src/hooks/useCommandKeybindings.tsx · 第 93–83 行(共 83 行)
源码引用: src/keybindings/template.ts · 第 1–40 行(共 53 行)
1| /**
2| * Keybindings template generator.
3| * Generates a well-documented template file for ~/.claude/keybindings.json
4| */
5|
6| import { jsonStringify } from '../utils/slowOperations.js'
7| import { DEFAULT_BINDINGS } from './defaultBindings.js'
8| import {
9| NON_REBINDABLE,
10| normalizeKeyForComparison,
11| } from './reservedShortcuts.js'
12| import type { KeybindingBlock } from './types.js'
13|
14| /**
15| * Filter out reserved shortcuts that cannot be rebound.
16| * These would cause /doctor to warn, so we exclude them from the template.
17| */
18| function filterReservedShortcuts(blocks: KeybindingBlock[]): KeybindingBlock[] {
19| const reservedKeys = new Set(
20| NON_REBINDABLE.map(r => normalizeKeyForComparison(r.key)),
21| )
22|
23| return blocks
24| .map(block => {
25| const filteredBindings: Record<string, string | null> = {}
26| for (const [key, action] of Object.entries(block.bindings)) {
27| if (!reservedKeys.has(normalizeKeyForComparison(key))) {
28| filteredBindings[key] = action
29| }
30| }
31| return { context: block.context, bindings: filteredBindings }
32| })
33| .filter(block => Object.keys(block.bindings).length > 0)
34| }
35|
36| /**
37| * Generate a template keybindings.json file content.
38| * Creates a fully valid JSON file with all default bindings that users can customize.
39| */
40| export function generateKeybindingsTemplate(): string {
本章小结与延伸
command-bindings 连接用户 JSON 配置与 slash 命令执行器。下一章 vim-bindings 说明为何不把所有编辑键迁入 keybindings。 继续学习: