本章总览
先记住定位:default-bindings 是产品默认键盘体验的单一真相源,定义“开箱可用动作 + context 边界 + 平台兼容策略”。在这张表之上,用户配置才做覆盖,校验层再通过 reserved 列表守住不可重绑按键。
学完本章你应该能
- 列举 Global 与 Chat context 的主要 action
- 解释 IMAGE_PASTE_KEY 与 MODE_CYCLE_KEY 平台分支
- 理解 ctrl+c/d 在 default 表存在但 reserved 不可改
- 知道 feature gate 如何展开 QUICK_SEARCH、VOICE_MODE 等块
- 区分 Settings 与 Select context 复用 select:* 动作
- 能用 useShortcutDisplay 反查 action 展示字符串
核心概念(先读懂这些)
Action 字符串是稳定 API
app:toggleTodos、chat:cycleMode 等字符串在 useGlobalKeybindings 与 defaultBindings 间契约化。新增 action 需同时:defaultBindings 条目、GlobalKeybindingHandlers 注册、schema 文档(若有)、Doctor 帮助表。
Context 隔离冲突键
enter 在 Chat 是 submit,在 Settings 是 close,在 Confirmation 是 yes——靠 context + activeContexts 隔离,而非不同物理键。Scroll context 处理 wheel/page 键;Transcript 用 q 退出(pager 惯例)。
Feature 条件 spread
MESSAGE_ACTIONS、QUICK_SEARCH、KAIROS、TERMINAL_PANEL、VOICE_MODE 等用 feature() 或 satisfies 展开 binding 对象。未编译进的 feature 在 bundle 中 DCE,对应 action 无 default 键但仍可被用户 JSON 绑定(若 customization 开启)。
建议学习步骤
- 阅读文件头 IMAGE_PASTE_KEY / MODE_CYCLE_KEY 逻辑
- 浏览 Global + Chat 两块 bindings
- 继续读 Transcript、HistorySearch、Scroll
- 查看 MESSAGE_ACTIONS 条件块
- 打开 reservedShortcuts 对照 ctrl+c
- 在 useGlobalKeybindings 映射 action 到 handler
常见误区
注意
Windows 无 VT 时 shift+tab 不可靠——用 meta+m
注意
voice:pushToTalk 绑定 space 时 unbound 陷阱见注释
注意
Settings 的 enter 保存关闭,space 才是 toggle
注意
Plugin context 仅 space/i,导航走 Select context
平台相关常量
IMAGE_PASTE_KEY:Windows 为 alt+v(ctrl+v 是系统粘贴),其它平台 ctrl+v。
SUPPORTS_TERMINAL_VT_MODE:Windows 上检测 Bun>=1.2.23 或 Node>=22.17/24.2+,否则 MODE_CYCLE_KEY=meta+m 代替 shift+tab。
注释引用 Windows Terminal VT issue 与 Node/Bun PR——mode cycle 是终端兼容性问题,不是 keybindings 解析 bug。
源码引用: src/keybindings/defaultBindings.ts · 第 12–30 行(共 341 行)
12| // Platform-specific image paste shortcut:
13| // - Windows: alt+v (ctrl+v is system paste)
14| // - Other platforms: ctrl+v
15| const IMAGE_PASTE_KEY = getPlatform() === 'windows' ? 'alt+v' : 'ctrl+v'
16|
17| // Modifier-only chords (like shift+tab) may fail on Windows Terminal without VT mode
18| // See: https://github.com/microsoft/terminal/issues/879#issuecomment-618801651
19| // Node enabled VT mode in 24.2.0 / 22.17.0: https://github.com/nodejs/node/pull/58358
20| // Bun enabled VT mode in 1.2.23: https://github.com/oven-sh/bun/pull/21161
21| const SUPPORTS_TERMINAL_VT_MODE =
22| getPlatform() !== 'windows' ||
23| (isRunningWithBun()
24| ? satisfies(process.versions.bun, '>=1.2.23')
25| : satisfies(process.versions.node, '>=22.17.0 <23.0.0 || >=24.2.0'))
26|
27| // Platform-specific mode cycle shortcut:
28| // - Windows without VT mode: meta+m (shift+tab doesn't work reliably)
29| // - Other platforms: shift+tab
30| const MODE_CYCLE_KEY = SUPPORTS_TERMINAL_VT_MODE ? 'shift+tab' : 'meta+m'
Global context
| 键 | Action | 行为 |
|---|---|---|
| ctrl+c | app:interrupt | 中断(reserved) |
| ctrl+d | app:exit | 退出(reserved) |
| ctrl+l | app:redraw | 重绘 |
| ctrl+t | app:toggleTodos | 任务面板 |
| ctrl+o | app:toggleTranscript | Transcript 模态 |
| ctrl+shift+o | app:toggleTeammatePreview | 队友预览 |
| ctrl+r | history:search | 历史搜索 |
| ctrl+shift+f/p | app:globalSearch/quickOpen | QUICK_SEARCH feature |
| meta+j | app:toggleTerminal | TERMINAL_PANEL |
KAIROS feature 追加 ctrl+shift+b → app:toggleBrief。
源码引用: 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
Chat 块覆盖 prompt 行编辑与提交:
- escape → chat:cancel
- ctrl+x ctrl+k → chat:killAgents(chord 避免 shadow readline)
- MODE_CYCLE_KEY → chat:cycleMode(权限模式循环)
- meta+p/o/t → modelPicker、fastMode、thinkingToggle
- enter → chat:submit;up/down → history
- ctrl+_ 与 ctrl+shift+- 双绑 chat:undo
- ctrl+x ctrl+e、ctrl+g → externalEditor
- ctrl+s → chat:stash
- IMAGE_PASTE_KEY → chat:imagePaste
- VOICE_MODE:space → voice:pushToTalk
MESSAGE_ACTIONS:shift+up → chat:messageActions。
源码引用: src/keybindings/defaultBindings.ts · 第 63–98 行(共 341 行)
63| {
64| context: 'Chat',
65| bindings: {
66| escape: 'chat:cancel',
67| // ctrl+x chord prefix avoids shadowing readline editing keys (ctrl+a/b/e/f/...).
68| 'ctrl+x ctrl+k': 'chat:killAgents',
69| [MODE_CYCLE_KEY]: 'chat:cycleMode',
70| 'meta+p': 'chat:modelPicker',
71| 'meta+o': 'chat:fastMode',
72| 'meta+t': 'chat:thinkingToggle',
73| enter: 'chat:submit',
74| up: 'history:previous',
75| down: 'history:next',
76| // Editing shortcuts (defined here, migration in progress)
77| // Undo has two bindings to support different terminal behaviors:
78| // - ctrl+_ for legacy terminals (send \x1f control char)
79| // - ctrl+shift+- for Kitty protocol (sends physical key with modifiers)
80| 'ctrl+_': 'chat:undo',
81| 'ctrl+shift+-': 'chat:undo',
82| // ctrl+x ctrl+e is the readline-native edit-and-execute-command binding.
83| 'ctrl+x ctrl+e': 'chat:externalEditor',
84| 'ctrl+g': 'chat:externalEditor',
85| 'ctrl+s': 'chat:stash',
86| // Image paste shortcut (platform-specific key defined above)
87| [IMAGE_PASTE_KEY]: 'chat:imagePaste',
88| ...(feature('MESSAGE_ACTIONS')
89| ? { 'shift+up': 'chat:messageActions' as const }
90| : {}),
91| // Voice activation (hold-to-talk). Registered so getShortcutDisplay
92| // finds it without hitting the fallback analytics log. To rebind,
93| // add a voice:pushToTalk entry (last wins); to disable, use /voice
94| // — null-unbinding space hits a pre-existing useKeybinding.ts trap
95| // where 'unbound' swallows the event (space dead for typing).
96| ...(feature('VOICE_MODE') ? { space: 'voice:pushToTalk' } : {}),
97| },
98| },
Settings、Confirmation、Autocomplete
Autocomplete:tab accept、dismiss escape、上下项。
Settings:escape → confirm:no;上下/k/j 复用 select:*;space toggle;enter settings:close;/ 搜索;r retry usage。
Confirmation:y/n、enter/escape、shift+tab cycleMode、ctrl+e 解释、ctrl+d permission debug。
这些 context 在对应 Dialog mount 时 registerActiveContext,关闭时 unregister。
源码引用: src/keybindings/defaultBindings.ts · 第 99–149 行(共 341 行)
99| {
100| context: 'Autocomplete',
101| bindings: {
102| tab: 'autocomplete:accept',
103| escape: 'autocomplete:dismiss',
104| up: 'autocomplete:previous',
105| down: 'autocomplete:next',
106| },
107| },
108| {
109| context: 'Settings',
110| bindings: {
111| // Settings menu uses escape only (not 'n') to dismiss
112| escape: 'confirm:no',
113| // Config panel list navigation (reuses Select actions)
114| up: 'select:previous',
115| down: 'select:next',
116| k: 'select:previous',
117| j: 'select:next',
118| 'ctrl+p': 'select:previous',
119| 'ctrl+n': 'select:next',
120| // Toggle/activate the selected setting (space only — enter saves & closes)
121| space: 'select:accept',
122| // Save and close the config panel
123| enter: 'settings:close',
124| // Enter search mode
125| '/': 'settings:search',
126| // Retry loading usage data (only active on error)
127| r: 'settings:retry',
128| },
129| },
130| {
131| context: 'Confirmation',
132| bindings: {
133| y: 'confirm:yes',
134| n: 'confirm:no',
135| enter: 'confirm:yes',
136| escape: 'confirm:no',
137| // Navigation for dialogs with lists
138| up: 'confirm:previous',
139| down: 'confirm:next',
140| tab: 'confirm:nextField',
141| space: 'confirm:toggle',
142| // Cycle modes (used in file permission dialogs and teams dialog)
143| 'shift+tab': 'confirm:cycleMode',
144| // Toggle permission explanation in permission dialogs
145| 'ctrl+e': 'confirm:toggleExplanation',
146| // Toggle permission debug info
147| 'ctrl+d': 'permission:toggleDebug',
148| },
149| },
Transcript、Scroll、Footer 等
Transcript:ctrl+e toggleShowAll、ctrl+c/escape/q exit。
HistorySearch:ctrl+r next、enter execute、escape accept。
Scroll:page/wheel、ctrl+home/end、selection:copy(kitty cmd+c)。
Footer:vim 式 j/k 与 enter openSelected。
MessageSelector / MessageActions / DiffDialog / ModelPicker / Plugin:各 dialog 专用 action 前缀,减少 Global 污染。
源码引用: src/keybindings/defaultBindings.ts · 第 160–220 行(共 341 行)
160| {
161| context: 'Transcript',
162| bindings: {
163| 'ctrl+e': 'transcript:toggleShowAll',
164| 'ctrl+c': 'transcript:exit',
165| escape: 'transcript:exit',
166| // q — pager convention (less, tmux copy-mode). Transcript is a modal
167| // reading view with no prompt, so q-as-literal-char has no owner.
168| q: 'transcript:exit',
169| },
170| },
171| {
172| context: 'HistorySearch',
173| bindings: {
174| 'ctrl+r': 'historySearch:next',
175| escape: 'historySearch:accept',
176| tab: 'historySearch:accept',
177| 'ctrl+c': 'historySearch:cancel',
178| enter: 'historySearch:execute',
179| },
180| },
181| {
182| context: 'Task',
183| bindings: {
184| // Background running foreground tasks (bash commands, agents)
185| // In tmux, users must press ctrl+b twice (tmux prefix escape)
186| 'ctrl+b': 'task:background',
187| },
188| },
189| {
190| context: 'ThemePicker',
191| bindings: {
192| 'ctrl+t': 'theme:toggleSyntaxHighlighting',
193| },
194| },
195| {
196| context: 'Scroll',
197| bindings: {
198| pageup: 'scroll:pageUp',
199| pagedown: 'scroll:pageDown',
200| wheelup: 'scroll:lineUp',
201| wheeldown: 'scroll:lineDown',
202| 'ctrl+home': 'scroll:top',
203| 'ctrl+end': 'scroll:bottom',
204| // Selection copy. ctrl+shift+c is standard terminal copy.
205| // cmd+c only fires on terminals using the kitty keyboard
206| // protocol (kitty/WezTerm/ghostty/iTerm2) where the super
207| // modifier actually reaches the pty — inert elsewhere.
208| // Esc-to-clear and contextual ctrl+c are handled via raw
209| // useInput so they can conditionally propagate.
210| 'ctrl+shift+c': 'selection:copy',
211| 'cmd+c': 'selection:copy',
212| },
213| },
214| {
215| context: 'Help',
216| bindings: {
217| escape: 'help:dismiss',
218| },
219| },
220| // Attachment navigation (select dialog image attachments)
源码引用: src/keybindings/defaultBindings.ts · 第 246–340 行(共 341 行)
246| // Message selector (rewind dialog) navigation
247| {
248| context: 'MessageSelector',
249| bindings: {
250| up: 'messageSelector:up',
251| down: 'messageSelector:down',
252| k: 'messageSelector:up',
253| j: 'messageSelector:down',
254| 'ctrl+p': 'messageSelector:up',
255| 'ctrl+n': 'messageSelector:down',
256| 'ctrl+up': 'messageSelector:top',
257| 'shift+up': 'messageSelector:top',
258| 'meta+up': 'messageSelector:top',
259| 'shift+k': 'messageSelector:top',
260| 'ctrl+down': 'messageSelector:bottom',
261| 'shift+down': 'messageSelector:bottom',
262| 'meta+down': 'messageSelector:bottom',
263| 'shift+j': 'messageSelector:bottom',
264| enter: 'messageSelector:select',
265| },
266| },
267| // PromptInput unmounts while cursor active — no key conflict.
268| ...(feature('MESSAGE_ACTIONS')
269| ? [
270| {
271| context: 'MessageActions' as const,
272| bindings: {
273| up: 'messageActions:prev' as const,
274| down: 'messageActions:next' as const,
275| k: 'messageActions:prev' as const,
276| j: 'messageActions:next' as const,
277| // meta = cmd on macOS; super for kitty keyboard-protocol — bind both.
278| 'meta+up': 'messageActions:top' as const,
279| 'meta+down': 'messageActions:bottom' as const,
280| 'super+up': 'messageActions:top' as const,
281| 'super+down': 'messageActions:bottom' as const,
282| // Mouse selection extends on shift+arrow (ScrollKeybindingHandler:573) when present —
283| // correct layered UX: esc clears selection, then shift+↑ jumps.
284| 'shift+up': 'messageActions:prevUser' as const,
285| 'shift+down': 'messageActions:nextUser' as const,
286| escape: 'messageActions:escape' as const,
287| 'ctrl+c': 'messageActions:ctrlc' as const,
288| // Mirror MESSAGE_ACTIONS. Not imported — would pull React/ink into this config module.
289| enter: 'messageActions:enter' as const,
290| c: 'messageActions:c' as const,
291| p: 'messageActions:p' as const,
292| },
293| },
294| ]
295| : []),
296| // Diff dialog navigation
297| {
298| context: 'DiffDialog',
299| bindings: {
300| escape: 'diff:dismiss',
301| left: 'diff:previousSource',
302| right: 'diff:nextSource',
303| up: 'diff:previousFile',
304| down: 'diff:nextFile',
305| enter: 'diff:viewDetails',
306| // Note: diff:back is handled by left arrow in detail mode
307| },
308| },
309| // Model picker effort cycling (ant-only)
310| {
311| context: 'ModelPicker',
312| bindings: {
313| left: 'modelPicker:decreaseEffort',
314| right: 'modelPicker:increaseEffort',
315| },
316| },
317| // Select component navigation (used by /model, /resume, permission prompts, etc.)
318| {
319| context: 'Select',
320| bindings: {
321| up: 'select:previous',
322| down: 'select:next',
323| j: 'select:next',
324| k: 'select:previous',
325| 'ctrl+n': 'select:next',
326| 'ctrl+p': 'select:previous',
327| enter: 'select:accept',
328| escape: 'select:cancel',
329| },
330| },
331| // Plugin dialog actions (manage, browse, discover plugins)
332| // Navigation (select:*) uses the Select context above
333| {
334| context: 'Plugin',
335| bindings: {
336| space: 'plugin:toggle',
337| i: 'plugin:install',
338| },
339| },
340| ]
reservedShortcuts 与校验
用户 keybindings.json 尝试绑定 reserved 键时,validateBindings 产出 error,KeybindingSetup 通过 notifications 提示 /doctor。
ctrl+c 在 Transcript context 映射 transcript:exit,在 Chat 仍可能被 Global interrupt 逻辑特殊处理——双重按压时间窗口在 useGlobalKeybindings 实现,不在 defaultBindings。
shortcutFormat.ts / useShortcutDisplay.ts 把 ParsedBinding 转为平台友好显示(Mac meta 符号等)。
源码引用: src/keybindings/reservedShortcuts.ts · 第 1–50 行(共 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
41| * - We use ctrl+s for the stash feature
42| */
43| export const TERMINAL_RESERVED: ReservedShortcut[] = [
44| {
45| key: 'ctrl+z',
46| reason: 'Unix process suspend (SIGTSTP)',
47| severity: 'warning',
48| },
49| {
50| key: 'ctrl+\\',
源码引用: src/keybindings/useShortcutDisplay.ts · 第 1–40 行(共 60 行)
1| import { useEffect, useRef } from 'react'
2| import {
3| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
4| logEvent,
5| } from '../services/analytics/index.js'
6| import { useOptionalKeybindingContext } from './KeybindingContext.js'
7| import type { KeybindingContextName } from './types.js'
8|
9| // TODO(keybindings-migration): Remove fallback parameter after migration is complete
10| // and we've confirmed no 'keybinding_fallback_used' events are being logged.
11| // The fallback exists as a safety net during migration - if bindings fail to load
12| // or an action isn't found, we fall back to hardcoded values. Once stable, callers
13| // should be able to trust that getBindingDisplayText always returns a value for
14| // known actions, and we can remove this defensive pattern.
15|
16| /**
17| * Hook to get the display text for a configured shortcut.
18| * Returns the configured binding or a fallback if unavailable.
19| *
20| * @param action - The action name (e.g., 'app:toggleTranscript')
21| * @param context - The keybinding context (e.g., 'Global')
22| * @param fallback - Fallback text if keybinding context unavailable
23| * @returns The configured shortcut display text
24| *
25| * @example
26| * const expandShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o')
27| * // Returns the user's configured binding, or 'ctrl+o' as default
28| */
29| export function useShortcutDisplay(
30| action: string,
31| context: KeybindingContextName,
32| fallback: string,
33| ): string {
34| const keybindingContext = useOptionalKeybindingContext()
35| const resolved = keybindingContext?.getDisplayText(action, context)
36| const isFallback = resolved === undefined
37| const reason = keybindingContext ? 'action_not_found' : 'no_context'
38|
39| // Log fallback usage once per mount (not on every render) to avoid
40| // flooding analytics with events from frequent re-renders.
与 useGlobalKeybindings 的映射
hooks/useGlobalKeybindings.tsx 为每个 app:* / chat:* / transcript:* 注册 useKeybinding handler,内部调 setAppState 或 REPL callbacks。
改 defaultBindings 而不注册 handler → 按键 resolve 成功但无效果;改 handler 而不更新 default → getDisplayText 回退 analytics log。
新增 Global 快捷键的标准 PR 顺序:defaultBindings → useGlobalKeybindings → 帮助文档 → schema template。
源码引用: src/hooks/useGlobalKeybindings.tsx · 第 1–80 行(共 265 行)
1| /**
2| * Component that registers global keybinding handlers.
3| *
4| * Must be rendered inside KeybindingSetup to have access to the keybinding context.
5| * This component renders nothing - it just registers the keybinding handlers.
6| */
7| import { feature } from 'bun:bundle'
8| import { useCallback } from 'react'
9| import instances from '../ink/instances.js'
10| import { useKeybinding } from '../keybindings/useKeybinding.js'
11| import type { Screen } from '../screens/REPL.js'
12| import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
13| import {
14| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
15| logEvent,
16| } from '../services/analytics/index.js'
17| import { useAppState, useSetAppState } from '../state/AppState.js'
18| import { count } from '../utils/array.js'
19| import { getTerminalPanel } from '../utils/terminalPanel.js'
20|
21| type Props = {
22| screen: Screen
23| setScreen: React.Dispatch<React.SetStateAction<Screen>>
24| showAllInTranscript: boolean
25| setShowAllInTranscript: React.Dispatch<React.SetStateAction<boolean>>
26| messageCount: number
27| onEnterTranscript?: () => void
28| onExitTranscript?: () => void
29| virtualScrollActive?: boolean
30| searchBarOpen?: boolean
31| }
32|
33| /**
34| * Registers global keybinding handlers for:
35| * - ctrl+t: Toggle todo list
36| * - ctrl+o: Toggle transcript mode
37| * - ctrl+e: Toggle showing all messages in transcript
38| * - ctrl+c/escape: Exit transcript mode
39| */
40| export function GlobalKeybindingHandlers({
41| screen,
42| setScreen,
43| showAllInTranscript,
44| setShowAllInTranscript,
45| messageCount,
46| onEnterTranscript,
47| onExitTranscript,
48| virtualScrollActive,
49| searchBarOpen = false,
50| }: Props): null {
51| const expandedView = useAppState(s => s.expandedView)
52| const setAppState = useSetAppState()
53|
54| // Toggle todo list (ctrl+t) - cycles through views
55| const handleToggleTodos = useCallback(() => {
56| logEvent('tengu_toggle_todos', {
57| is_expanded: expandedView === 'tasks',
58| })
59| setAppState(prev => {
60| const { getAllInProcessTeammateTasks } =
61| // eslint-disable-next-line @typescript-eslint/no-require-imports
62| require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js')
63| const hasTeammates =
64| count(
65| getAllInProcessTeammateTasks(prev.tasks),
66| t => t.status === 'running',
67| ) > 0
68|
69| if (hasTeammates) {
70| // Both exist: none → tasks → teammates → none
71| switch (prev.expandedView) {
72| case 'none':
73| return { ...prev, expandedView: 'tasks' as const }
74| case 'tasks':
75| return { ...prev, expandedView: 'teammates' as const }
76| case 'teammates':
77| return { ...prev, expandedView: 'none' as const }
78| }
79| }
80| // Only tasks: none ↔ tasks
默认表是产品行为,不只是示例
DEFAULT_BINDINGS 先于用户 keybindings.json 解析,用户块再覆盖,所以默认表既是开箱 UX,也是一份 action 名称清单。这里的键位不是随意示例:ctrl+c、ctrl+d 虽写在 Global 中,却被 reservedShortcuts 禁止重绑;Chat 的 ctrl+x chord 专门避开 readline 常用 ctrl+a/b/e/f;IMAGE_PASTE_KEY 与 MODE_CYCLE_KEY 把终端平台差异封装在文件头,避免每个 handler 自己判断 Windows、VT 模式或 Kitty 协议。
读每个 context 时要同时看“键位冲突”和“激活范围”。enter 在 Chat 是 submit,在 Settings 是 close,在 Confirmation 是 yes,这不是 resolver 的特殊规则,而是 context 隔离带来的结果。Transcript、HistorySearch、Scroll、Footer、Plugin 等 dialog 专用块让 Global 保持精简;feature gate 展开的 QUICK_SEARCH、VOICE_MODE、TERMINAL_PANEL 等条目说明 default 表还承担实验开关的落点。新增默认键位时不能只改这里,还要确认 action handler、显示文案、reserved 校验和用户模板是否同步,否则用户会看到快捷键提示但按下无效,或能绑定到一个没有消费方的 action。
默认表也记录了迁移中的历史包袱。Chat 里仍有 ctrl+_、ctrl+shift+-、ctrl+x ctrl+e、ctrl+g 等编辑相关动作,注释说明它们要兼容不同终端和 readline 习惯;voice:pushToTalk 的 space 绑定旁边写明 null-unbind 会触发 unbound 吞键陷阱。看到这些注释时,不应简单把键位整理成“更规整”的表,而要理解它们对应真实终端差异和旧用户习惯。默认键位的改动属于产品行为变更,需要比新增用户自定义示例更谨慎。
验证默认键位时,最好同时检查显示层。getDisplayText 依赖解析后的最后匹配项,如果默认键被 feature gate 移除或被用户覆盖,帮助文案和 footer 提示会随之变化。
源码引用: src/keybindings/defaultBindings.ts · 第 7–30 行(共 341 行)
7| /**
8| * Default keybindings that match current Claude Code behavior.
9| * These are loaded first, then user keybindings.json overrides them.
10| */
11|
12| // Platform-specific image paste shortcut:
13| // - Windows: alt+v (ctrl+v is system paste)
14| // - Other platforms: ctrl+v
15| const IMAGE_PASTE_KEY = getPlatform() === 'windows' ? 'alt+v' : 'ctrl+v'
16|
17| // Modifier-only chords (like shift+tab) may fail on Windows Terminal without VT mode
18| // See: https://github.com/microsoft/terminal/issues/879#issuecomment-618801651
19| // Node enabled VT mode in 24.2.0 / 22.17.0: https://github.com/nodejs/node/pull/58358
20| // Bun enabled VT mode in 1.2.23: https://github.com/oven-sh/bun/pull/21161
21| const SUPPORTS_TERMINAL_VT_MODE =
22| getPlatform() !== 'windows' ||
23| (isRunningWithBun()
24| ? satisfies(process.versions.bun, '>=1.2.23')
25| : satisfies(process.versions.node, '>=22.17.0 <23.0.0 || >=24.2.0'))
26|
27| // Platform-specific mode cycle shortcut:
28| // - Windows without VT mode: meta+m (shift+tab doesn't work reliably)
29| // - Other platforms: shift+tab
30| const MODE_CYCLE_KEY = SUPPORTS_TERMINAL_VT_MODE ? 'shift+tab' : 'meta+m'
源码引用: src/keybindings/defaultBindings.ts · 第 32–98 行(共 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| },
63| {
64| context: 'Chat',
65| bindings: {
66| escape: 'chat:cancel',
67| // ctrl+x chord prefix avoids shadowing readline editing keys (ctrl+a/b/e/f/...).
68| 'ctrl+x ctrl+k': 'chat:killAgents',
69| [MODE_CYCLE_KEY]: 'chat:cycleMode',
70| 'meta+p': 'chat:modelPicker',
71| 'meta+o': 'chat:fastMode',
72| 'meta+t': 'chat:thinkingToggle',
73| enter: 'chat:submit',
74| up: 'history:previous',
75| down: 'history:next',
76| // Editing shortcuts (defined here, migration in progress)
77| // Undo has two bindings to support different terminal behaviors:
78| // - ctrl+_ for legacy terminals (send \x1f control char)
79| // - ctrl+shift+- for Kitty protocol (sends physical key with modifiers)
80| 'ctrl+_': 'chat:undo',
81| 'ctrl+shift+-': 'chat:undo',
82| // ctrl+x ctrl+e is the readline-native edit-and-execute-command binding.
83| 'ctrl+x ctrl+e': 'chat:externalEditor',
84| 'ctrl+g': 'chat:externalEditor',
85| 'ctrl+s': 'chat:stash',
86| // Image paste shortcut (platform-specific key defined above)
87| [IMAGE_PASTE_KEY]: 'chat:imagePaste',
88| ...(feature('MESSAGE_ACTIONS')
89| ? { 'shift+up': 'chat:messageActions' as const }
90| : {}),
91| // Voice activation (hold-to-talk). Registered so getShortcutDisplay
92| // finds it without hitting the fallback analytics log. To rebind,
93| // add a voice:pushToTalk entry (last wins); to disable, use /voice
94| // — null-unbinding space hits a pre-existing useKeybinding.ts trap
95| // where 'unbound' swallows the event (space dead for typing).
96| ...(feature('VOICE_MODE') ? { space: 'voice:pushToTalk' } : {}),
97| },
98| },
源码引用: src/keybindings/template.ts · 第 40–44 行(共 53 行)
40| export function generateKeybindingsTemplate(): string {
41| // Filter out reserved shortcuts that cannot be rebound
42| const bindings = filterReservedShortcuts(DEFAULT_BINDINGS)
43|
44| // Format as object wrapper with bindings array
新增默认键位的变更清单
给 DEFAULT_BINDINGS 新增一条记录时,建议把它当产品行为变更处理,而不是配置补丁。最少需要检查四件事:是否已有同 context 冲突键;对应 action 是否有 handler;是否触碰 reservedShortcuts;快捷键展示与帮助文案是否同步。若键位挂在 feature gate 下,还要验证 feature 关闭时不会遗留无效展示文本。
这份清单的价值在于减少“表里有、运行时无”的假阳性。很多线上问题不是 resolver 失效,而是 action 没有注册消费方,或者门控条件导致默认键从未生效。
源码引用: src/keybindings/defaultBindings.ts · 第 32–149 行(共 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| },
63| {
64| context: 'Chat',
65| bindings: {
66| escape: 'chat:cancel',
67| // ctrl+x chord prefix avoids shadowing readline editing keys (ctrl+a/b/e/f/...).
68| 'ctrl+x ctrl+k': 'chat:killAgents',
69| [MODE_CYCLE_KEY]: 'chat:cycleMode',
70| 'meta+p': 'chat:modelPicker',
71| 'meta+o': 'chat:fastMode',
72| 'meta+t': 'chat:thinkingToggle',
73| enter: 'chat:submit',
74| up: 'history:previous',
75| down: 'history:next',
76| // Editing shortcuts (defined here, migration in progress)
77| // Undo has two bindings to support different terminal behaviors:
78| // - ctrl+_ for legacy terminals (send \x1f control char)
79| // - ctrl+shift+- for Kitty protocol (sends physical key with modifiers)
80| 'ctrl+_': 'chat:undo',
81| 'ctrl+shift+-': 'chat:undo',
82| // ctrl+x ctrl+e is the readline-native edit-and-execute-command binding.
83| 'ctrl+x ctrl+e': 'chat:externalEditor',
84| 'ctrl+g': 'chat:externalEditor',
85| 'ctrl+s': 'chat:stash',
86| // Image paste shortcut (platform-specific key defined above)
87| [IMAGE_PASTE_KEY]: 'chat:imagePaste',
88| ...(feature('MESSAGE_ACTIONS')
89| ? { 'shift+up': 'chat:messageActions' as const }
90| : {}),
91| // Voice activation (hold-to-talk). Registered so getShortcutDisplay
92| // finds it without hitting the fallback analytics log. To rebind,
93| // add a voice:pushToTalk entry (last wins); to disable, use /voice
94| // — null-unbinding space hits a pre-existing useKeybinding.ts trap
95| // where 'unbound' swallows the event (space dead for typing).
96| ...(feature('VOICE_MODE') ? { space: 'voice:pushToTalk' } : {}),
97| },
98| },
99| {
100| context: 'Autocomplete',
101| bindings: {
102| tab: 'autocomplete:accept',
103| escape: 'autocomplete:dismiss',
104| up: 'autocomplete:previous',
105| down: 'autocomplete:next',
106| },
107| },
108| {
109| context: 'Settings',
110| bindings: {
111| // Settings menu uses escape only (not 'n') to dismiss
112| escape: 'confirm:no',
113| // Config panel list navigation (reuses Select actions)
114| up: 'select:previous',
115| down: 'select:next',
116| k: 'select:previous',
117| j: 'select:next',
118| 'ctrl+p': 'select:previous',
119| 'ctrl+n': 'select:next',
120| // Toggle/activate the selected setting (space only — enter saves & closes)
121| space: 'select:accept',
122| // Save and close the config panel
123| enter: 'settings:close',
124| // Enter search mode
125| '/': 'settings:search',
126| // Retry loading usage data (only active on error)
127| r: 'settings:retry',
128| },
129| },
130| {
131| context: 'Confirmation',
132| bindings: {
133| y: 'confirm:yes',
134| n: 'confirm:no',
135| enter: 'confirm:yes',
136| escape: 'confirm:no',
137| // Navigation for dialogs with lists
138| up: 'confirm:previous',
139| down: 'confirm:next',
140| tab: 'confirm:nextField',
141| space: 'confirm:toggle',
142| // Cycle modes (used in file permission dialogs and teams dialog)
143| 'shift+tab': 'confirm:cycleMode',
144| // Toggle permission explanation in permission dialogs
145| 'ctrl+e': 'confirm:toggleExplanation',
146| // Toggle permission debug info
147| 'ctrl+d': 'permission:toggleDebug',
148| },
149| },
源码引用: src/hooks/useGlobalKeybindings.tsx · 第 1–80 行(共 265 行)
1| /**
2| * Component that registers global keybinding handlers.
3| *
4| * Must be rendered inside KeybindingSetup to have access to the keybinding context.
5| * This component renders nothing - it just registers the keybinding handlers.
6| */
7| import { feature } from 'bun:bundle'
8| import { useCallback } from 'react'
9| import instances from '../ink/instances.js'
10| import { useKeybinding } from '../keybindings/useKeybinding.js'
11| import type { Screen } from '../screens/REPL.js'
12| import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
13| import {
14| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
15| logEvent,
16| } from '../services/analytics/index.js'
17| import { useAppState, useSetAppState } from '../state/AppState.js'
18| import { count } from '../utils/array.js'
19| import { getTerminalPanel } from '../utils/terminalPanel.js'
20|
21| type Props = {
22| screen: Screen
23| setScreen: React.Dispatch<React.SetStateAction<Screen>>
24| showAllInTranscript: boolean
25| setShowAllInTranscript: React.Dispatch<React.SetStateAction<boolean>>
26| messageCount: number
27| onEnterTranscript?: () => void
28| onExitTranscript?: () => void
29| virtualScrollActive?: boolean
30| searchBarOpen?: boolean
31| }
32|
33| /**
34| * Registers global keybinding handlers for:
35| * - ctrl+t: Toggle todo list
36| * - ctrl+o: Toggle transcript mode
37| * - ctrl+e: Toggle showing all messages in transcript
38| * - ctrl+c/escape: Exit transcript mode
39| */
40| export function GlobalKeybindingHandlers({
41| screen,
42| setScreen,
43| showAllInTranscript,
44| setShowAllInTranscript,
45| messageCount,
46| onEnterTranscript,
47| onExitTranscript,
48| virtualScrollActive,
49| searchBarOpen = false,
50| }: Props): null {
51| const expandedView = useAppState(s => s.expandedView)
52| const setAppState = useSetAppState()
53|
54| // Toggle todo list (ctrl+t) - cycles through views
55| const handleToggleTodos = useCallback(() => {
56| logEvent('tengu_toggle_todos', {
57| is_expanded: expandedView === 'tasks',
58| })
59| setAppState(prev => {
60| const { getAllInProcessTeammateTasks } =
61| // eslint-disable-next-line @typescript-eslint/no-require-imports
62| require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js')
63| const hasTeammates =
64| count(
65| getAllInProcessTeammateTasks(prev.tasks),
66| t => t.status === 'running',
67| ) > 0
68|
69| if (hasTeammates) {
70| // Both exist: none → tasks → teammates → none
71| switch (prev.expandedView) {
72| case 'none':
73| return { ...prev, expandedView: 'tasks' as const }
74| case 'tasks':
75| return { ...prev, expandedView: 'teammates' as const }
76| case 'teammates':
77| return { ...prev, expandedView: 'none' as const }
78| }
79| }
80| // Only tasks: none ↔ tasks
源码引用: src/keybindings/reservedShortcuts.ts · 第 1–50 行(共 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
41| * - We use ctrl+s for the stash feature
42| */
43| export const TERMINAL_RESERVED: ReservedShortcut[] = [
44| {
45| key: 'ctrl+z',
46| reason: 'Unix process suspend (SIGTSTP)',
47| severity: 'warning',
48| },
49| {
50| key: 'ctrl+\\',
阅读顺序建议
首次阅读 defaultBindings.ts 时,建议先看文件头平台分支,再看 Global 与 Chat 两个高频 context,最后看各类 dialog context。这样能先建立“全局动作与输入动作分层”的大图,再进入细节键位。
源码引用: src/keybindings/defaultBindings.ts · 第 7–98 行(共 341 行)
7| /**
8| * Default keybindings that match current Claude Code behavior.
9| * These are loaded first, then user keybindings.json overrides them.
10| */
11|
12| // Platform-specific image paste shortcut:
13| // - Windows: alt+v (ctrl+v is system paste)
14| // - Other platforms: ctrl+v
15| const IMAGE_PASTE_KEY = getPlatform() === 'windows' ? 'alt+v' : 'ctrl+v'
16|
17| // Modifier-only chords (like shift+tab) may fail on Windows Terminal without VT mode
18| // See: https://github.com/microsoft/terminal/issues/879#issuecomment-618801651
19| // Node enabled VT mode in 24.2.0 / 22.17.0: https://github.com/nodejs/node/pull/58358
20| // Bun enabled VT mode in 1.2.23: https://github.com/oven-sh/bun/pull/21161
21| const SUPPORTS_TERMINAL_VT_MODE =
22| getPlatform() !== 'windows' ||
23| (isRunningWithBun()
24| ? satisfies(process.versions.bun, '>=1.2.23')
25| : satisfies(process.versions.node, '>=22.17.0 <23.0.0 || >=24.2.0'))
26|
27| // Platform-specific mode cycle shortcut:
28| // - Windows without VT mode: meta+m (shift+tab doesn't work reliably)
29| // - Other platforms: shift+tab
30| const MODE_CYCLE_KEY = SUPPORTS_TERMINAL_VT_MODE ? 'shift+tab' : 'meta+m'
31|
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| },
63| {
64| context: 'Chat',
65| bindings: {
66| escape: 'chat:cancel',
67| // ctrl+x chord prefix avoids shadowing readline editing keys (ctrl+a/b/e/f/...).
68| 'ctrl+x ctrl+k': 'chat:killAgents',
69| [MODE_CYCLE_KEY]: 'chat:cycleMode',
70| 'meta+p': 'chat:modelPicker',
71| 'meta+o': 'chat:fastMode',
72| 'meta+t': 'chat:thinkingToggle',
73| enter: 'chat:submit',
74| up: 'history:previous',
75| down: 'history:next',
76| // Editing shortcuts (defined here, migration in progress)
77| // Undo has two bindings to support different terminal behaviors:
78| // - ctrl+_ for legacy terminals (send \x1f control char)
79| // - ctrl+shift+- for Kitty protocol (sends physical key with modifiers)
80| 'ctrl+_': 'chat:undo',
81| 'ctrl+shift+-': 'chat:undo',
82| // ctrl+x ctrl+e is the readline-native edit-and-execute-command binding.
83| 'ctrl+x ctrl+e': 'chat:externalEditor',
84| 'ctrl+g': 'chat:externalEditor',
85| 'ctrl+s': 'chat:stash',
86| // Image paste shortcut (platform-specific key defined above)
87| [IMAGE_PASTE_KEY]: 'chat:imagePaste',
88| ...(feature('MESSAGE_ACTIONS')
89| ? { 'shift+up': 'chat:messageActions' as const }
90| : {}),
91| // Voice activation (hold-to-talk). Registered so getShortcutDisplay
92| // finds it without hitting the fallback analytics log. To rebind,
93| // add a voice:pushToTalk entry (last wins); to disable, use /voice
94| // — null-unbinding space hits a pre-existing useKeybinding.ts trap
95| // where 'unbound' swallows the event (space dead for typing).
96| ...(feature('VOICE_MODE') ? { space: 'voice:pushToTalk' } : {}),
97| },
98| },
本章小结与延伸
default-bindings 是产品默认 UX 的单一真相源。下一章 command-bindings 读 command:* 动态层。 继续学习: