Claude Code 源码分析Claude Code 源码分析
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
  • 概览

    • Claude Code 源码分析
    • 源码统计
    • CodeGraph 图谱
  • 架构

    • 系统架构
    • UML 图表索引
    • 查询引擎
    • 核心流程
    • 消息系统
    • 状态管理
  • 功能模块

    • 工具系统
    • 斜杠命令
    • 服务层
    • MCP 协议
    • Skills 技能
    • 子代理系统
  • 分层深度

    • 入口层
    • UI / Ink 层
    • utils 基础设施
    • 桥接 / 远程
    • 上下文压缩
  • 原理与安全

    • 底层原理
    • 技术难点
    • 权限与安全
    • 内部机制
    • 遥测与分析
  • 深度专题

    • Hooks 系统
    • 插件系统
    • 记忆系统
    • API 通信层
    • Ink 终端 UI
    • 认证系统
    • 构建与发布
    • 术语表
  • 调用分析

    • 调用链分析
    • 核心文件索引
  • 模块详解

    • utils

      • 模块: utils
      • messages · 消息工厂与规范化
      • session-storage · JSONL 会话持久化
      • permissions · 工具权限决策
      • shell-hooks · 用户 Shell Hook 系统
    • components

      • 模块: components
      • REPL · 主屏编排
      • messages · 消息行渲染
      • PermissionRequest · 权限弹窗
      • PromptInput · 底部输入
    • services

      • 模块: services
      • api-claude · Anthropic API 流式与重试
      • mcp-client · MCP 连接与工具调用
      • compact · 上下文压缩与自动触发
      • analytics · GrowthBook、Datadog 与 1P 事件
    • tools

      • 模块: tools
      • tool-interface · Tool 契约与注册表
      • bash-tool · Shell 执行与权限
      • streaming-executor · 流式工具并发调度
      • agent-tool · 子 Agent 委派
    • commands

      • 模块: commands
      • command-registry · commands.ts 注册与分派
      • model-command · /model 模型选择
      • mcp-commands · /mcp 服务器管理
      • compact-memory-commands · /compact 与 /memory
    • ink

      • 模块: ink
      • Ink 渲染管线 · Screen 与终端输出
      • 终端事件 · resize、paste、stdin
      • Ink Hooks · 输入、搜索、终端状态
      • Ink 组件 · Box、Text、ScrollBox 原语
    • hooks

      • 模块: hooks
      • useCanUseTool · 权限 UI 接缝
      • 输入与快捷键 Hook
      • 合并态 Hook(MCP + 本地)
      • notifs 通知 Hook
    • bridge

      • 模块: bridge
      • repl-bridge · REPL 桥初始化与传输
      • bridge-messaging · 桥消息路由与入站处理
      • remote-bridge-core · env-less 核心与守护主循环
      • bridge-permissions-ui · 权限、API 与 TUI
    • cli

      • 模块: cli
      • Structured IO · NDJSON SDK 协议
      • CLI Transports · Session Ingress 传输层
      • CLI Handlers · 子命令懒加载实现
      • Update & Upload · 自更新与串行上传原语
    • screens

      • 模块: screens
      • REPL 屏 · Screen 类型与顶层路由
      • ResumeConversation · 会话恢复选择器
      • Doctor · 安装诊断全屏
    • entrypoints

      • 模块: entrypoints
      • cli-entrypoint · Bootstrap 与快路径
      • sdk-types · core / control / runtime 类型体系
      • mcp-entrypoint · MCP stdio 服务器
      • sandbox-types · 沙箱配置单一真相源
    • skills

      • 模块: skills
      • skills-loading · 磁盘加载与 bundled 注册表
      • bundled-skills · 内置 skill 与 initBundledSkills
      • mcp-skills · MCP prompt 转 skill
      • skill-tool-integration · SkillTool 与命令注册
    • types

      • 模块: types
      • message-types · Message 联合与 content blocks
      • tool-permission-types · Tool、Permission、Command 类型
      • api-sdk-types · API 与 Hooks 协议类型
      • misc-types · ids、plugin、generated 与其余类型
    • tasks

      • 模块: tasks
      • local-agent-task · 本地 Agent 与主会话后台化
      • remote-agent-task · 远程 CCR 与 In-Process Teammate
      • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
      • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybindings

      • 模块: keybindings
      • keybinding-registry · 注册、Provider 与 useKeybinding
      • default-bindings · 默认键位表与平台差异
      • command-bindings · command:* 动态斜杠命令绑定
      • vim-bindings · Vim 模式与 keybindings 边界
    • memdir

      • 模块: memdir
      • memdir-core · 路径、加载与 MEMORY.md
      • memory-extraction · extractMemories 与 SessionMemory
      • memdir-commands · /memory、/remember 与命令集成
    • state

      • 模块: state
      • app-state-core · store、AppState 类型与 Provider
      • app-state-selectors · selectors 与 onChangeAppState
      • teammate-state · 队友视图与 swarm 状态
      • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query

      • 模块: query
      • query config 与 deps · 配置快照与依赖注入
      • query tokenBudget · +500k 自动续跑
      • query transitions · Continue / Terminal 状态机
      • query stopHooks · Stop 事件与 turn 结束编排
  • 模块详解(扩展)

    • messages · 消息工厂与规范化
    • session-storage · JSONL 会话持久化
    • permissions · 工具权限决策
    • shell-hooks · 用户 Shell Hook 系统
    • REPL · 主屏编排
    • messages · 消息行渲染
    • PermissionRequest · 权限弹窗
    • PromptInput · 底部输入
    • api-claude · Anthropic API 流式与重试
    • mcp-client · MCP 连接与工具调用
    • compact · 上下文压缩与自动触发
    • analytics · GrowthBook、Datadog 与 1P 事件
    • tool-interface · Tool 契约与注册表
    • bash-tool · Shell 执行与权限
    • streaming-executor · 流式工具并发调度
    • agent-tool · 子 Agent 委派
    • command-registry · commands.ts 注册与分派
    • model-command · /model 模型选择
    • mcp-commands · /mcp 服务器管理
    • compact-memory-commands · /compact 与 /memory
    • Ink 渲染管线 · Screen 与终端输出
    • 终端事件 · resize、paste、stdin
    • Ink Hooks · 输入、搜索、终端状态
    • Ink 组件 · Box、Text、ScrollBox 原语
    • useCanUseTool · 权限 UI 接缝
    • 输入与快捷键 Hook
    • 合并态 Hook(MCP + 本地)
    • notifs 通知 Hook
    • repl-bridge · REPL 桥初始化与传输
    • bridge-messaging · 桥消息路由与入站处理
    • remote-bridge-core · env-less 核心与守护主循环
    • bridge-permissions-ui · 权限、API 与 TUI
    • Structured IO · NDJSON SDK 协议
    • CLI Transports · Session Ingress 传输层
    • CLI Handlers · 子命令懒加载实现
    • Update & Upload · 自更新与串行上传原语
    • REPL 屏 · Screen 类型与顶层路由
    • ResumeConversation · 会话恢复选择器
    • Doctor · 安装诊断全屏
    • cli-entrypoint · Bootstrap 与快路径
    • sdk-types · core / control / runtime 类型体系
    • mcp-entrypoint · MCP stdio 服务器
    • sandbox-types · 沙箱配置单一真相源
    • skills-loading · 磁盘加载与 bundled 注册表
    • bundled-skills · 内置 skill 与 initBundledSkills
    • mcp-skills · MCP prompt 转 skill
    • skill-tool-integration · SkillTool 与命令注册
    • message-types · Message 联合与 content blocks
    • tool-permission-types · Tool、Permission、Command 类型
    • api-sdk-types · API 与 Hooks 协议类型
    • misc-types · ids、plugin、generated 与其余类型
    • local-agent-task · 本地 Agent 与主会话后台化
    • remote-agent-task · 远程 CCR 与 In-Process Teammate
    • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
    • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybinding-registry · 注册、Provider 与 useKeybinding
    • default-bindings · 默认键位表与平台差异
    • command-bindings · command:* 动态斜杠命令绑定
    • vim-bindings · Vim 模式与 keybindings 边界
    • memdir-core · 路径、加载与 MEMORY.md
    • memory-extraction · extractMemories 与 SessionMemory
    • memdir-commands · /memory、/remember 与命令集成
    • app-state-core · store、AppState 类型与 Provider
    • app-state-selectors · selectors 与 onChangeAppState
    • teammate-state · 队友视图与 swarm 状态
    • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query config 与 deps · 配置快照与依赖注入
    • query tokenBudget · +500k 自动续跑
    • query transitions · Continue / Terminal 状态机
    • query stopHooks · Stop 事件与 turn 结束编排
  • 工具详解

    • tool-interface · Tool 契约与注册表
    • tool-permission-types · Tool、Permission、Command 类型
    • 工具: Bash
    • 工具: PowerShell
    • 工具: Agent
    • 工具: LSP
    • 工具: FileEdit
    • 工具: FileRead
    • 工具: Skill
    • 工具: WebFetch
    • 工具: MCP
    • 工具: SendMessage
    • 工具: FileWrite
    • 工具: Config
    • 工具: Grep
    • 工具: Brief
    • 工具: ExitPlanMode
    • 工具: ToolSearch
    • 工具: NotebookEdit
    • 工具: TaskOutput
    • 工具: WebSearch
    • 工具: ScheduleCron

本章总览

Claude Code 终端 REPL 的键盘体验由 hooks/ 下四条输入/快捷键链路共同构成:useSearchInput 提供类 less/vim 的搜索框编辑;useGlobalKeybindings 注册全局视图切换;useCommandKeybindings 把用户 keybinding 配置里的 command:* 动作映射为斜杠命令;useVimInput 在 VimTextInput 上叠加 NORMAL/INSERT 模式与 dot-repeat。它们都运行在 Ink 事件模型之上,但职责边界清晰——改 transcript 搜索不应动 prompt 的 vim 状态,改 ctrl+t 不应改 slash 命令绑定。

学完本章你应该能

  • 解释 useSearchInput 的 onExit / onCancel 语义与 backspaceExitsOnEmpty 选项
  • 说明 useInput 向后兼容桥接与 onKeyDown 迁移计划
  • 列举 GlobalKeybindingHandlers 注册的 app:* 与 transcript:* 动作
  • 理解 CommandKeybindingHandlers 如何从 KeybindingContext 动态发现 command:*
  • 画出 useVimInput 与 useTextInput 的组合关系及 Esc 为何不迁入 keybindings 系统
  • 能在 REPL.tsx 中定位四条 Hook 的挂载点与 isActive 门控

核心概念(先读懂这些)

两层快捷键:声明式 vs 输入内联

声明式层(useGlobalKeybindings / useCommandKeybindings)通过 useKeybinding / useKeybindings 向 KeybindingSetup 注册,由 keybindings 模块解析 chord、context、isActive。内联层(useSearchInput / useVimInput)在 handleKeyDown / handleVimInput 里直接读 KeyboardEvent,适合需要 readline 语义(kill ring、yank-pop、vim operator)的场景。混用时注意事件冒泡:transcript 搜索栏打开时 GlobalKeybindingHandlers 会 gate transcript:exit,避免 Esc 同时触发 onCancel 与退出 transcript。

Cursor 工具类与 kill ring

useSearchInput 不自己实现字符插入,而是每次按键用 Cursor.fromText(query, columns, offset) 构造不可变 cursor,再 backspace/insert/deleteWordBefore 等。kill ring 状态在 utils/Cursor.js 模块级维护,与 Emacs readline 行为对齐。非 kill 键会 resetKillAccumulation,非 yank 键 resetYankState——这是多步 kill 与 yank-pop 正确性的前提。

Vim 输入不配置 Esc

useVimInput 注释明确 NOTE(keybindings):INSERT 模式 Esc 切 NORMAL 是 vim 内置行为,故意不迁入可配置 keybindings,以免 vim 用户期望被破坏。NORMAL 模式 Esc 则取消 pending operator。Ctrl 组合键一律 delegate 给底层 useTextInput,保证 ctrl+c 等仍走 REPL 全局逻辑。

建议学习步骤

  1. 阅读 useSearchInput 选项类型与 UNHANDLED_SPECIAL_KEYS 设计
  2. 对照 handleKeyDown 分支:退出、backspace、ctrl/meta、普通字符
  3. 打开 useGlobalKeybindings,梳理 useKeybinding 注册表
  4. 阅读 useCommandKeybindings 的 commandActions 扫描与 NOOP_HELPERS
  5. 在 useVimInput 中跟踪 handleVimInput 的模式分支与 transition
  6. 在 REPL.tsx 搜索 GlobalKeybindingHandlers / useSearchInput 调用

常见误区

注意

useSearchInput 底部仍订阅 useInput——未迁移的 11 个 call site 依赖此桥接,勿删

注意

CommandKeybindingHandlers 在 modal overlay 或 local JSX 命令激活时 isActive 为 false

注意

useVimInput 的 inputFilter 在 NORMAL 模式只 disarm 状态、不应用变换后的 input

注意

不要把 utils/hooks.ts 的 Shell Hook 与 hooks/ 目录 React Hook 混淆

在 REPL 架构中的位置

键盘事件在 Claude Code 中的流向可概括为:

终端 raw input → Ink KeyboardEvent
  ├─ KeybindingSetup(context: Global / Chat / Transcript)
  │    ├─ GlobalKeybindingHandlers(ctrl+t/o/e、meta+j…)
  │    └─ CommandKeybindingHandlers(command:commit → /commit)
  ├─ PromptInput / VimTextInput(useVimInput → useTextInput)
  └─ Transcript 搜索 overlay(useSearchInput.handleKeyDown)

REPL.tsx 在 <KeybindingSetup> 内渲染 Global 与 Command 两个零 UI 组件;PromptInput 与 TranscriptSearchBar 各自消费输入 Hook。调试「按键无反应」时,先查 isActive 与 context,再查 overlay 是否吞事件。

useSearchInput:选项契约与 less/vim 语义

useSearchInput 服务于 transcript 内 / 搜索、Settings 列表过滤等「单行查询」场景。核心选项:

选项含义
onExitEnter / Down 提交搜索
onCancelEsc / Ctrl+G(若提供)放弃,区别于 commit
onExitUpUp 键可选回调
backspaceExitsOnEmpty空串时 Backspace 是否退出(less 删过 / 即退出)
passthroughCtrlKeys允许透传特定 ctrl 组合给外层

设计要点: 若提供 onCancel,单次 Esc 直接 cancel,不再「先清空再 Esc 退出」;未提供 onCancel 时 Esc 先清 query,空串再 onExit。Ctrl+C 在无 onCancel 时静默忽略——多数 call site 期望不干扰 REPL 中断逻辑。

返回值 { query, setQuery, cursorOffset, handleKeyDown } 供组件绑定;setQuery 会同步把 cursor 移到末尾。

源码引用: src/hooks/useSearchInput.ts · 第 17–40 行(共 365 行)

  17| type UseSearchInputOptions = {
  18|   isActive: boolean
  19|   onExit: () => void
  20|   /** Esc + Ctrl+C abandon (distinct from onExit = Enter commit). When
  21|    *  provided: single-Esc calls this directly (no clear-first-then-exit
  22|    *  two-press). When absent: current behavior — Esc clears non-empty
  23|    *  query, exits on empty; Ctrl+C silently swallowed (no switch case). */
  24|   onCancel?: () => void
  25|   onExitUp?: () => void
  26|   columns?: number
  27|   passthroughCtrlKeys?: string[]
  28|   initialQuery?: string
  29|   /** Backspace (and ctrl+h) on empty query calls onCancel ?? onExit — the
  30|    *  less/vim "delete past the /" convention. Dialogs that want Esc-only
  31|    *  cancel set this false so a held backspace doesn't eject the user. */
  32|   backspaceExitsOnEmpty?: boolean
  33| }
  34| 
  35| type UseSearchInputReturn = {
  36|   query: string
  37|   setQuery: (q: string) => void
  38|   cursorOffset: number
  39|   handleKeyDown: (e: KeyboardEvent) => void
  40| }

源码引用: src/hooks/useSearchInput.ts · 第 63–82 行(共 365 行)

  63| const UNHANDLED_SPECIAL_KEYS = new Set([
  64|   'pageup',
  65|   'pagedown',
  66|   'insert',
  67|   'wheelup',
  68|   'wheeldown',
  69|   'mouse',
  70|   'f1',
  71|   'f2',
  72|   'f3',
  73|   'f4',
  74|   'f5',
  75|   'f6',
  76|   'f7',
  77|   'f8',
  78|   'f9',
  79|   'f10',
  80|   'f11',
  81|   'f12',
  82| ])

useSearchInput:handleKeyDown 分支走读

handleKeyDown 是本章最密集的键盘逻辑,建议按下列顺序阅读:

  1. 门控:!isActive 早退;passthrough ctrl 键直接 return
  2. kill/yank 状态:非 kill 键 resetKillAccumulation;非 yank 键 resetYankState
  3. 退出:return/down → onExit;up → onExitUp;escape → onCancel 或清 query
  4. 编辑:backspace/delete;带 meta/ctrl/fn 的箭头词跳;home/end
  5. Ctrl 绑定:Emacs 风格 a/e/b/f/d/h/k/u/w/y;空行 ctrl+d/h 可触发 cancel
  6. Meta 绑定:词移、meta+d 删词、meta+y yank-pop
  7. 普通字符:e.key.length >= 1 且不在 UNHANDLED_SPECIAL_KEYS

UNHANDLED_SPECIAL_KEYS 拦截 pageup、f1–f12 等,防止泄漏为字面文本。注释强调 batched stdin(如 paste)可能一次传入多字符 e.key,与旧 useInput 行为一致。

文件末尾 useInput 桥接(TODO onKeyDown-migration):尚未把 handleKeyDown 接到 <Box onKeyDown> 的 consumer 仍通过 useInput 订阅,适配 InputEvent → KeyboardEvent。

源码引用: src/hooks/useSearchInput.ts · 第 104–148 行(共 365 行)

 104|   const handleKeyDown = (e: KeyboardEvent): void => {
 105|     if (!isActive) return
 106| 
 107|     const cursor = Cursor.fromText(query, effectiveColumns, cursorOffset)
 108| 
 109|     // Check passthrough ctrl keys
 110|     if (e.ctrl && passthroughCtrlKeys.includes(e.key.toLowerCase())) {
 111|       return
 112|     }
 113| 
 114|     // Reset kill accumulation for non-kill keys
 115|     if (!isKillKey(e)) {
 116|       resetKillAccumulation()
 117|     }
 118| 
 119|     // Reset yank state for non-yank keys
 120|     if (!isYankKey(e)) {
 121|       resetYankState()
 122|     }
 123| 
 124|     // Exit conditions
 125|     if (e.key === 'return' || e.key === 'down') {
 126|       e.preventDefault()
 127|       onExit()
 128|       return
 129|     }
 130|     if (e.key === 'up') {
 131|       e.preventDefault()
 132|       if (onExitUp) {
 133|         onExitUp()
 134|       }
 135|       return
 136|     }
 137|     if (e.key === 'escape') {
 138|       e.preventDefault()
 139|       if (onCancel) {
 140|         onCancel()
 141|       } else if (query.length > 0) {
 142|         setQueryState('')
 143|         setCursorOffset(0)
 144|       } else {
 145|         onExit()
 146|       }
 147|       return
 148|     }

源码引用: src/hooks/useSearchInput.ts · 第 221–300 行(共 365 行)

 221|     // Ctrl key bindings
 222|     if (e.ctrl) {
 223|       e.preventDefault()
 224|       switch (e.key.toLowerCase()) {
 225|         case 'a':
 226|           setCursorOffset(0)
 227|           return
 228|         case 'e':
 229|           setCursorOffset(query.length)
 230|           return
 231|         case 'b':
 232|           setCursorOffset(cursor.left().offset)
 233|           return
 234|         case 'f':
 235|           setCursorOffset(cursor.right().offset)
 236|           return
 237|         case 'd': {
 238|           if (query.length === 0) {
 239|             ;(onCancel ?? onExit)()
 240|             return
 241|           }
 242|           const newCursor = cursor.del()
 243|           setQueryState(newCursor.text)
 244|           setCursorOffset(newCursor.offset)
 245|           return
 246|         }
 247|         case 'h': {
 248|           if (query.length === 0) {
 249|             if (backspaceExitsOnEmpty) (onCancel ?? onExit)()
 250|             return
 251|           }
 252|           const newCursor = cursor.backspace()
 253|           setQueryState(newCursor.text)
 254|           setCursorOffset(newCursor.offset)
 255|           return
 256|         }
 257|         case 'k': {
 258|           const { cursor: newCursor, killed } = cursor.deleteToLineEnd()
 259|           pushToKillRing(killed, 'append')
 260|           setQueryState(newCursor.text)
 261|           setCursorOffset(newCursor.offset)
 262|           return
 263|         }
 264|         case 'u': {
 265|           const { cursor: newCursor, killed } = cursor.deleteToLineStart()
 266|           pushToKillRing(killed, 'prepend')
 267|           setQueryState(newCursor.text)
 268|           setCursorOffset(newCursor.offset)
 269|           return
 270|         }
 271|         case 'w': {
 272|           const { cursor: newCursor, killed } = cursor.deleteWordBefore()
 273|           pushToKillRing(killed, 'prepend')
 274|           setQueryState(newCursor.text)
 275|           setCursorOffset(newCursor.offset)
 276|           return
 277|         }
 278|         case 'y': {
 279|           const text = getLastKill()
 280|           if (text.length > 0) {
 281|             const startOffset = cursor.offset
 282|             const newCursor = cursor.insert(text)
 283|             recordYank(startOffset, text.length)
 284|             setQueryState(newCursor.text)
 285|             setCursorOffset(newCursor.offset)
 286|           }
 287|           return
 288|         }
 289|         case 'g':
 290|         case 'c':
 291|           // Cancel (abandon search). ctrl+g is less's cancel key. Only
 292|           // fires if onCancel provided — otherwise falls through and
 293|           // returns silently (11 call sites, most expect ctrl+c to no-op).
 294|           if (onCancel) {
 295|             onCancel()
 296|             return
 297|           }
 298|       }
 299|       return
 300|     }

源码引用: src/hooks/useSearchInput.ts · 第 352–364 行(共 365 行)

 352|   // Backward-compat bridge: existing consumers don't yet wire handleKeyDown
 353|   // to <Box onKeyDown>. Subscribe via useInput and adapt InputEvent →
 354|   // KeyboardEvent until all 11 call sites are migrated (separate PRs).
 355|   // TODO(onKeyDown-migration): remove once all consumers pass handleKeyDown.
 356|   useInput(
 357|     (_input, _key, event) => {
 358|       handleKeyDown(new KeyboardEvent(event.keypress))
 359|     },
 360|     { isActive },
 361|   )
 362| 
 363|   return { query, setQuery, cursorOffset, handleKeyDown }
 364| }

