跳转至

Topic | 键位系统(Keybinding System)全拆

重要性:⭐⭐⭐⭐(TUI 交互核心) 出现位置src/keybindings/src/hooks/useGlobalKeybindings.tsxsrc/hooks/useCommandKeybindings.tsx 关联phase-02-repl.md 的键位层phase-04-components.md 的 Dialog isCancelActive


1. 为什么 TUI 需要自研键位系统

Web 项目有 mousetrap / react-hotkeys / react-shortcuts 等成熟库。
Claude Code 不用这些库,因为 TUI 键位有 3 个 Web 没有的特殊需求:

需求 Web 库能解决? TUI 特殊点
chord 组合(先 Ctrl+K 再 Ctrl+S) 部分支持 TUI 用户期望 vim/emacs 风格
context-aware(输入框里 Esc 是删字符,列表里 Esc 是取消) 通过 scope 支持 嵌套组件多,scope 复杂
可发现性(快捷键能查到提示文案) 部分支持 TUI 没有 hover 提示
跨平台(macOS ⌘C vs Linux ^C 部分支持 TUI 用户对平台差异敏感

结论:自研。

2. 目录结构

src/keybindings/
├── KeybindingProviderSetup.tsx       全局 Provider
├── useShortcutDisplay.ts             快捷键显示 hook
├── shortcutFormat.ts                 跨平台格式化
├── useKeybinding.ts                  单个 keybinding hook(推测)
├── useKeybindings.ts                 多个 keybindings hook
└── <其他辅助>

加上 hooks/ 目录的 3 个相关 hook: - src/hooks/useGlobalKeybindings.tsx - src/hooks/useCommandKeybindings.tsx - src/hooks/useCancelRequest.ts

3. 三层架构

┌────────────────────────────────────────────────────────┐
│  Layer 3: Ink useInput (兜底)                          │
│  src/ink/hooks/use-input.ts                            │
│  处理单字符快捷键(Esc、g、G、j、k)                  │
└────────────────────────────────────────────────────────┘
                          ↓ 透传
┌────────────────────────────────────────────────────────┐
│  Layer 2: Command Keybindings (命令模式快捷键)         │
│  src/hooks/useCommandKeybindings.tsx                   │
│  仅在用户输入 `/` 后激活                                │
└────────────────────────────────────────────────────────┘
                          ↓ 透传
┌────────────────────────────────────────────────────────┐
│  Layer 1: Global Keybindings (全局快捷键)              │
│  src/hooks/useGlobalKeybindings.tsx                    │
│  Ctrl+C 取消、Ctrl+L 清屏、Ctrl+O 全屏                 │
└────────────────────────────────────────────────────────┘
                          ↓ 透传
┌────────────────────────────────────────────────────────┐
│  Layer 0: Component 内部 useInput (组件级)             │
│  PromptInput / FuzzyPicker / Tabs                       │
│  比如 Esc 在输入框删字符、在 List 是取消                │
└────────────────────────────────────────────────────────┘

关键:每一层都可以消费事件(不传给下层)或透传(让下层处理)。

4. KeybindingProvider

// src/keybindings/KeybindingProviderSetup.tsx 推测
<KeybindingSetup>
  <GlobalKeybindingHandlers />
  <CommandKeybindingHandlers />
  <CancelRequestHandler />
  <BackgroundTaskNavigation />
  {children}
</KeybindingSetup>

职责: - 把所有快捷键注册到全局(React Context 树) - 提供 useKeybinding() / useKeybindings() 钩子让组件订阅 - 维护"已注册的快捷键列表"(用于可发现性提示)

5. useGlobalKeybindings.tsx

推测结构(基于 import 和 use 模式)

export function GlobalKeybindingHandlers() {
  // 1. 注册 Ctrl+C → 取消当前请求
  useKeybinding({
    keys: 'ctrl+c',
    description: 'Cancel current request',
    handler: useCallback(() => {
      cancelCurrentRequest()
    }, []),
  })

  // 2. 注册 Ctrl+D → 退出
  useKeybinding({
    keys: 'ctrl+d',
    description: 'Exit Claude Code',
    handler: useCallback(() => {
      if (isInputEmpty()) {
        exit()
      } else {
        // 转发到输入框作为 delete-forward
      }
    }, []),
  })

  // 3. 注册 Ctrl+L → 清屏
  useKeybinding({
    keys: 'ctrl+l',
    description: 'Clear screen',
    handler: clearScreen,
  })

  // 4. 注册 Ctrl+O → 全屏切换
  useKeybinding({
    keys: 'ctrl+o',
    description: 'Toggle fullscreen',
    handler: toggleFullscreen,
  })

  // 5. 注册 Ctrl+R → 反向搜索历史
  useKeybinding({
    keys: 'ctrl+r',
    description: 'Reverse search history',
    handler: openHistorySearch,
  })

  return null  // 不渲染任何东西
}

关键观察: - 返回 null —— Provider 不渲染 UI - useCallback 包裹 handler —— 避免每次重渲染重新注册 - 每个 binding 独立 useKeybinding —— 不是数组循环,方便 HMR 单个修改

6. chord 组合实现

// 推测的 chord 实现
useKeybinding({
  keys: ['ctrl+k', 'ctrl+s'],  // 先 Ctrl+K 再 Ctrl+S
  description: 'Save transcript',
  handler: saveTranscript,
})

实现机制(推测): 1. 维护一个 "chord buffer"(最近 1.5 秒内的按键) 2. 收到 Ctrl+K → 标记等待下一个 3. 1.5 秒内收到 Ctrl/S → 触发 4. 超时或收到其他键 → 重置

前端类比:和 VSCode 的 keybindings.json chord 模式一样。

7. context-aware 键位

7.1 Dialog 的 isCancelActive

// src/components/design-system/Dialog.tsx:14
type DialogProps = {
  isCancelActive?: boolean  // 默认 true
  // ...
}

// 在 Dialog 内部
const isCancelActive = props.isCancelActive ?? true
if (isCancelActive) {
  useKeybinding({
    keys: 'escape',
    handler: props.onCancel,
  })
  useKeybinding({
    keys: 'ctrl+c',
    handler: props.onCancel,
  })
}

场景: - Dialog 包含一个 <TextInput> 嵌入输入 - 用户在 TextInput 里按 Esc 想删字符 - Dialog 不应该拦截 Esc

解决: - 当 TextInput 聚焦时,Dialog 设 isCancelActive={false} - TextInput 自己的 useInput 接收 Esc 删字符 - 用户离开 TextInput(按 Tab)→ Dialog 重新激活 Esc 取消

前端类比:和 Web 的 e.stopPropagation() 等价,但显式声明而不是命令式。

7.2 PromptInput 的 vim/normal 模式

// src/components/PromptInput/PromptInput.tsx 推测
function PromptInput({ mode, onChange }) {
  if (mode === 'vim') {
    return <VimInput onChange={onChange} />
  }
  return <NormalInput onChange={onChange} />
}

vim 模式src/vim/)独立实现,键位系统全替换: - normal 模式:h/j/k/l 移动、d 删除、y 复制 - insert 模式:i 进入、Esc 退出 - visual 模式:v 进入、选择文本

