Vim 模式
Claude Code 如何在终端 UI 中实现完整的 vim 键绑定引擎 — 状态机、纯函数和正确性零妥协。
Claude Code 的输入框是一个丰富的终端小部件,而不是浏览器 <input>。 这意味着标准操作系统键绑定是默认可用的。 为了提供完整的 vim 体验(动作、运算符、文本对象、点重复、寄存器),团队必须在 TypeScript 中从头开始实现整个 vim 输入语法。
结果存在于下面的五个文件中 src/vim/,总共大约700行。 它是代码库中设计最精心的子系统之一:贯穿始终的纯函数、适用于每种模式的显式状态机以及用作活文档的 TypeScript 的可区分联合。
状态机类型
所有模式、状态和记录的更改作为 TypeScript 联合。 阅读此文件会告诉您引擎可以执行的所有操作。
转换表
每个状态一个函数。 每个接收按键并返回下一个状态和/或要运行的副作用。
纯光标数学
将运动键+计数解析为目标光标的无状态函数。 没有突变。
文本突变
删除/更改/复制应用于字符范围、行范围或文本对象。
结构选择
iw、aw、i(、a" 等 - 边界查找器,用于处理具有字素安全性的原始文本偏移。
首先要读入的内容 types.ts 是顶部的块注释——它包含一个 ASCII 状态图。 但真正的规范是类型本身。 VimState 是一个只有两名成员的受歧视联盟:
/**
* Complete vim state. Mode determines what data is tracked.
*
* INSERT mode: Track text being typed (for dot-repeat)
* NORMAL mode: Track command being parsed (state machine)
*/
export type VimState =
| { mode: 'INSERT'; insertedText: string }
| { mode: 'NORMAL'; command: CommandState }
INSERT模式仅携带 insertedText — 进入插入模式后输入的字符,将通过点重复重播。 NORMAL 模式将所有复杂性委托给 CommandState:
export type CommandState =
| { type: 'idle' }
| { type: 'count'; digits: string }
| { type: 'operator'; op: Operator; count: number }
| { type: 'operatorCount'; op: Operator; count: number; digits: string }
| { type: 'operatorFind'; op: Operator; count: number; find: FindType }
| { type: 'operatorTextObj'; op: Operator; count: number; scope: TextObjScope }
| { type: 'find'; find: FindType; count: number }
| { type: 'g'; count: number }
| { type: 'operatorG'; op: Operator; count: number }
| { type: 'replace'; count: number }
| { type: 'indent'; dir: '>' | '<'; count: number }
每个变体都准确地编码了解析器需要记住的中间序列。 operatorCount 存在是因为 vim 允许 2d3w — 对操作员的计数 and 对运动的计数,有效计数是它们的乘积(2 * 3 = 6 words).
CommandState,每个处理它的 switch 语句都会产生编译错误,直到添加处理程序为止。 类型系统以零运行时间成本强制执行完整性。
持久状态——编辑的记忆
除了正在解析的当前命令之外,还必须存在跨命令生存的状态:
export type PersistentState = {
lastChange: RecordedChange | null // dot-repeat target
lastFind: { type: FindType; char: string } | null // ; / , repeat
register: string // yank/delete clipboard
registerIsLinewise: boolean // affects paste behavior
}
RecordedChange 是它自己的可区分联合,捕获重播命令所需的所有内容:操作符、动作、计数,或者(对于文本对象操作)范围和对象类型。 这就是使 . 之后可以正常工作 ci" or 3dw.
transitions.ts 是发动机的心脏。 它导出单个 transition() 调度基于的函数 state.type:
export function transition(
state: CommandState,
input: string,
ctx: TransitionContext,
): TransitionResult {
switch (state.type) {
case 'idle': return fromIdle(input, ctx)
case 'count': return fromCount(state, input, ctx)
case 'operator': return fromOperator(state, input, ctx)
case 'operatorCount': return fromOperatorCount(state, input, ctx)
case 'operatorFind': return fromOperatorFind(state, input, ctx)
case 'operatorTextObj':return fromOperatorTextObj(state, input, ctx)
case 'find': return fromFind(state, input, ctx)
case 'g': return fromG(state, input, ctx)
case 'operatorG': return fromOperatorG(state, input, ctx)
case 'replace': return fromReplace(state, input, ctx)
case 'indent': return fromIndent(state, input, ctx)
}
}
返回类型是 TransitionResult: 可选 下一个状态 以及可选的 执行回调。 来电者致电 execute() 如果存在,则将命令状态更新为 next (或重置为 idle if next 执行后不存在)。 这种分离意味着转换逻辑永远不会直接改变编辑器状态。
共享输入处理
Both fromIdle and fromCount 在考虑计数后接受同一组输入。 两者都委托给共享助手,而不是重复逻辑:
function handleNormalInput(
input: string,
count: number,
ctx: TransitionContext,
): TransitionResult | null {
if (isOperatorKey(input)) {
return { next: { type: 'operator', op: OPERATORS[input], count } }
}
if (SIMPLE_MOTIONS.has(input)) {
return {
execute: () => {
const target = resolveMotion(input, ctx.cursor, count)
ctx.setOffset(target.offset)
},
}
}
// ... fFtT, g, r, >, <, ~, x, J, p, P, D, C, Y, G, ., ;, ,, u, i/I/a/A, o/O
return null // unrecognized input
}
null from handleNormalInput 意思是“在此上下文中无法识别此输入。” 呼叫者可能会失败并重置为 idle。 这可以干净地处理无效的击键,而不会引发错误。
motions.ts 故意是最简单的文件。 它导出三个纯函数:
/** Resolve a motion to a target cursor position. Pure calculation. */
export function resolveMotion(key: string, cursor: Cursor, count: number): Cursor {
let result = cursor
for (let i = 0; i < count; i++) {
const next = applySingleMotion(key, result)
if (next.equals(result)) break // at boundary, stop early
result = next
}
return result
}
export function isInclusiveMotion(key: string): boolean {
return 'eE$'.includes(key)
}
export function isLinewiseMotion(key: string): boolean {
return 'jkG'.includes(key) || key === 'gg'
}
循环中 resolveMotion 一次应用一步,当光标停止移动时提前中断。 这可以正确处理命中缓冲区中间计数的开始/结束。
运动包容性对于操作员来说很重要: dw 排除目标下的字符,但是 de 包括它。 isInclusiveMotion and isLinewiseMotion 是那些标志 getOperatorRange in operators.ts 用于计算要操作的确切字节范围。
| Category | Keys | 游标法 |
|---|---|---|
| Character | h l | cursor.left() / cursor.right() |
| 线(逻辑) | j k | cursor.downLogicalLine() / cursor.upLogicalLine() |
| 线(视觉) | gj gk | cursor.down() / cursor.up() |
| 单词(小-w) | w b e | nextVimWord() / prevVimWord() / endOfVimWord() |
| 字(字) | W B E | nextWORD() / prevWORD() / endOfWORD() |
| 线路位置 | 0 ^ $ | startOfLogicalLine() / firstNonBlank…() / endOfLogicalLine() |
| File | G gg | startOfLastLine() / startOfFirstLine() |
运算符(d, c, y)需要一个 range 在他们采取行动之前。 operators.ts 为每个运算符+范围源的组合提供一个入口点:
dw、c$、y^、...
Calls resolveMotion,转换为尊重包含/逐行标志的字节范围,然后应用运算符。
dfx、ctY、...
Uses cursor.findCharacter() 定位目标,然后计算该偏移量的包含范围。
石油, ca(, ", ...
代表们 findTextObject() from textObjects.ts 并将运算符应用于返回的 {start, end}.
日、抄送、年
运算符加倍 - 从当前位置计算完整的逻辑行,处理缓冲区中最后一行的边缘情况。
所有四个人共用一个私人 applyOperator() helper:
function applyOperator(
op: Operator,
from: number,
to: number,
ctx: OperatorContext,
linewise: boolean = false,
): void {
let content = ctx.text.slice(from, to)
if (linewise && !content.endsWith('\n')) content = content + '\n'
ctx.setRegister(content, linewise)
if (op === 'yank') {
ctx.setOffset(from) // cursor to yank start, no text change
} else if (op === 'delete') {
const newText = ctx.text.slice(0, from) + ctx.text.slice(to)
ctx.setText(newText)
ctx.setOffset(Math.min(from, newText.length - 1))
} else if (op === 'change') {
const newText = ctx.text.slice(0, from) + ctx.text.slice(to)
ctx.setText(newText)
ctx.enterInsert(from) // change always enters INSERT mode
}
}
CW 特例
真正的 vim 款待 cw 不同于 dw: 它改变为 词尾,不是下一个单词的开始。 getOperatorRange() 明确地处理这个问题:
// Special case: cw/cW changes to end of word, not start of next word
if (op === 'change' && (motion === 'w' || motion === 'W')) {
let wordCursor = cursor
for (let i = 0; i < count - 1; i++) {
wordCursor = motion === 'w' ? wordCursor.nextVimWord() : wordCursor.nextWORD()
}
const wordEnd = motion === 'w'
? wordCursor.endOfVimWord()
: wordCursor.endOfWORD()
to = cursor.measuredText.nextOffset(wordEnd.offset)
}
cursor.snapOutOfImageRef() 在每个运算符范围的两端调用。 这确保了文字或动作操作永远不会留下部分内容 [Image #N] 缓冲区中的占位符 — Claude Code 的多模式输入小部件特有的一个问题。
文本对象是 vim 的杀手级功能之一: ci" 更改引号内的内容,无需先将光标移动到那里。 textObjects.ts 将它们实现为单个导出函数:
export function findTextObject(
text: string,
offset: number,
objectType: string,
isInner: boolean,
): TextObjectRange {
if (objectType === 'w') return findWordObject(text, offset, isInner, isVimWordChar)
if (objectType === 'W') return findWordObject(text, offset, isInner, ch => !isVimWhitespace(ch))
const pair = PAIRS[objectType]
if (pair) {
const [open, close] = pair
return open === close
? findQuoteObject(text, offset, open, isInner)
: findBracketObject(text, offset, open, close, isInner)
}
return null
}
分隔符对表涵盖了所有标准 vim 文本对象:
const PAIRS: Record<string, [string, string]> = {
'(': ['(', ')'], ')': ['(', ')'], b: ['(', ')'],
'[': ['[', ']'], ']': ['[', ']'],
'{': ['{', '}'], '}': ['{', '}'], B: ['{', '}'],
'<': ['<', '>'], '>': ['<', '>'],
'"': ['"', '"'], "'": ["'", "'"], '`': ['`', '`'],
}
括号与报价算法
支架对((), [], {}, <>)使用深度计数双向扫描 - 向左走找到匹配的左括号,然后向右走找到关闭的、正确处理嵌套的。 引号对使用不同的方法,因为引号是对称的 - 没有“嵌套”。 相反,他们会找到该行上的所有报价位置并将它们配对为 0-1、2-3、4-5。
for (let i = offset; i >= 0; i--) {
if (text[i] === close && i !== offset) depth++
else if (text[i] === open) {
if (depth === 0) { start = i; break }
depth--
}
}
单词对象查找器使用以下命令将文本预先分割为字素 Intl.Segmenter 扫描之前。 这保证了多字节 unicode 字符的正确性——大多数 vim 实现在细节上都会出错。
所有 vim 文件均不导入 React、Ink 或任何 UI 框架。 所有副作用都流经两种上下文类型:
export type OperatorContext = {
cursor: Cursor
text: string
setText: (text: string) => void
setOffset: (offset: number) => void
enterInsert: (offset: number) => void
getRegister: () => string
setRegister: (content: string, linewise: boolean) => void
getLastFind: () => { type: FindType; char: string } | null
setLastFind: (type: FindType, char: string) => void
recordChange:(change: RecordedChange) => void
}
export type TransitionContext = OperatorContext & {
onUndo?: () => void
onDotRepeat?: () => void
}
真正的编辑器通过关闭 React 状态设置器来创建这些上下文对象。 vim 引擎只调用回调。 这意味着整个引擎可以针对任何满足接口的对象进行测试——无需模拟。
OperatorContext + TransitionContext 接口。 这是教科书上的依赖倒置,这就是为什么引擎可以完全进行单元测试而无需启动终端。
点重复 (.)
When . 被按下,转换调用 ctx.onDotRepeat?.()。 调用者(编辑器组件)持有 PersistentState.lastChange 并通过构造新的上下文并重新运行适当的执行函数来重放它。 这 RecordedChange union 确保每个命令类型都准确地携带重新运行它所需的数据。
查找/直到 (f F t T) 并重复 (; ,)
fromFind calls cursor.findCharacter(char, findType, count) 并将结果存储在 PersistentState.lastFind via ctx.setLastFind()。 这 ; and , 处理程序在 handleNormalInput 读取存储的查找,如果翻转方向 ,,然后重复搜索——一个微妙的 vim 功能的三行实现。
function executeRepeatFind(reverse: boolean, count: number, ctx: TransitionContext): void {
const lastFind = ctx.getLastFind()
if (!lastFind) return
let findType = lastFind.type
if (reverse) {
const flipMap: Record<FindType, FindType> = { f: 'F', F: 'f', t: 'T', T: 't' }
findType = flipMap[findType]
}
const result = ctx.cursor.findCharacter(lastFind.char, findType, count)
if (result !== null) ctx.setOffset(result)
}
替换字符 (r)
fromReplace 处理一种很容易被忽略的边缘情况:如果输入是空字符串,则意味着按下了 Backspace/Delete。 在维姆中, r<BS> 取消替换而不是删除字符。 守卫 if (input === '') return { next: { type: 'idle' } } 正确地实现了这一点。
G 和 gg 导航
G 没有计数就转到最后一行。 带有计数(例如, 42G),转到第 42 课 行。实施检查 count === 1 作为“没有计数”的哨兵——因为紧迫 G 单独从空闲状态产生count=1。 同样的模式适用于 gg.
真正的 vim 支持复合计数: 2d3w 删除 6 个单词 (2 × 3)。 这需要两个独立的计数累加器——一个在操作员上,一个在运动上——以及 operatorCount 国家的存在只是为了这个目的。
function fromOperatorCount(state, input, ctx) {
if (/[0-9]/.test(input)) {
const newDigits = state.digits + input
const parsedDigits = Math.min(parseInt(newDigits, 10), MAX_VIM_COUNT)
return { next: { ...state, digits: String(parsedDigits) } }
}
const motionCount = parseInt(state.digits, 10)
const effectiveCount = state.count * motionCount // <-- multiplication
const result = handleOperatorInput(state.op, effectiveCount, input, ctx)
if (result) return result
return { next: { type: 'idle' } }
}
The MAX_VIM_COUNT = 10000 常量可防止用户意外触发 999999j 并造成性能灾难。
每个键组都是一个命名常量,而不是内联文字:
export const OPERATORS = {
d: 'delete',
c: 'change',
y: 'yank',
} as const satisfies Record<string, Operator>
export const SIMPLE_MOTIONS = new Set([
'h', 'l', 'j', 'k', // Basic movement
'w', 'b', 'e', 'W', 'B', 'E', // Word motions
'0', '^', '$', // Line positions
])
export const FIND_KEYS = new Set(['f', 'F', 't', 'T'])
export const TEXT_OBJ_TYPES = new Set(['w', 'W', '"', "'", '`', '(', ...])
Using as const satisfies Record<string, Operator> on OPERATORS 给出了两种缩小的文字类型(所以 OPERATORS['d'] 键入为 'delete',不仅仅是 string) 并编译时检查所有值是否有效 Operator members.
要点
- vim 引擎是一个纯粹的 TypeScript 状态机——没有 UI 导入,所有副作用都通过上下文回调实现。
CommandState是一个受歧视的工会,兼作文件; TypeScript 的详尽检查强制每个状态都有一个处理程序。- 运动是返回新值的纯函数
Cursor; 运算符通过将游标增量转换为字节范围来组合在顶部。 - 文本对象查找器对对称对(引号:线性扫描 + 配对)和非对称对(括号:深度计数双向扫描)使用不同的算法。
- 复合计数 (
2d3w)由专门的人员处理operatorCount在执行之前将两个计数相乘的状态。 - 点重复之所以有效,是因为每个突变都记录了一个
RecordedChange捕获操作类型、计数以及重播所需的所有参数。 - 字素分割通过
Intl.Segmenter使引擎具有 unicode 安全性——对于可以用任何语言编辑文本的人工智能工具来说非常重要。
检查你的理解情况
d 在NORMAL模式下,转换表返回什么?3d2w?textObjects.ts 使用不同的算法 i" vs i(?CommandState 变体不会默默地破坏转换表吗?d then 3 在正常模式下?