REPL 中 Transcript 搜索对 useSearchInput 的用法

REPL.tsx 的 TranscriptSearchBar 传入:

  • onExit: () =&gt; onClose(query) — Enter 提交,保留 query 供 n/N 导航
  • onCancel — Esc 撤销到打开搜索前状态
  • initialQuery — less 风格:再次 / 显示上次 pattern

这与 GlobalKeybindingHandlers 的 searchBarOpen gate 联动:搜索栏打开时 transcript:exit 不 active,否则 Esc 会同时触发 useSearchInput.onCancel 与退出 transcript(useSearchInput 不 stopPropagation,子 handler 先 fire 再 bubble)。

工程练习: 在 transcript 模式打开 / 搜索,按 Esc 观察是仅 cancel 搜索还是整页退出——取决于 searchBarOpen 状态。

源码引用: src/screens/REPL.tsx · 第 380–398 行(共 7050 行)

 380| } from '../utils/fileHistory.js'
 381| import {
 382|   type AttributionState,
 383|   incrementPromptCount,
 384| } from '../utils/commitAttribution.js'
 385| import { recordAttributionSnapshot } from '../utils/sessionStorage.js'
 386| import {
 387|   computeStandaloneAgentContext,
 388|   restoreAgentFromSession,
 389|   restoreSessionStateFromLog,
 390|   restoreWorktreeForResume,
 391|   exitRestoredWorktree,
 392| } from '../utils/sessionRestore.js'
 393| import {
 394|   isBgSession,
 395|   updateSessionName,
 396|   updateSessionActivity,
 397| } from '../utils/concurrentSessions.js'
 398| import {

源码引用: src/hooks/useGlobalKeybindings.tsx · 第 238–246 行(共 265 行)

 238| 
 239|   // Clear screen and force full redraw (ctrl+l). Recovery path when the
 240|   // terminal was cleared externally (macOS Cmd+K) and Ink's diff engine
 241|   // thinks unchanged cells don't need repainting.
 242|   const handleRedraw = useCallback(() => {
 243|     instances.get(process.stdout)?.forceRedraw()
 244|   }, [])
 245|   useKeybinding('app:redraw', handleRedraw, { context: 'Global' })
 246| 

useGlobalKeybindings:全局视图与 transcript 专用键

GlobalKeybindingHandlers 是 零 UI 组件(return null),必须在 KeybindingSetup 内渲染。

app 级绑定(context: Global):

  • app:toggleTodos — ctrl+t,expandedView 在 none/tasks/teammates 间循环(有 running teammate 时三态,否则 none↔tasks)
  • app:toggleTranscript — ctrl+o,prompt↔transcript;KAIROS brief stuck 时先清 isBriefOnly
  • app:toggleBrief — ctrl+shift+b(feature gate)
  • app:toggleTeammatePreview、app:toggleTerminal(meta+j)、app:redraw(ctrl+l 强制 Ink 重绘)

transcript 级绑定(context: Transcript,带 isActive):

  • transcript:toggleShowAll — ctrl+e,且 !virtualScrollActive
  • transcript:exit — ctrl+c/escape,且 !searchBarOpen

每次切换写 analytics(tengu_toggle_transcript 等),便于产品分析 transcript 使用率。

源码引用: src/hooks/useGlobalKeybindings.tsx · 第 29–88 行(共 265 行)

  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
  81|       return {
  82|         ...prev,
  83|         expandedView:
  84|           prev.expandedView === 'tasks'
  85|             ? ('none' as const)
  86|             : ('tasks' as const),
  87|       }
  88|     })

