本章总览
先定边界: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。
建议学习步骤
- 阅读 useVimInput 顶部 props 与 switchToNormalMode
- 跟踪 handleVimInput 模式分支
- 阅读 createOperatorContext 与 replayLastChange
- 对照 defaultBindings Chat 块哪些键 vim 仍消费
- 阅读 useSearchInput NOTE(keybindings)
- 浏览 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| * │ │ └─[><]─────► 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 关系 |
|---|---|---|
| enter | chat:submit | INSERT 下通常 insert newline 或 submit 由组件 policy 决定 |
| ctrl+r | history:search | 打开 HistorySearch context |
| meta+p | chat:modelPicker | overlay |
| ctrl+s | chat:stash | stash 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 读四条输入管线全貌。 继续学习: