Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
4313c9e
docs: add vim engine modularization and text-object plans
oribarilan Jul 23, 2026
3813202
test: pin uncovered normal-mode branches before refactor
oribarilan Jul 23, 2026
e4ca157
refactor: move vim.ts into src/vim/ directory barrel
oribarilan Jul 23, 2026
2c8e27c
refactor: extract vim types into types.ts
oribarilan Jul 23, 2026
734129b
refactor: extract pure text algorithms into text.ts
oribarilan Jul 23, 2026
47be3a1
refactor: extract keybinding tables into tables.ts
oribarilan Jul 23, 2026
8c543a7
refactor: extract translateKey/PASS/pushN into util.ts
oribarilan Jul 23, 2026
19b3e48
refactor: extract VimState lifecycle into state.ts
oribarilan Jul 23, 2026
365ce03
refactor: extract handleInsertKey into insert.ts
oribarilan Jul 23, 2026
012f000
refactor: extract handleNormalKey into normal.ts
oribarilan Jul 23, 2026
949bc4a
refactor: extract handleVisualKey into visual.ts
oribarilan Jul 23, 2026
a273e4d
refactor: reduce vim barrel to public surface, drop dead _CONSUME
oribarilan Jul 23, 2026
5cead18
test: split vim tests to mirror the module layout
oribarilan Jul 23, 2026
89e9d9c
docs: document the modular vim engine layout
oribarilan Jul 23, 2026
24c1ebf
refactor: consolidate pending state into a discriminated union
oribarilan Jul 23, 2026
7dbabb6
docs: note dangling-operator fix in changelog, sync test line counts
oribarilan Jul 23, 2026
e9c4aca
chore: tighten Biome lint config
oribarilan Aug 31, 2026
3e9e6ac
docs: sync AGENTS.md line counts after rebase
oribarilan Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 33 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,25 +57,43 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation,

```
src/
index.ts (357 lines) Plugin entry: intercept registration, action application
vim.ts (645 lines) Pure vim engine: state, handlers, command tables, types
index.ts (408 lines) Plugin entry: intercept registration, action application
vim/ Pure vim engine (thin barrel re-exports the public surface):
index.ts (7 lines) Barrel — public surface only. No export *, no internals.
types.ts (50 lines) Action union, VimState, Mode, Operator, KeyEvent, HandlerResult, PromptAccess
text.ts (36 lines) Pure string algorithms: isWhitespace, charKind, endOfWord, currentLineRange
tables.ts (35 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal)
util.ts (19 lines) State-agnostic primitives: translateKey, PASS, pushN
state.ts (76 lines) VimState lifecycle + transitions
insert.ts (32 lines) handleInsertKey
normal.ts (340 lines) handleNormalKey (+ file-local finishUndoableChange, isInputEmpty)
visual.ts (78 lines) handleVisualKey
leader.ts (73 lines) Leader key matching: matchesKeyLike, findMatchingLeader, leaderChar
clipboard.ts (19 lines) writeClipboard() — cross-platform (pbcopy/xclip/xsel/wl-copy/clip.exe)
version.ts (46 lines) Version constant, GitHub update check (cached daily)
test/
vim.test.ts (1434 lines) Characterization tests for all key handling branches
leader.test.ts (125 lines) Unit tests for leader key matching functions
support.ts (33 lines) Shared assertion helpers + ev()
fixtures.ts (17 lines) Prompt fixtures: mockPrompt, emptyPrompt
vim/ Per-module engine tests mirroring src/vim/:
text.test.ts (136) endOfWord + charKind/isWhitespace/currentLineRange units
state.test.ts (70) createVimState, toggleVimMode
util.test.ts (31) translateKey
insert.test.ts (92) handleInsertKey
normal.test.ts (655) handleNormalKey branches
visual.test.ts (195) handleVisualKey branches
integration.test.ts (418) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync
leader.test.ts (125 lines) Unit tests for leader key matching functions
```

**Data flow:**
```
KeyEvent → translateKey() → handleInsertKey/handleNormalKey/handleVisualKey() → HandlerResult { consume, actions[] }
↓ ↓
mutates VimState applyActions() in index.ts
(count, pendingOp, pendingChar, mode) dispatches commands via setTimeout
(count, pending, mode) dispatches commands via setTimeout
```

Handlers in `vim.ts` are pure — they take state + key + event, mutate state, return actions. They never touch `api`. The only file that calls `api.keymap.dispatchCommand` is `index.ts`.
Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they take state + key + event, mutate state, return actions. They never touch `api`. The only file that calls `api.keymap.dispatchCommand` is `index.ts`. `src/vim/index.ts` is a strict barrel: it re-exports only the public surface (no `export *`, no internal helpers), and sibling modules import each other directly (`./types`, `./state`, …) never through the barrel.

**Action types:**
- `{ type: "cmd", cmd: string }` — dispatched via `setTimeout(() => api.keymap.dispatchCommand(cmd), 0)`
Expand All @@ -92,14 +110,14 @@ Handlers in `vim.ts` are pure — they take state + key + event, mutate state, r

### Adding a keybinding