源码引用: src/hooks/useGlobalKeybindings.tsx · 第 90–154 行(共 265 行)

  90| 
  91|   // Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript.
  92|   // Brief view has its own dedicated toggle on ctrl+shift+b.
  93|   const isBriefOnly =
  94|     feature('KAIROS') || feature('KAIROS_BRIEF')
  95|       ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
  96|         useAppState(s => s.isBriefOnly)
  97|       : false
  98|   const handleToggleTranscript = useCallback(() => {
  99|     if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
 100|       // Escape hatch: GB kill-switch while defaultView=chat was persisted
 101|       // can leave isBriefOnly stuck on, showing a blank filterForBriefTool
 102|       // view. Users will reach for ctrl+o — clear the stuck state first.
 103|       // Only needed in the prompt screen — transcript mode already ignores
 104|       // isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode).
 105|       /* eslint-disable @typescript-eslint/no-require-imports */
 106|       const { isBriefEnabled } =
 107|         require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js')
 108|       /* eslint-enable @typescript-eslint/no-require-imports */
 109|       if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') {
 110|         setAppState(prev => {
 111|           if (!prev.isBriefOnly) return prev
 112|           return { ...prev, isBriefOnly: false }
 113|         })
 114|         return
 115|       }
 116|     }
 117| 
 118|     const isEnteringTranscript = screen !== 'transcript'
 119|     logEvent('tengu_toggle_transcript', {
 120|       is_entering: isEnteringTranscript,
 121|       show_all: showAllInTranscript,
 122|       message_count: messageCount,
 123|     })
 124|     setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript'))
 125|     setShowAllInTranscript(false)
 126|     if (isEnteringTranscript && onEnterTranscript) {
 127|       onEnterTranscript()
 128|     }
 129|     if (!isEnteringTranscript && onExitTranscript) {
 130|       onExitTranscript()
 131|     }
 132|   }, [
 133|     screen,
 134|     setScreen,
 135|     isBriefOnly,
 136|     showAllInTranscript,
 137|     setShowAllInTranscript,
 138|     messageCount,
 139|     setAppState,
 140|     onEnterTranscript,
 141|     onExitTranscript,
 142|   ])
 143| 
 144|   // Toggle showing all messages in transcript mode (ctrl+e)
 145|   const handleToggleShowAll = useCallback(() => {
 146|     logEvent('tengu_transcript_toggle_show_all', {
 147|       is_expanding: !showAllInTranscript,
 148|       message_count: messageCount,
 149|     })
 150|     setShowAllInTranscript(prev => !prev)
 151|   }, [showAllInTranscript, setShowAllInTranscript, messageCount])
 152| 
 153|   // Exit transcript mode (ctrl+c or escape)
 154|   const handleExitTranscript = useCallback(() => {

源码引用: src/hooks/useGlobalKeybindings.tsx · 第 184–246 行(共 265 行)

 184|       logEvent('tengu_brief_mode_toggled', {
 185|         enabled: next,
 186|         gated: false,
 187|         source:
 188|           'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 189|       })
 190|       setAppState(prev => {
 191|         if (prev.isBriefOnly === next) return prev
 192|         return { ...prev, isBriefOnly: next }
 193|       })
 194|     }
 195|   }, [isBriefOnly, setAppState])
 196| 
 197|   // Register keybinding handlers
 198|   useKeybinding('app:toggleTodos', handleToggleTodos, {
 199|     context: 'Global',
 200|   })
 201|   useKeybinding('app:toggleTranscript', handleToggleTranscript, {
 202|     context: 'Global',
 203|   })
 204|   if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
 205|     // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant
 206|     useKeybinding('app:toggleBrief', handleToggleBrief, {
 207|       context: 'Global',
 208|     })
 209|   }
 210| 
 211|   // Register teammate keybinding
 212|   useKeybinding(
 213|     'app:toggleTeammatePreview',
 214|     () => {
 215|       setAppState(prev => ({
 216|         ...prev,
 217|         showTeammateMessagePreview: !prev.showTeammateMessagePreview,
 218|       }))
 219|     },
 220|     {
 221|       context: 'Global',
 222|     },
 223|   )
 224| 
 225|   // Toggle built-in terminal panel (meta+j).
 226|   // toggle() blocks in spawnSync until the user detaches from tmux.
 227|   const handleToggleTerminal = useCallback(() => {
 228|     if (feature('TERMINAL_PANEL')) {
 229|       if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false)) {
 230|         return
 231|       }
 232|       getTerminalPanel().toggle()
 233|     }
 234|   }, [])
 235|   useKeybinding('app:toggleTerminal', handleToggleTerminal, {
 236|     context: 'Global',
 237|   })
 238| 
 239|   // Clear screen and force full redraw (ctrl+l). Recovery path when the
 240|   // terminal was cleared externally (macOS Cmd+K) and Ink's diff engine
 241|   // thinks unchanged cells don't need repainting.
 242|   const handleRedraw = useCallback(() => {
 243|     instances.get(process.stdout)?.forceRedraw()
 244|   }, [])
 245|   useKeybinding('app:redraw', handleRedraw, { context: 'Global' })
 246| 

useCommandKeybindings:配置驱动的斜杠命令

CommandKeybindingHandlers 扫描 KeybindingContext 里所有 action.startsWith("command:") 的绑定,动态构建 handler map:

command:commit → onSubmit("/commit", NOOP_HELPERS, undefined, { fromKeybinding: true })

关键语义:

  1. immediate 执行 — 不等用户编辑 prompt,直接走 handlePromptSubmit 路径
  2. 保留输入 — NOOP_HELPERS 的 clearBuffer/setCursorOffset 均为空操作,现有 prompt 文本不清空
  3. fromKeybinding: true — 下游可区分键盘触发 vs 手动输入
  4. isActive 门控 — isActive && !isModalOverlayActive;local JSX 命令打开时 REPL 传 isActive={!toolJSX?.isLocalJSXCommand}

