1.4 Vim Mode: A Full Editor Inside the Terminal

Source location: src/vim/ (5 files, ~540 lines total) How to activate: press Esc in the input box, or enable vim mode in settings


One-Sentence Understanding

Claude Code's input box includes a complete Vim keybinding system—not just arrow-key support, but full NORMAL / INSERT mode switching with a state machine, text objects, operators, count prefixes, . repeat, and u undo.


State Machine Design (VimState)

The core of Vim mode is a two-layer state machine:

VimState
  ├── { mode: 'INSERT', insertedText: string }
  │     ← records inserted text for . repeat
  │
  └── { mode: 'NORMAL', command: CommandState }
        │
        ├── { type: 'idle' }                        ← default waiting state
        ├── { type: 'count', digits: string }       ← numeric prefix (3dw)
        ├── { type: 'operator', op, count }         ← waiting for motion (the d in d_)
        ├── { type: 'operatorCount', op, count, digits } ← digits after operator
        ├── { type: 'operatorFind', op, count, find }   ← df<char>
        ├── { type: 'operatorTextObj', op, count, scope } ← daw / diw
        ├── { type: 'find', find, count }           ← f<char> jump
        ├── { type: 'g', count }                    ← g + gg/G
        ├── { type: 'operatorG', op, count }        ← dg + G
        ├── { type: 'replace', count }              ← r<char>
        └── { type: 'indent', dir, count }          ← >> / <<

The source comments directly include the full state diagram (src/vim/types.ts lines 1-26), a rare "types as documentation" design.


Supported Keybindings

Motions

KeyFunction
h j k lleft/down/up/right
w Wnext word start
e Eend of current word
b Bprevious word start
0line start
$line end
^first non-whitespace of line
g gfirst line
Glast line
f<x> F<x>find character forward/backward
t<x> T<x>find character forward/backward (stop one before)

Operators

KeyOperation
ddelete
cchange (then switch to INSERT)
yyank (copy)

Operator + Motion Combinations

dd    → delete whole line
d$    → delete to line end
dw    → delete one word
daw   → delete one word (with surrounding space, around)
diw   → delete one word (without surrounding space, inner)
d3w   → delete 3 words
3dd   → delete 3 lines
cc    → change whole line
c$    → change to line end
yy    → yank whole line

Text Objects

ScopeWordBracketsQuotes
inner (i)iwi( i[ i{i' i" i`
around (a)awa( a[ a{a' a" a`

Other Common Operations

KeyFunction
i Ienter INSERT (current position / line start)
a Aenter INSERT (next position / line end)
o Oinsert new line (below / above) + INSERT
xdelete current character
r<x>replace current character
p Ppaste (after / before)
uundo
> <indent / outdent
Jjoin next line
~toggle case
.repeat last change

PersistentState: Cross-Command Memory

// src/vim/types.ts
type PersistentState = {
  register: string | null      // yank register (most recent yank/delete content)
  lastFind: LastFind | null    // last f/F/t/T search (for ; and , repeat)
  lastInsert: string | null    // last INSERT content (for . repeat)
  lastOp: LastOp | null        // last operation (for . repeat)
}

This explains why . and ; , can repeat correctly—all memory needed for repetition is persisted in PersistentState.


Integration with Ink

The Vim state machine is designed as pure functions and does not manipulate the DOM directly. Claude Code's Ink components capture raw keyboard events and pass them to transition():

// src/vim/transitions.ts (core entry)
  state: CommandState,
  key: string,
  ctx: TransitionContext,
): TransitionResult

// TransitionResult
type TransitionResult = {
  next?: CommandState    // next state (omitted = back to idle)
  execute?: () => void   // operation to execute (side effect)
}

Design principle: the state machine itself has no side effects; it only returns "next state" and "what operation to execute." Side effects (cursor movement, text deletion) are performed by the caller.


Why Pure Functional?

Benefits of implementing the Vim state machine with pure functions:

  1. Testable: input (state, key) → output (nextState, operation), naturally unit-testable
  2. No race conditions: no mutable shared state; concurrent key input does not pollute globals
  3. Traceable: every state transition is an explicit function call; stack traces directly reflect operation sequences during debugging

Next