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

本章总览

先定边界:vim-bindings 讲的是“Vim 编辑状态机与可配置 keybindings 的分工”,核心结论是 Vim 语义键由 useVimInput + vim/ 维护,不直接下沉到 keybindings.json。这样既保留 Vim 连招语义,也让全局/对话动作继续走声明式 binding 系统。

学完本章你应该能

  • 理解 useVimInput 组合 useTextInput 的方式
  • 说明 Esc 为何不注册为 chat:* binding
  • 列举 NORMAL 模式 operator/motion 处理路径
  • 知道 ctrl 键 delegate 给底层 readline 的原因
  • 理解 inputFilter 在 NORMAL 模式的 disarm 语义
  • 能对比 useSearchInput 与 useVimInput 的职责

核心概念(先读懂这些)

两层键盘:声明式 vs Vim 状态机

keybindings 层:context + action + chord,无模式状态。Vim 层:NORMAL/INSERT 状态机 + register + lastChange dot-repeat。PromptInput 同时挂载 useKeybinding(chat:submit 等)与 useVimInput——事件顺序:KeybindingSetup 先 resolve,未 match 的键进入 VimTextInput onInput。

NOTE(keybindings) 注释

useVimInput 与 useSearchInput 文件内均有 NOTE(keybindings):特定键故意不 configurable。产品原则:Vim 语义 > 用户 JSON 覆盖;若需 rebind Esc,应改 Vim 层而非 keybindings.json。

textInputTypes 契约

VimInputState 暴露 mode、offset、onInput handler。VimMode = NORMAL | INSERT。组件 VimTextInput 展示 mode indicator,onModeChange 供 statusline 或 footer。

建议学习步骤

  1. 阅读 useVimInput 顶部 props 与 switchToNormalMode
  2. 跟踪 handleVimInput 模式分支
  3. 阅读 createOperatorContext 与 replayLastChange
  4. 对照 defaultBindings Chat 块哪些键 vim 仍消费
  5. 阅读 useSearchInput NOTE(keybindings)
  6. 浏览 vim/transitions.ts transition 表

常见误区

注意

NORMAL 模式 inputFilter 只 disarm 不应用变换

注意

useVimInput 的 inputFilter 在 props 顶层应用一次

注意

chat:undo binding 与 vim u 可能重叠——优先看谁 stopPropagation

注意

transcript 搜索用 useSearchInput 非 useVimInput

useVimInput 架构

useVimInput = useTextInput(readline 核心)+ vimStateRef + persistentRef(register、lastFind、lastChange)。

KeyboardEvent
  → handleVimInput (vim 状态机)
      NORMAL: operator/motion/insert transition
      INSERT: 字符插入,Esc → switchToNormalMode
  → 未处理且 ctrl → delegate textInput
  → chat:* useKeybinding 可能在更外层已处理

switchToNormalMode:INSERT 退出时光标左移一位(Vim 惯例),记录 insertedText 到 lastChange。

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

  28| type UseVimInputProps = Omit<UseTextInputProps, 'inputFilter'> & {
  29|   onModeChange?: (mode: VimMode) => void
  30|   onUndo?: () => void
  31|   inputFilter?: UseTextInputProps['inputFilter']
  32| }
  33| 
  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 · 第 82–120 行(共 317 行)

  82|   function createOperatorContext(
  83|     cursor: Cursor,
  84|     isReplay: boolean = false,
  85|   ): OperatorContext {
  86|     return {
  87|       cursor,
  88|       text: props.value,
  89|       setText: (newText: string) => props.onChange(newText),
  90|       setOffset: (offset: number) => textInput.setOffset(offset),
  91|       enterInsert: (offset: number) => switchToInsertMode(offset),
  92|       getRegister: () => persistentRef.current.register,
  93|       setRegister: (content: string, linewise: boolean) => {
  94|         persistentRef.current.register = content
  95|         persistentRef.current.registerIsLinewise = linewise
  96|       },
  97|       getLastFind: () => persistentRef.current.lastFind,
  98|       setLastFind: (type, char) => {
  99|         persistentRef.current.lastFind = { type, char }
 100|       },
 101|       recordChange: isReplay
 102|         ? () => {}
 103|         : (change: RecordedChange) => {
 104|             persistentRef.current.lastChange = change
 105|           },
 106|     }
 107|   }
 108| 
 109|   function replayLastChange(): void {
 110|     const change = persistentRef.current.lastChange
 111|     if (!change) return
 112| 
 113|     const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
 114|     const ctx = createOperatorContext(cursor, true)
 115| 
 116|     switch (change.type) {
 117|       case 'insert':
 118|         if (change.text) {
 119|           const newCursor = cursor.insert(change.text)
 120|           props.onChange(newCursor.text)

Esc 与 keybindings 边界

INSERT Esc:内联 switchToNormalMode,不走 chat:cancel binding。

Chat context 的 escape → chat:cancel 用于取消 prompt 提交态或清空,与 Vim NORMAL 切换不同。REPL 协调:VimTextInput focus 时 Esc 优先 vim;非 Vim 模式 Esc 走 keybindings。

NORMAL Esc:取消 pending operator(transition idle)。

此边界在 hooks/input-keybindings 子章节与 keybindings defaultBindings 对照阅读。

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

  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/keybindings/defaultBindings.ts · 第 64–67 行(共 341 行)

  64|     context: 'Chat',
  65|     bindings: {
  66|       escape: 'chat:cancel',
  67|       // ctrl+x chord prefix avoids shadowing readline editing keys (ctrl+a/b/e/f/...).

textInputTypes 与组件

VimInputState 类型定义 mode、handleVimInput、setOffset 等。VimTextInput 组件(components)组合 PromptInput 样式与 mode indicator。

onModeChange 回调写入 footer 或 analytics。createInitialVimState / createInitialPersistentState 在 vim/types.ts。

types 层不 import React——VimInputState 在 textInputTypes.ts 纯类型。

源码引用: src/types/textInputTypes.ts · 第 1–60 行(共 388 行)

   1| import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
   2| import type { UUID } from 'crypto'
   3| import type React from 'react'
   4| import type { PermissionResult } from '../entrypoints/agentSdkTypes.js'
   5| import type { Key } from '../ink.js'
   6| import type { PastedContent } from '../utils/config.js'
   7| import type { ImageDimensions } from '../utils/imageResizer.js'
   8| import type { TextHighlight } from '../utils/textHighlighting.js'
   9| import type { AgentId } from './ids.js'
  10| import type { AssistantMessage, MessageOrigin } from './message.js'
  11| 
  12| /**
  13|  * Inline ghost text for mid-input command autocomplete
  14|  */
  15| export type InlineGhostText = {
  16|   /** The ghost text to display (e.g., "mit" for /commit) */
  17|   readonly text: string
  18|   /** The full command name (e.g., "commit") */
  19|   readonly fullCommand: string
  20|   /** Position in the input where the ghost text should appear */
  21|   readonly insertPosition: number
  22| }
  23| 
  24| /**
  25|  * Base props for text input components
  26|  */
  27| export type BaseTextInputProps = {
  28|   /**
  29|    * Optional callback for handling history navigation on up arrow at start of input
  30|    */
  31|   readonly onHistoryUp?: () => void
  32| 
  33|   /**
  34|    * Optional callback for handling history navigation on down arrow at end of input
  35|    */
  36|   readonly onHistoryDown?: () => void
  37| 
  38|   /**
  39|    * Text to display when `value` is empty.
  40|    */
  41|   readonly placeholder?: string
  42| 
  43|   /**
  44|    * Allow multi-line input via line ending with backslash (default: `true`)
  45|    */
  46|   readonly multiline?: boolean
  47| 
  48|   /**
  49|    * Listen to user's input. Useful in case there are multiple input components
  50|    * at the same time and input must be "routed" to a specific component.
  51|    */
  52|   readonly focus?: boolean
  53| 
  54|   /**
  55|    * Replace all chars and mask the value. Useful for password inputs.
  56|    */
  57|   readonly mask?: string
  58| 
  59|   /**
  60|    * Whether to show cursor and allow navigation inside text input with arrow keys.

源码引用: src/vim/types.ts · 第 1–50 行(共 200 行)

   1| /**
   2|  * Vim Mode State Machine Types
   3|  *
   4|  * This file defines the complete state machine for vim input handling.
   5|  * The types ARE the documentation - reading them tells you how the system works.
   6|  *
   7|  * State Diagram:
   8|  * ```
   9|  *                              VimState
  10|  *   ┌──────────────────────────────┬──────────────────────────────────────┐
  11|  *   │  INSERT                      │  NORMAL                              │
  12|  *   │  (tracks insertedText)       │  (CommandState machine)              │
  13|  *   │                              │                                      │
  14|  *   │                              │  idle ──┬─[d/c/y]──► operator        │
  15|  *   │                              │         ├─[1-9]────► count           │
  16|  *   │                              │         ├─[fFtT]───► find            │
  17|  *   │                              │         ├─[g]──────► g               │
  18|  *   │                              │         ├─[r]──────► replace         │
  19|  *   │                              │         └─[&gt;&lt;]─────► indent          │
  20|  *   │                              │                                      │
  21|  *   │                              │  operator ─┬─[motion]──► execute     │
  22|  *   │                              │            ├─[0-9]────► operatorCount│
  23|  *   │                              │            ├─[ia]─────► operatorTextObj
  24|  *   │                              │            └─[fFtT]───► operatorFind │
  25|  *   └──────────────────────────────┴──────────────────────────────────────┘
  26|  * ```
  27|  */
  28| 
  29| // ============================================================================
  30| // Core Types
  31| // ============================================================================
  32| 
  33| export type Operator = 'delete' | 'change' | 'yank'
  34| 
  35| export type FindType = 'f' | 'F' | 't' | 'T'
  36| 
  37| export type TextObjScope = 'inner' | 'around'
  38| 
  39| // ============================================================================
  40| // State Machine Types
  41| // ============================================================================
  42| 
  43| /**
  44|  * Complete vim state. Mode determines what data is tracked.
  45|  *
  46|  * INSERT mode: Track text being typed (for dot-repeat)
  47|  * NORMAL mode: Track command being parsed (state machine)
  48|  */
  49| export type VimState =
  50|   | { mode: 'INSERT'; insertedText: string }

operators 与 transitions

vim/operators.ts:executeX、executeOperatorMotion、executeReplace、executeToggleCase 等,接受 OperatorContext。

vim/transitions.ts:transition(state, key) 返回下一 VimState,处理 dd、cw、fa 等。

dot-repeat:replayLastChange 读 persistentRef.lastChange,在 NORMAL 按 . 重放。

这些逻辑 thousands 行在 vim/ 目录,keybindings parser 无对应 action 字符串。

源码引用: src/vim/transitions.ts · 第 1–60 行(共 491 行)

   1| /**
   2|  * Vim State Transition Table
   3|  *
   4|  * This is the scannable source of truth for state transitions.
   5|  * To understand what happens in any state, look up that state's transition function.
   6|  */
   7| 
   8| import { resolveMotion } from './motions.js'
   9| import {
  10|   executeIndent,
  11|   executeJoin,
  12|   executeLineOp,
  13|   executeOpenLine,
  14|   executeOperatorFind,
  15|   executeOperatorG,
  16|   executeOperatorGg,
  17|   executeOperatorMotion,
  18|   executeOperatorTextObj,
  19|   executePaste,
  20|   executeReplace,
  21|   executeToggleCase,
  22|   executeX,
  23|   type OperatorContext,
  24| } from './operators.js'
  25| import {
  26|   type CommandState,
  27|   FIND_KEYS,
  28|   type FindType,
  29|   isOperatorKey,
  30|   isTextObjScopeKey,
  31|   MAX_VIM_COUNT,
  32|   OPERATORS,
  33|   type Operator,
  34|   SIMPLE_MOTIONS,
  35|   TEXT_OBJ_SCOPES,
  36|   TEXT_OBJ_TYPES,
  37|   type TextObjScope,
  38| } from './types.js'
  39| 
  40| /**
  41|  * Context passed to transition functions.
  42|  */
  43| export type TransitionContext = OperatorContext & {
  44|   onUndo?: () => void
  45|   onDotRepeat?: () => void
  46| }
  47| 
  48| /**
  49|  * Result of a transition.
  50|  */
  51| export type TransitionResult = {
  52|   next?: CommandState
  53|   execute?: () => void
  54| }
  55| 
  56| /**
  57|  * Main transition function. Dispatches based on current state type.
  58|  */
  59| export function transition(
  60|   state: CommandState,

源码引用: src/vim/operators.ts · 第 1–50 行(共 557 行)

   1| /**
   2|  * Vim Operator Functions
   3|  *
   4|  * Pure functions for executing vim operators (delete, change, yank, etc.)
   5|  */
   6| 
   7| import { Cursor } from '../utils/Cursor.js'
   8| import { firstGrapheme, lastGrapheme } from '../utils/intl.js'
   9| import { countCharInString } from '../utils/stringUtils.js'
  10| import {
  11|   isInclusiveMotion,
  12|   isLinewiseMotion,
  13|   resolveMotion,
  14| } from './motions.js'
  15| import { findTextObject } from './textObjects.js'
  16| import type {
  17|   FindType,
  18|   Operator,
  19|   RecordedChange,
  20|   TextObjScope,
  21| } from './types.js'
  22| 
  23| /**
  24|  * Context for operator execution.
  25|  */
  26| export type OperatorContext = {
  27|   cursor: Cursor
  28|   text: string
  29|   setText: (text: string) => void
  30|   setOffset: (offset: number) => void
  31|   enterInsert: (offset: number) => void
  32|   getRegister: () => string
  33|   setRegister: (content: string, linewise: boolean) => void
  34|   getLastFind: () => { type: FindType; char: string } | null
  35|   setLastFind: (type: FindType, char: string) => void
  36|   recordChange: (change: RecordedChange) => void
  37| }
  38| 
  39| /**
  40|  * Execute an operator with a simple motion.
  41|  */
  42| export function executeOperatorMotion(
  43|   op: Operator,
  44|   motion: string,
  45|   count: number,
  46|   ctx: OperatorContext,
  47| ): void {
  48|   const target = resolveMotion(motion, ctx.cursor, count)
  49|   if (target.equals(ctx.cursor)) return
  50| 

Chat defaultBindings 与 Vim 共存

仍生效的 Chat binding(Vim 未吞键时):

键Action与 Vim 关系
enterchat:submitINSERT 下通常 insert newline 或 submit 由组件 policy 决定
ctrl+rhistory:search打开 HistorySearch context
meta+pchat:modelPickeroverlay
ctrl+schat:stashstash buffer

ctrl+字母多数 delegate useTextInput(readline emacs 绑定)。ctrl+x ctrl+e externalEditor 是 chord binding,与 vim ctrl+x 前缀可能交互——resolver chord 优先。

源码引用: src/keybindings/defaultBindings.ts · 第 63–88 行(共 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')

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

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

useSearchInput 的 vim/less 语义

Transcript / 搜索与 Settings 列表过滤用 useSearchInput:独立 Cursor kill ring、word motion,不是 useVimInput。

NOTE(keybindings):搜索栏打开时 GlobalKeybindingHandlers gate transcript:exit,避免 Esc 双触发。

与 keybindings Transcript context(q、ctrl+c exit)配合:搜索 overlay 激活时 activeContexts 不同。

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

   1| import { useCallback, useState } from 'react'
   2| import { KeyboardEvent } from '../ink/events/keyboard-event.js'
   3| // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until consumers wire handleKeyDown to <Box onKeyDown>
   4| import { useInput } from '../ink.js'
   5| import {
   6|   Cursor,
   7|   getLastKill,
   8|   pushToKillRing,
   9|   recordYank,
  10|   resetKillAccumulation,
  11|   resetYankState,
  12|   updateYankLength,
  13|   yankPop,
  14| } from '../utils/Cursor.js'
  15| import { useTerminalSize } from './useTerminalSize.js'
  16| 
  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| }
  41| 
  42| function isKillKey(e: KeyboardEvent): boolean {
  43|   if (e.ctrl && (e.key === 'k' || e.key === 'u' || e.key === 'w')) {
  44|     return true
  45|   }
  46|   if (e.meta && e.key === 'backspace') {
  47|     return true
  48|   }
  49|   return false
  50| }

源码引用: src/keybindings/defaultBindings.ts · 第 160–170 行(共 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|   },

设计取舍总结

能力实现层
全局 toggles、dialog 导航keybindings/
Slash 命令快捷键command:* + useCommandKeybindings
Prompt 行 Vim 编辑useVimInput + vim/
Transcript 搜索编辑useSearchInput

扩展 Vim 命令:改 vim/transitions,不是 keybindings.json。扩展 REPL 全局热键:defaultBindings + useGlobalKeybindings。

边界来自状态机而不是偏好

useVimInput 明确把 Vim 视为有状态编辑器:INSERT 记录 insertedText 供 dot-repeat,Esc 退出 INSERT 时按 Vim 习惯左移光标,NORMAL 下 transition 表处理 operator、count、motion、find、replace 等组合。NOTE(keybindings) 写在 Esc 分支附近,是为了说明这类键不是“默认快捷键”,而是 Vim 语义本身。如果把 Esc、d、c、y、.、f{char} 迁进 keybindings.json,就会丢失 pending operator、寄存器、lastFind、lastChange 等上下文,用户也无法获得符合 Vim 直觉的组合行为。

useSearchInput 是另一个边界例子。Transcript 或 Settings 的搜索框需要 less/vim 风格的 Esc、Ctrl+G、Backspace 删除到空时退出、Ctrl+A/E/B/F 移动、kill ring 和 yank,但它不是 prompt 的 Vim 编辑器,也不共享 useVimInput 的 NORMAL/INSERT 状态。它通过独立 handleKeyDown 管理 query、cursorOffset、onCancel/onExit,并允许 passthroughCtrlKeys 把少量组合键留给外层。实际产品里三层会同时存在:keybindings 处理 dialog/context/action,useVimInput 处理 prompt 内 Vim 状态机,useSearchInput 处理搜索输入。判断一个键该放哪层,要看它是否依赖编辑器内部状态;依赖状态就留在 hook 或 vim/,只是触发产品动作才进入 keybindings。

这个原则也适用于 ctrl 组合。useVimInput 在 key.ctrl 时直接 delegate 给 useTextInput,是为了保留终端 readline 和现有编辑快捷键;而 ctrl+x ctrl+e 这类 chord 由 resolver 更外层先捕获,成为 chat:externalEditor。若用户报告 Vim 模式下某个键失效,先判断它是否被 keybinding chord 拦截,再看 useVimInput 是否在 INSERT/NORMAL 中消费,最后才看 useTextInput。不要把所有键都归咎于 defaultBindings。

扩展 Vim 行为时应优先增加 transition 或 operator 测试,而不是给 defaultBindings 新增 chat:vim-* action。只有当按键要打开产品 UI、切换模式面板或调用 slash 命令时,才应穿过 keybindings 层。

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

  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–207 行(共 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|     }

源码引用: src/hooks/useSearchInput.ts · 第 17–33 行(共 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| }

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

 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|     }
 149| 
 150|     // Backspace/Delete
 151|     if (e.key === 'backspace') {
 152|       e.preventDefault()
 153|       if (e.meta) {
 154|         // Meta+Backspace: kill word before
 155|         const { cursor: newCursor, killed } = cursor.deleteWordBefore()
 156|         pushToKillRing(killed, 'prepend')
 157|         setQueryState(newCursor.text)
 158|         setCursorOffset(newCursor.offset)
 159|         return
 160|       }
 161|       if (query.length === 0) {
 162|         // Backspace past the / — cancel (clear + snap back), not commit.
 163|         // less: same. vim: deletes the / and exits command mode.
 164|         if (backspaceExitsOnEmpty) (onCancel ?? onExit)()
 165|         return

扩展键位时的落点判断

要判断一个新键该改哪里,可用一条简单规则:如果按键行为依赖编辑器内部状态(operator pending、count、寄存器、lastChange),就放在 vim/ 与 useVimInput;如果按键只是触发产品动作(开面板、提交命令、切换视图),就放在 keybindings/defaultBindings + handler。这样能避免把状态机逻辑拆散到 resolver 层。

这条规则同样适用于搜索输入:useSearchInput 管的是搜索框内部编辑体验,而不是全局动作路由。

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

  82|   function createOperatorContext(
  83|     cursor: Cursor,
  84|     isReplay: boolean = false,
  85|   ): OperatorContext {
  86|     return {
  87|       cursor,
  88|       text: props.value,
  89|       setText: (newText: string) => props.onChange(newText),
  90|       setOffset: (offset: number) => textInput.setOffset(offset),
  91|       enterInsert: (offset: number) => switchToInsertMode(offset),
  92|       getRegister: () => persistentRef.current.register,
  93|       setRegister: (content: string, linewise: boolean) => {
  94|         persistentRef.current.register = content
  95|         persistentRef.current.registerIsLinewise = linewise
  96|       },
  97|       getLastFind: () => persistentRef.current.lastFind,
  98|       setLastFind: (type, char) => {
  99|         persistentRef.current.lastFind = { type, char }
 100|       },
 101|       recordChange: isReplay
 102|         ? () => {}
 103|         : (change: RecordedChange) => {
 104|             persistentRef.current.lastChange = change
 105|           },
 106|     }
 107|   }
 108| 
 109|   function replayLastChange(): void {
 110|     const change = persistentRef.current.lastChange
 111|     if (!change) return
 112| 
 113|     const cursor = Cursor.fromText(props.value, props.columns, textInput.offset)
 114|     const ctx = createOperatorContext(cursor, true)
 115| 
 116|     switch (change.type) {
 117|       case 'insert':
 118|         if (change.text) {
 119|           const newCursor = cursor.insert(change.text)
 120|           props.onChange(newCursor.text)

源码引用: src/vim/transitions.ts · 第 1–60 行(共 491 行)

   1| /**
   2|  * Vim State Transition Table
   3|  *
   4|  * This is the scannable source of truth for state transitions.
   5|  * To understand what happens in any state, look up that state's transition function.
   6|  */
   7| 
   8| import { resolveMotion } from './motions.js'
   9| import {
  10|   executeIndent,
  11|   executeJoin,
  12|   executeLineOp,
  13|   executeOpenLine,
  14|   executeOperatorFind,
  15|   executeOperatorG,
  16|   executeOperatorGg,
  17|   executeOperatorMotion,
  18|   executeOperatorTextObj,
  19|   executePaste,
  20|   executeReplace,
  21|   executeToggleCase,
  22|   executeX,
  23|   type OperatorContext,
  24| } from './operators.js'
  25| import {
  26|   type CommandState,
  27|   FIND_KEYS,
  28|   type FindType,
  29|   isOperatorKey,
  30|   isTextObjScopeKey,
  31|   MAX_VIM_COUNT,
  32|   OPERATORS,
  33|   type Operator,
  34|   SIMPLE_MOTIONS,
  35|   TEXT_OBJ_SCOPES,
  36|   TEXT_OBJ_TYPES,
  37|   type TextObjScope,
  38| } from './types.js'
  39| 
  40| /**
  41|  * Context passed to transition functions.
  42|  */
  43| export type TransitionContext = OperatorContext & {
  44|   onUndo?: () => void
  45|   onDotRepeat?: () => void
  46| }
  47| 
  48| /**
  49|  * Result of a transition.
  50|  */
  51| export type TransitionResult = {
  52|   next?: CommandState
  53|   execute?: () => void
  54| }
  55| 
  56| /**
  57|  * Main transition function. Dispatches based on current state type.
  58|  */
  59| export function transition(
  60|   state: CommandState,

源码引用: src/keybindings/defaultBindings.ts · 第 63–88 行(共 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')

本章小结与延伸

vim-bindings 章阐明 keybindings 引擎不替代 Vim 状态机。回到 hooks/input-keybindings 读四条输入管线全貌。 继续学习:

  • hooks/input-keybindings
  • command-bindings
Prev
command-bindings · command:* 动态斜杠命令绑定
Next
memdir-core · 路径、加载与 MEMORY.md