React Compiler 生成的 _c / $[] memo 可忽略,关注 commandActions 的 useMemo 与 handlers map 构建即可。无 KeybindingContext 时 commandActions 为空 Set,不注册任何绑定。

源码引用: src/hooks/useCommandKeybindings.tsx · 第 17–30 行(共 83 行)

  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| }

源码引用: src/hooks/useCommandKeybindings.tsx · 第 59–83 行(共 83 行)

  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| 

useVimInput:模式机与 useTextInput 组合

useVimInput 包装 useTextInput(不传 inputFilter 给底层),在 handleVimInput 最外层统一跑 inputFilter:

  • INSERT 模式 — 使用 filter 后的 input,并累积 insertedText 供 dot-repeat
  • NORMAL 模式 — 用 rawInput 做 vim 命令 lookup,避免 stateful filter prepend 空格破坏单字符命令

模式切换:

  • INSERT + Esc → switchToNormalMode(vim 惯例:光标左移一格,除非行首或前一字符为换行)
  • NORMAL + Esc → command 重置为 idle
  • Enter 任意模式都 delegate 给 textInput.onInput(允许 NORMAL 下提交)

NORMAL 模式 通过 transition(state.command, vimInput, ctx) 驱动 vim/transitions.js;箭头在 idle/count/operator 态映射为 hjkl;expectsMotion 时 backspace→h、delete→x(count 态除外,避免误删)。

replayLastChange 读取 persistentRef.lastChange,支持 insert/x/replace/operator 等类型的 . 重复。