1. In `vim.ts`, find the right section in `handleNormalKey()` (motions, operators, special keys, insert entries)
1. In `src/vim/normal.ts`, find the right section in `handleNormalKey()` (motions, operators, special keys, insert entries)
2. Add the key check and return appropriate actions:
```ts
if (key === "yourkey") {
return { consume: true, actions: [{ type: "cmd", cmd: "input.some.command" }] }
}
```
3. Add a test in `test/vim.test.ts`:
3. Add a test in the matching `test/vim/*.test.ts` (e.g. `test/vim/normal.test.ts`), importing helpers from `../support` and fixtures from `../fixtures`:
```ts
it("yourkey dispatches some.command", () => {
const result = handleNormalKey(state, "yourkey", ev("yourkey"), mockPrompt)
Expand All @@ -110,12 +128,12 @@ Handlers in `vim.ts` are pure — they take state + key + event, mutate state, r

### Adding an operator+motion combo

Operators (d/c/y) use two tables: `MOTIONS` maps key → standalone cursor command, `DELETE_MOTION` maps key → destructive command. When `pendingOp` is set and a motion key arrives, `handleNormalKey` looks up `DELETE_MOTION[key]` and dispatches it.
Operators (d/c/y) use two tables in `src/vim/tables.ts`: `MOTIONS` maps key → standalone cursor command, `DELETE_MOTION` maps key → destructive command. When an operator is pending and a motion key arrives, `handleNormalKey` (in `src/vim/normal.ts`) looks up `DELETE_MOTION[key]` and dispatches it.

To add a new motion that works with operators:
1. Add the standalone motion to `MOTIONS`: `{ "yourkey": "input.move.whatever" }`
2. Add the destructive version to `DELETE_MOTION`: `{ "yourkey": "input.delete.whatever" }`
3. If the motion needs special handling with operators (like j/k which delete multiple lines), add an explicit branch in the `pendingOp && key in MOTIONS` section.
3. If the motion needs special handling with operators (like j/k which delete multiple lines), add an explicit branch in the `state.pending.kind === "operator" && key in MOTIONS` section.

### Known limitations

Expand Down Expand Up @@ -146,7 +164,9 @@ Branch naming: `type/description` — e.g. `feat/replace-char`, `fix/escape-hand

**No classes.** Use plain objects for state (`VimState`), plain functions for behavior. Pass state by reference, mutate it directly. Return results as data.

**Single responsibility per file.** `vim.ts` owns all key handling logic and state transitions. `index.ts` owns all OpenCode API interaction. `clipboard.ts` owns platform I/O. Don't mix these concerns.
**Single responsibility per file.** The `src/vim/` engine is split by concern: `types.ts` (data), `text.ts`/`tables.ts` (pure algorithms + keybinding maps), `util.ts`/`state.ts` (state-agnostic primitives + VimState lifecycle), and one handler per mode (`insert.ts`/`normal.ts`/`visual.ts`). `src/index.ts` owns all OpenCode API interaction. `clipboard.ts` owns platform I/O. Don't mix these concerns.

**Barrel firewall + import discipline.** `src/vim/index.ts` re-exports only the public surface (explicit named re-exports, no `export *`, no internal helpers). Sibling modules import each other directly (`./types`, `./state`, `./text`, `./tables`, `./util`) and must never import from the barrel `./index` — barrel-import + barrel-re-export is a circular-import trap that can hand back `undefined` at runtime. Nothing under `src/vim/` may touch the plugin `api`.

**Comments explain why, not what.** The code should read clearly without narration. Reserve comments for non-obvious decisions (like why `setTimeout` is needed for dispatch, or why `g` doesn't wait for a second keypress).

Expand All @@ -156,11 +176,11 @@ Branch naming: `type/description` — e.g. `feat/replace-char`, `fix/escape-hand

**Every mode transition emits a `mode` action.** The `Mode` type is the single source of truth for all displayable modes — including transient states like `"(insert)"` (one-shot normal). Never represent a mode as a separate boolean flag with a toast side-channel. If something changes what mode the user is in, it goes through the `Mode` type and a `{ type: "mode" }` action.

**Keep `vim.ts` under 500 lines.** If it grows past that, split by concern (motions, operators, insert entries). The handlers are already structured with clear sectionsthose become natural file boundaries.
**Keep each `src/vim/` module focused and under 500 lines.** The engine was split out of a single `vim.ts` once it crossed that line; the handlers already have clear internal sections, so if one grows past 500 again, split it further by concern. The barrel (`index.ts`) stays a pure re-export firewallnever add logic there.

**Shifted key translation** happens in `translateKey()` before the handler sees the key. Handlers work with normalized keys (`$` not `shift+4`, `G` not `shift+g`). Add new shift mappings in `translateKey`, not in handlers.

**TypeScript strictness.** `strict: true` in tsconfig. No `any` in `vim.ts` or `test/`. The `api` parameter in `index.ts` is typed as `any` because the plugin types come from peer deps that may not be installed locally — that's the one acceptable use.
**TypeScript strictness.** `strict: true` in tsconfig. No `any` in `src/vim/` or `test/`. The `api` parameter in `index.ts` is typed as `any` because the plugin types come from peer deps that may not be installed locally — that's the one acceptable use.

**Cross-platform.** All code must work on macOS, Linux, and Windows. No platform-specific assumptions without a runtime `process.platform` check and fallbacks for other platforms.

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version

## [Unreleased]

### Changed

- Split the vim engine from a single `src/vim.ts` into a modular `src/vim/` tree (`types`, `text`, `tables`, `util`, `state`, `insert`, `normal`, `visual`) behind a thin barrel. No behavior change.

### Fixed

- `dgg` and `drx` no longer leave a dangling delete operator that corrupted the next motion (fallout of consolidating the two pending-state fields into a single discriminated union).

## [0.16.0] — 2026-08-31

### Added
Expand Down
24 changes: 22 additions & 2 deletions biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,35 @@
"indentWidth": 2,
"lineWidth": 120
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"suspicious": {
"recommended": true,
"noExplicitAny": "error",
"noConsole": "error"
},
"style": {
"recommended": true,
"noNonNullAssertion": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
"quoteStyle": "double",
"semicolons": "always",
"trailingCommas": "all",
"arrowParentheses": "always"
}
}
}
Loading
Loading