8. 跨平台格式化

// src/keybindings/shortcutFormat.ts 推测
export function getShortcutDisplay(
  keys: string,
  platform: 'darwin' | 'linux' | 'win32'
): string {
  if (platform === 'darwin') {
    return keys
      .replace('ctrl', '⌘')
      .replace('alt', '⌥')
      .replace('shift', '⇧')
  }
  return keys  // Linux/Windows 用 'Ctrl+C' 文字
}

实际显示: - macOS:⌘C ⇧⌘P - Linux/Windows:Ctrl+C Shift+Ctrl+P

// src/keybindings/useShortcutDisplay.ts
const platform = usePlatform()  // 'darwin' | 'linux' | 'win32'
const display = useCallback(
  (keys: string) => getShortcutDisplay(keys, platform),
  [platform]
)

9. 关键洞察

9.1 三层架构 vs React Router scope

Web 的 react-router 也有"scope"概念(嵌套路由)。Claude Code 的三层和它同形: - Global ≈ 最外层路由 - Command ≈ 当前命令的子路由 - Component ≈ 路由内的具体组件

9.2 Provider pattern 让"全局快捷键"测试友好

useKeybinding 注册到 React Context,测试时可以包不同 Provider 模拟不同键位环境

9.3 chord buffer 的状态机化

chord 实现本质是两状态有限状态机(idle → waiting-first → idle / waiting-second → triggered)。 观察 useKeybinding 的实现是否用 useState/useReducer 维护 buffer 是个学习点。

9.4 "可发现性" 是 TUI 的核心 UX

TUI 没有 hover,用户必须能查到所有快捷键useShortcutDisplay + KeyboardShortcutHint 组件的组合实现了"任何 dialog 都展示可用快捷键"。

10. 阅读清单

  1. src/keybindings/KeybindingProviderSetup.tsx(通读)
  2. src/hooks/useGlobalKeybindings.tsx(通读)
  3. src/keybindings/shortcutFormat.ts(通读,跨平台逻辑)
  4. src/components/design-system/Dialog.tsx:14-30isCancelActive 的注释)
  5. 📌 src/hooks/useCommandKeybindings.tsx(命令模式)
  6. 📌 src/hooks/useCancelRequest.ts(Ctrl+C 取消流程)
  7. 📌 src/keybindings/useKeybinding.ts(单 binding 注册的实现)

11. 练习任务

  1. 列出所有全局快捷键 —— 跑 grep -rnE "useKeybinding\(" src/,分类
  2. 手写一个 chord binding —— 实现"先 Ctrl+K 再 Ctrl+S 保存"的 React hook
  3. 写 macOS / Linux 兼容测试 —— 给 shortcutFormat 加测试
  4. 思考:如果让你把 TUI 的键位系统移植到 Web(用 mousetrap 替换底层),哪些 API 会变?哪些不变?