源码引用: src/hooks/useVimInput.ts · 第 34–80 行(共 317 行)

  34| export function useVimInput(props: UseVimInputProps): VimInputState {
  35|   const vimStateRef = React.useRef<VimState>(createInitialVimState())
  36|   const [mode, setMode] = useState<VimMode>('INSERT')
  37| 
  38|   const persistentRef = React.useRef<PersistentState>(
  39|     createInitialPersistentState(),
  40|   )
  41| 
  42|   // inputFilter is applied once at the top of handleVimInput (not here) so
  43|   // vim-handled paths that return without calling textInput.onInput still
  44|   // run the filter — otherwise a stateful filter (e.g. lazy-space-after-
  45|   // pill) stays armed across an Escape → NORMAL → INSERT round-trip.
  46|   const textInput = useTextInput({ ...props, inputFilter: undefined })
  47|   const { onModeChange, inputFilter } = props
  48| 
  49|   const switchToInsertMode = useCallback(
  50|     (offset?: number): void => {
  51|       if (offset !== undefined) {
  52|         textInput.setOffset(offset)
  53|       }
  54|       vimStateRef.current = { mode: 'INSERT', insertedText: '' }
  55|       setMode('INSERT')
  56|       onModeChange?.('INSERT')
  57|     },
  58|     [textInput, onModeChange],
  59|   )
  60| 
  61|   const switchToNormalMode = useCallback((): void => {
  62|     const current = vimStateRef.current
  63|     if (current.mode === 'INSERT' && current.insertedText) {
  64|       persistentRef.current.lastChange = {
  65|         type: 'insert',
  66|         text: current.insertedText,
  67|       }
  68|     }
  69| 
  70|     // Vim behavior: move cursor left by 1 when exiting insert mode
  71|     // (unless at beginning of line or at offset 0)
  72|     const offset = textInput.offset
  73|     if (offset > 0 && props.value[offset - 1] !== '\n') {
  74|       textInput.setOffset(offset - 1)
  75|     }
  76| 
  77|     vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
  78|     setMode('NORMAL')
  79|     onModeChange?.('NORMAL')
  80|   }, [onModeChange, textInput, props.value])

源码引用: src/hooks/useVimInput.ts · 第 175–228 行(共 317 行)

 175|   function handleVimInput(rawInput: string, key: Key): void {
 176|     const state = vimStateRef.current
 177|     // Run inputFilter in all modes so stateful filters disarm on any key,
 178|     // but only apply the transformed input in INSERT — NORMAL-mode command
 179|     // lookups expect single chars and a prepended space would break them.
 180|     const filtered = inputFilter ? inputFilter(rawInput, key) : rawInput
 181|     const input = state.mode === 'INSERT' ? filtered : rawInput
 182|     const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
 183| 
 184|     if (key.ctrl) {
 185|       textInput.onInput(input, key)
 186|       return
 187|     }
 188| 
 189|     // NOTE(keybindings): This escape handler is intentionally NOT migrated to the keybindings system.
 190|     // It's vim's standard INSERT->NORMAL mode switch - a vim-specific behavior that should not be
 191|     // configurable via keybindings. Vim users expect Esc to always exit INSERT mode.
 192|     if (key.escape && state.mode === 'INSERT') {
 193|       switchToNormalMode()
 194|       return
 195|     }
 196| 
 197|     // Escape in NORMAL mode cancels any pending command (replace, operator, etc.)
 198|     if (key.escape && state.mode === 'NORMAL') {
 199|       vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
 200|       return
 201|     }
 202| 
 203|     // Pass Enter to base handler regardless of mode (allows submission from NORMAL)
 204|     if (key.return) {
 205|       textInput.onInput(input, key)
 206|       return
 207|     }
 208| 
 209|     if (state.mode === 'INSERT') {
 210|       // Track inserted text for dot-repeat
 211|       if (key.backspace || key.delete) {
 212|         if (state.insertedText.length > 0) {
 213|           vimStateRef.current = {
 214|             mode: 'INSERT',
 215|             insertedText: state.insertedText.slice(
 216|               0,
 217|               -(lastGrapheme(state.insertedText).length || 1),
 218|             ),
 219|           }
 220|         }
 221|       } else {
 222|         vimStateRef.current = {
 223|           mode: 'INSERT',
 224|           insertedText: state.insertedText + input,
 225|         }
 226|       }
 227|       textInput.onInput(input, key)
 228|       return

源码引用: src/hooks/useVimInput.ts · 第 245–295 行(共 317 行)

 245|     const ctx: TransitionContext = {
 246|       ...createOperatorContext(cursor, false),
 247|       onUndo: props.onUndo,
 248|       onDotRepeat: replayLastChange,
 249|     }
 250| 
 251|     // Backspace/Delete are only mapped in motion-expecting states. In
 252|     // literal-char states (replace, find, operatorFind), mapping would turn
 253|     // r+Backspace into "replace with h" and df+Delete into "delete to next x".
 254|     // Delete additionally skips count state: in vim, N<Del> removes a count
 255|     // digit rather than executing Nx; we don't implement digit removal but
 256|     // should at least not turn a cancel into a destructive Nx.
 257|     const expectsMotion =
 258|       state.command.type === 'idle' ||
 259|       state.command.type === 'count' ||
 260|       state.command.type === 'operator' ||
 261|       state.command.type === 'operatorCount'
 262| 
 263|     // Map arrow keys to vim motions in NORMAL mode
 264|     let vimInput = input
 265|     if (key.leftArrow) vimInput = 'h'
 266|     else if (key.rightArrow) vimInput = 'l'
 267|     else if (key.upArrow) vimInput = 'k'
 268|     else if (key.downArrow) vimInput = 'j'
 269|     else if (expectsMotion && key.backspace) vimInput = 'h'
 270|     else if (expectsMotion && state.command.type !== 'count' && key.delete)
 271|       vimInput = 'x'
 272| 
 273|     const result = transition(state.command, vimInput, ctx)
 274| 
 275|     if (result.execute) {
 276|       result.execute()
 277|     }
 278| 
 279|     // Update command state (only if execute didn't switch to INSERT)
 280|     if (vimStateRef.current.mode === 'NORMAL') {
 281|       if (result.next) {
 282|         vimStateRef.current = { mode: 'NORMAL', command: result.next }
 283|       } else if (result.execute) {
 284|         vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
 285|       }
 286|     }
 287| 
 288|     if (
 289|       input === '?' &&
 290|       state.mode === 'NORMAL' &&
 291|       state.command.type === 'idle'
 292|     ) {
 293|       props.onChange('?')
 294|     }
 295|   }

VimTextInput 集成与外部 setMode

components/VimTextInput.tsx 调用 useVimInput 并暴露 mode 给 UI(模式指示器)。外部可通过返回的 setMode 强制 INSERT/NORMAL,用于设置页切换 vim 开关等场景。

useVimInput 返回 { ...textInput, onInput: handleVimInput, mode, setMode } — 对 PromptInput 而言,唯一替换点是 onInput;其余 offset、history 行为与纯 text input 一致。

若你实现新的 multiline 输入组件,优先复用 useTextInput + 可选 useVimInput,而非在组件内复制 Esc/operator 逻辑。

源码引用: src/hooks/useVimInput.ts · 第 297–316 行(共 317 行)

 297|   const setModeExternal = useCallback(
 298|     (newMode: VimMode) => {
 299|       if (newMode === 'INSERT') {
 300|         vimStateRef.current = { mode: 'INSERT', insertedText: '' }
 301|       } else {
 302|         vimStateRef.current = { mode: 'NORMAL', command: { type: 'idle' } }
 303|       }
 304|       setMode(newMode)
 305|       onModeChange?.(newMode)
 306|     },
 307|     [onModeChange],
 308|   )
 309| 
 310|   return {
 311|     ...textInput,
 312|     onInput: handleVimInput,
 313|     mode,
 314|     setMode: setModeExternal,
 315|   }
 316| }

源码目录(本主题相关文件)

关联:keybindings/useKeybinding.js、utils/Cursor.js、vim/transitions.js、components/VimTextInput.tsx、screens/REPL.tsx(KeybindingSetup subtree)。

动手练习

  1. 在 settings.json 添加 command:doctor 类 keybinding,验证 CommandKeybindingHandlers 是否自动注册
  2. transcript 模式:ctrl+o 进入 → / 打开搜索 → Esc,确认只关闭搜索栏
  3. 开启 vim 模式,INSERT 下输入文字再 Esc,观察光标是否左移
  4. useSearchInput 单元场景:空 query 时 ctrl+u kill 到行首,再 ctrl+y yank 回来
  5. 对照 useGlobalKeybindings 注释,理解 ctrl+l forceRedraw 在 macOS Cmd+K 清屏后的 recovery 用途

本章小结与延伸

输入与快捷键 Hook = REPL 键盘 UX 的四条专管线。下一章建议 merged-state,理解工具池与命令队列如何与输入并行运行。 继续学习:

  • hooks 模块总览
  • 合并态 Hook
Prev
useCanUseTool · 权限 UI 接缝
Next
合并态 Hook(MCP + 本地)