From 4313c9ed865867ab5dab707aa335a8d99ce6b063 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 17:59:39 +0300 Subject: [PATCH 01/18] docs: add vim engine modularization and text-object plans --- .../2026-07-23-vim-engine-modularization.md | 700 ++++++++++++++++++ docs/plans/2026-07-23-vim-text-objects.md | 583 +++++++++++++++ 2 files changed, 1283 insertions(+) create mode 100644 docs/plans/2026-07-23-vim-engine-modularization.md create mode 100644 docs/plans/2026-07-23-vim-text-objects.md diff --git a/docs/plans/2026-07-23-vim-engine-modularization.md b/docs/plans/2026-07-23-vim-engine-modularization.md new file mode 100644 index 0000000..a3fcb9d --- /dev/null +++ b/docs/plans/2026-07-23-vim-engine-modularization.md @@ -0,0 +1,700 @@ +# Vim Engine Modularization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split the 656-line `src/vim.ts` into a focused `src/vim/` module tree with single-responsibility files, without changing any runtime behavior (Tasks 0–12). A trailing **Phase 4 (Task 13)** then consolidates the two pending fields into one `Pending` union — that step is a *deliberate, separately-committed behavioral change* (it removes untested dangling-operator states), explicitly outside the "no behavior change" guarantee. It lives here because it is a pure state-representation refactor of the just-extracted `state.ts`/handlers, and it is the prerequisite for the text-object feature plan. + +**Architecture:** `src/vim.ts` becomes a `src/vim/` directory. Pure concerns separate into leaf modules (`types`, `text`, `tables`, `util`), state lifecycle into `state`, and each key handler into its own file (`insert`, `normal`, `visual`). `src/vim/index.ts` is a thin barrel that re-exports only the public surface, so `./vim` keeps resolving and no consumer import changes. The existing 184-test suite is the safety net; every step keeps it green. + +**Tech Stack:** TypeScript (strict), Bun test runner, OpenCode TUI plugin API. + +## Global Constraints + +- Every step ends with `bun test` green. Baseline is 184 tests; Task 0 raises it. +- **Pure engine.** Nothing under `src/vim/` may touch the plugin `api`. Side effects stay in `src/index.ts`. +- **Import discipline.** Sibling modules import directly from each other (`./types`, `./state`, `./text`, `./tables`, `./util`). They must NEVER import from the barrel `./index`. (Barrel-import + barrel-re-export is the circular-import trap; Bun can hand back `undefined` at runtime.) +- **Barrel is a strict firewall.** `src/vim/index.ts` uses explicit named re-exports of the public surface only. No `export *`. Never re-export internal helpers (`resetPending`, `consumeCount`, `enterInsert`, `PASS`, `pushN`, `MOTIONS`, `SELECT_MOTIONS`, `DELETE_MOTION`). +- **Structural only.** Tasks 1-11 move code verbatim; they do not change behavior. The only content changes are Task 0 (new tests) and Task 10 (delete dead `_CONSUME`). +- **One module per commit.** Any bug found while hardening (Task 0) is fixed in its own commit, never mixed with an extraction. +- No `any` in `src/vim/` or `test/`. Keep every file under 500 lines. Cross-platform (macOS/Linux/Windows). + +## Public surface (what the barrel must re-export) + +Confirmed by grep — the only external consumers are `src/index.ts`, `src/leader.ts`, and the tests. + +- **Values:** `createVimState`, `toggleVimMode`, `translateKey`, `handleInsertKey`, `handleNormalKey`, `handleVisualKey`, `finishOneShotIfComplete`, `endOfWord` +- **Types:** `Action`, `VimState`, `KeyEvent`, `PromptAccess` + +`MOTIONS` and `SELECT_MOTIONS` are currently exported but used only inside `vim.ts`. They become internal to `tables.ts` and are NOT re-exported. + +## Target module structure + +`src/vim/` (all files pure, no `api` access): + +| File | Responsibility | Imports from | +|---|---|---| +| `types.ts` | `Action` union, `VimState`, `Mode`, `Operator`, `KeyEvent`, `HandlerResult`, `PromptAccess`. No logic. | (none) | +| `text.ts` | Pure string algorithms: `isWhitespace`, `charKind`, `endOfWord`, `currentLineRange`. Input string+offset → offset/range. (Landing zone for the future `wordRangeAt`.) | (none) | +| `tables.ts` | Static keybinding maps: `MOTIONS`, `SELECT_MOTIONS`, `DELETE_MOTION`. Pure data. | (none) | +| `util.ts` | Shared state-agnostic primitives: `translateKey`, `PASS`, `pushN`. | `types` | +| `state.ts` | VimState lifecycle + transitions only: `createVimState`, `toggleVimMode`, `finishOneShotIfComplete`, `resetPending`, `consumeCount`, `enterInsert`, `enterNormal`, `exitVisual`. | `types` | +| `insert.ts` | `handleInsertKey`. | `types`, `util` | +| `normal.ts` | `handleNormalKey`. Owns the (inlined) single-use helpers `finishUndoableChange` and `isInputEmpty`. | `types`, `util`, `state`, `text`, `tables` | +| `visual.ts` | `handleVisualKey`. | `types`, `util`, `state`, `text`, `tables` | +| `index.ts` | Barrel. Explicit named re-exports of the public surface. | all | + +Dependency graph is acyclic: `types` / `text` / `tables` are leaves; `util` and `state` depend only on `types`; handlers depend on `types` + `util` + `state` (+ `text` + `tables` for normal/visual); the barrel re-exports. + +### Where the council review changed the original proposal + +1. `state.ts` is scoped to VimState-only. The state-agnostic helpers move to `util.ts` (`translateKey`, `PASS`, `pushN`); the single-use helpers `finishUndoableChange` and `isInputEmpty` are inlined into `normal.ts`. +2. Import discipline + strict barrel firewall are now hard constraints (see Global Constraints). +3. Dead `_CONSUME` is deleted (Task 10). +4. `text.ts` is extracted first (Task 3) as the feature's landing zone; `currentLineRange` lives there and `charKind` is exported for the upcoming text-object work. +5. Test split gives the integration/undo tests their own file rather than forcing them under a handler (Task 11). + +## Out of scope + +The text-object feature (issue #57: `diw`/`ciw`/`yiw`/`viw`/`daw`) is a **separate plan** that runs after this refactor. This plan only restructures and hardens. + +## Extraction convention (Tasks 2-9) + +"Move symbol X" means: cut X verbatim from `src/vim/index.ts` into the target file, add the import header shown, then in the barrel add the named re-export shown and import anything the barrel still needs internally. After each task, `src/vim/index.ts` shrinks and the new module is self-contained. Run `bun test` and commit. + +**Per-task barrel invariant.** Each extraction must add the barrel re-export in the *same commit* it removes the definition. Between commits the barrel must always re-export the full public surface, or `src/index.ts` / `src/leader.ts` / the tests break mid-sequence and the "every step ends green" guarantee fails. + +--- + +### Task 0: Harden the remaining coverage gaps + +Pin every branch that later moves. Source is untouched here except where a gap reveals a real bug (fix that in a separate commit). + +**Files:** +- Modify: `test/vim.test.ts` (add tests to existing describe blocks) + +**Interfaces:** +- Consumes: `handleNormalKey`, `finishOneShotIfComplete`, `ev`, `cmds`, `mockPrompt`, `state` (all already in the test file). +- Produces: nothing new; raises `src/vim.ts` line coverage toward 100%. + +- [ ] **Step 1: Add the missing-branch tests** + +First verify what is actually uncovered — do not add duplicates. The current suite **already tests** `Ctrl+R` → `input.redo` and `O` opens-a-line-above; skip those two below. Confirm the rest are genuinely missing (run `bun test --coverage` and read the `vim.ts` gaps) before appending. Append only the real gaps to `test/vim.test.ts` in the matching describe blocks: + +```ts +// in: handleNormalKey — special keys +it("Ctrl+R dispatches input.redo", () => { + const result = handleNormalKey(state, "r", ev("r", { ctrl: true }), mockPrompt); + expect(result.consume).toBe(true); + expect(cmds(result.actions)).toEqual(["input.redo"]); +}); + +it("Enter in normal mode submits the prompt", () => { + const result = handleNormalKey(state, "return", ev("return"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.submit"]); +}); + +it("x deletes the character under the cursor", () => { + const result = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.delete"]); +}); + +it("3x deletes three characters", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + const result = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.delete", "input.delete", "input.delete"]); +}); + +// in: handleNormalKey — insert entries +it("O opens a line above and enters insert", () => { + const result = handleNormalKey(state, "O", ev("O"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.line.home", "input.newline", "input.move.up"]); + expect(result.actions.some((a) => a.type === "mode" && a.mode === "insert")).toBe(true); +}); + +// in: Ctrl+O one-shot normal mode +it("finishOneShotIfComplete does not double-append insert when the result already enters insert", () => { + state.oneShotNormal = true; + const result = { consume: true, actions: [{ type: "mode", mode: "insert" } as const] }; + finishOneShotIfComplete(state, result); + expect(state.oneShotNormal).toBe(false); + expect(result.actions.filter((a) => a.type === "mode" && a.mode === "insert").length).toBe(1); +}); +``` + +- [ ] **Step 2: Run the suite** + +Run: `bun test` +Expected: PASS. Count rises from the current baseline by however many of the proposed tests are genuinely new (fewer than six, since `Ctrl+R` and `O` are already covered). Don't treat an exact target number as a gate — the coverage delta in Step 3 is the real signal. + +- [ ] **Step 3: Confirm coverage closed** + +Run: `bun test --coverage` +Expected: `src/vim.ts` line coverage at/near 100%. The only permitted remaining gap is the operator+motion fallthrough (`vim.ts:411-412`): it is defensive code, unreachable because `j`/`k`/`G` are handled by earlier branches and every remaining `MOTIONS` key has a `DELETE_MOTION` entry. Leave it, with a one-line `// unreachable: every MOTIONS key reaching here has a DELETE_MOTION entry (j/k/G handled above)` comment. Do not contort a test to reach it. + +- [ ] **Step 4: Commit** + +```bash +git add test/vim.test.ts src/vim.ts +git commit -m "test: pin uncovered normal-mode branches before refactor" +``` + +--- + +### Task 1: Turn `vim.ts` into the directory barrel + +**Files:** +- Rename: `src/vim.ts` → `src/vim/index.ts` + +- [ ] **Step 1: Move the file, preserving history** + +```bash +git mv src/vim.ts src/vim/index.ts +``` + +- [ ] **Step 2: Verify resolution is unchanged** + +`src/index.ts` (`from "./vim"`), `src/leader.ts` (`from "./vim"`), and `test/vim.test.ts` (`from "../src/vim"`) all resolve to the new `src/vim/index.ts` automatically. + +Run: `bun test` +Expected: PASS (~190), no import edits needed. + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor: move vim.ts into src/vim/ directory barrel" +``` + +--- + +### Task 2: Extract `types.ts` + +**Files:** +- Create: `src/vim/types.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Produces: `Action`, `VimState`, `Mode`, `Operator`, `KeyEvent`, `HandlerResult`, `PromptAccess`. + +- [ ] **Step 1: Move the type declarations** + +Cut `Mode`, `Operator`, `Action`, `HandlerResult`, `VimState`, `KeyEvent`, `PromptAccess` (currently `index.ts` lines 1-49) into `src/vim/types.ts`. No imports needed — they are self-contained. + +- [ ] **Step 2: Re-wire the barrel** + +At the top of `src/vim/index.ts`: + +```ts +import type { Action, HandlerResult, KeyEvent, Mode, Operator, PromptAccess, VimState } from "./types"; + +export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; +``` + +(Import `Mode`/`Operator`/`HandlerResult` only because the code still living in the barrel references them; they are not re-exported.) + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract vim types into types.ts" +``` + +--- + +### Task 3: Extract `text.ts` (feature landing zone) + +**Files:** +- Create: `src/vim/text.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Produces: `endOfWord` (public), `charKind`, `isWhitespace`, `currentLineRange` (internal to the engine; consumed by handlers and, later, `wordRangeAt`). + +- [ ] **Step 1: Move the pure string functions** + +Cut `endOfWord`, `isWhitespace`, `charKind` (index.ts ~lines 123-150) and `currentLineRange` (~lines 617-623) into `src/vim/text.ts`. Export all four (`charKind` is exported now because the text-object work will classify boundary characters). No imports needed. + +- [ ] **Step 2: Re-wire the barrel** + +```ts +import { currentLineRange, endOfWord } from "./text"; + +export { endOfWord } from "./text"; +``` + +(Only `endOfWord` is public. `currentLineRange` is imported because the `V` handler still living in the barrel uses it.) + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract pure text algorithms into text.ts" +``` + +--- + +### Task 4: Extract `tables.ts` + +**Files:** +- Create: `src/vim/tables.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Produces: `MOTIONS`, `SELECT_MOTIONS`, `DELETE_MOTION` (all engine-internal). + +- [ ] **Step 1: Move the maps** + +Cut `MOTIONS`, `SELECT_MOTIONS` (index.ts lines 51-75) and `DELETE_MOTION` (lines 77-85) into `src/vim/tables.ts`. Export all three. No imports needed. + +- [ ] **Step 2: Re-wire the barrel** + +```ts +import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables"; +``` + +Not re-exported (internal). The handlers still in the barrel use them via this import for now. + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract keybinding tables into tables.ts" +``` + +--- + +### Task 5: Extract `util.ts` + +**Files:** +- Create: `src/vim/util.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Produces: `translateKey` (public), `PASS`, `pushN` (engine-internal). + +- [ ] **Step 1: Move the shared primitives** + +Cut `translateKey` (index.ts ~lines 152-162) and `pushN` (~lines 650-652) into `src/vim/util.ts`. Add `PASS` there too (currently defined ~line 87). Header: + +```ts +import type { Action, HandlerResult, KeyEvent } from "./types"; + +export const PASS: HandlerResult = { consume: false, actions: [] }; + +export function translateKey(ev: KeyEvent): string { /* moved verbatim */ } + +export function pushN(actions: Action[], cmd: string, n: number): void { /* moved verbatim */ } +``` + +- [ ] **Step 2: Re-wire the barrel** + +```ts +import { PASS, pushN, translateKey } from "./util"; + +export { translateKey } from "./util"; +``` + +Delete the old inline `PASS`/`pushN`/`translateKey` definitions from the barrel. + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract translateKey/PASS/pushN into util.ts" +``` + +--- + +### Task 6: Extract `state.ts` + +**Files:** +- Create: `src/vim/state.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Produces: `createVimState`, `toggleVimMode`, `finishOneShotIfComplete` (public); `resetPending`, `consumeCount`, `enterInsert`, `enterNormal`, `exitVisual` (engine-internal). + +- [ ] **Step 1: Move state lifecycle + transitions** + +Cut `createVimState`, `toggleVimMode`, `finishOneShotIfComplete`, `resetPending`, `consumeCount`, `enterInsert`, `enterNormal`, `exitVisual` into `src/vim/state.ts`. Header: + +```ts +import type { Action, HandlerResult, Mode, Operator, VimState } from "./types"; +``` + +Do NOT move `finishUndoableChange` or `isInputEmpty` here — they go into `normal.ts` in Task 8. + +- [ ] **Step 2: Re-wire the barrel** + +```ts +import { consumeCount, createVimState, enterInsert, enterNormal, exitVisual, resetPending } from "./state"; + +export { createVimState, finishOneShotIfComplete, toggleVimMode } from "./state"; +``` + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract VimState lifecycle into state.ts" +``` + +--- + +### Task 7: Extract `insert.ts` + +**Files:** +- Create: `src/vim/insert.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Consumes: `PASS` from `./util`; types from `./types`. +- Produces: `handleInsertKey`. + +- [ ] **Step 1: Move the handler** + +Cut `handleInsertKey` into `src/vim/insert.ts`. Header: + +```ts +import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { PASS } from "./util"; +``` + +- [ ] **Step 2: Re-wire the barrel** + +```ts +export { handleInsertKey } from "./insert"; +``` + +Remove the now-unused `PASS` import from the barrel if nothing else there references it. + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract handleInsertKey into insert.ts" +``` + +--- + +### Task 8: Extract `normal.ts` (with inlined single-use helpers) + +**Files:** +- Create: `src/vim/normal.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Consumes: `MOTIONS`, `DELETE_MOTION` from `./tables`; `endOfWord`, `currentLineRange` from `./text`; `PASS`, `pushN` from `./util`; `resetPending`, `consumeCount`, `enterInsert` from `./state`; types from `./types`. +- Produces: `handleNormalKey`. + +- [ ] **Step 1: Move the handler and inline its private helpers** + +Cut `handleNormalKey` into `src/vim/normal.ts`. Move `finishUndoableChange` and `isInputEmpty` here too (they are used only by this handler) as file-local functions. Header: + +```ts +import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { DELETE_MOTION, MOTIONS } from "./tables"; +import { currentLineRange, endOfWord } from "./text"; +import { PASS, pushN } from "./util"; +import { consumeCount, enterInsert, resetPending } from "./state"; + +function finishUndoableChange(actions: Action[]): HandlerResult { /* moved verbatim */ } +function isInputEmpty(prompt: PromptAccess): boolean { /* moved verbatim */ } +``` + +- [ ] **Step 2: Re-wire the barrel** + +```ts +export { handleNormalKey } from "./normal"; +``` + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract handleNormalKey into normal.ts" +``` + +--- + +### Task 9: Extract `visual.ts` + +**Files:** +- Create: `src/vim/visual.ts` +- Modify: `src/vim/index.ts` + +**Interfaces:** +- Consumes: `SELECT_MOTIONS` from `./tables`; `endOfWord` from `./text`; `pushN` from `./util`; `consumeCount`, `enterInsert`, `enterNormal`, `exitVisual` from `./state`; types from `./types`. +- Produces: `handleVisualKey`. + +- [ ] **Step 1: Move the handler** + +Cut `handleVisualKey` into `src/vim/visual.ts`. Header: + +```ts +import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { SELECT_MOTIONS } from "./tables"; +import { endOfWord } from "./text"; +import { PASS, pushN } from "./util"; +import { consumeCount, enterInsert, enterNormal, exitVisual } from "./state"; +``` + +- [ ] **Step 2: Re-wire the barrel** + +```ts +export { handleVisualKey } from "./visual"; +``` + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: extract handleVisualKey into visual.ts" +``` + +--- + +### Task 10: Finalize the barrel + drop dead code + +**Files:** +- Modify: `src/vim/index.ts` + +- [ ] **Step 1: Reduce the barrel to re-exports only** + +After Tasks 2-9, `src/vim/index.ts` should contain nothing but the public re-exports: + +```ts +export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; +export { endOfWord } from "./text"; +export { translateKey } from "./util"; +export { createVimState, finishOneShotIfComplete, toggleVimMode } from "./state"; +export { handleInsertKey } from "./insert"; +export { handleNormalKey } from "./normal"; +export { handleVisualKey } from "./visual"; +``` + +Delete any leftover internal imports. Confirm `_CONSUME` is gone (it was dead code; it must not have been carried into any module). + +- [ ] **Step 2: Verify the firewall** + +Two checks. First, the external shell must not reach any internal (anchor the fragile short names with `\b` so `PASS` doesn't match `bypass`/`compass`): + +Run: `grep -rnE "\b(MOTIONS|SELECT_MOTIONS|DELETE_MOTION|resetPending|consumeCount|PASS|pushN)\b" src/index.ts src/leader.ts` +Expected: no matches. The shell depends only on the public surface. + +Second, verify import discipline inside the engine — no sibling module imports the barrel (the circular-import trap): + +Run: `grep -rnE "from \"\.\/index\"|from \"\.\.\/vim\"" src/vim/` +Expected: no matches. Siblings import each other directly (`./types`, `./state`, …); only external consumers go through the barrel. + +- [ ] **Step 3: Verify + commit** + +```bash +bun test # PASS +git add -A +git commit -m "refactor: reduce vim barrel to public surface, drop dead _CONSUME" +``` + +--- + +### Task 11: Split the test suite to mirror the modules + +**Files:** +- Create: `test/support.ts` (assertion helpers), `test/fixtures.ts` (prompt fixtures) +- Create: `test/vim/text.test.ts`, `test/vim/state.test.ts`, `test/vim/util.test.ts`, `test/vim/insert.test.ts`, `test/vim/normal.test.ts`, `test/vim/visual.test.ts`, `test/integration.test.ts` +- Delete: `test/vim.test.ts` + +**Interfaces:** +- `test/support.ts` produces: `cmds`, `cursorTos`, `deleteRanges`, `saveUndoSnapshots`, `selectRanges`, `ev`. +- `test/fixtures.ts` produces: `mockPrompt`, `emptyPrompt`. + +- [ ] **Step 1: Extract shared test helpers** + +Move `cmds`/`cursorTos`/`deleteRanges`/`saveUndoSnapshots`/`selectRanges`/`ev` (current `vim.test.ts` lines 16-46) into `test/support.ts`, and `mockPrompt`/`emptyPrompt` (lines 48-62) into `test/fixtures.ts`. Export each. + +- [ ] **Step 2: Split describe blocks into per-module files (one at a time)** + +Do this **incrementally**, never as a big-bang rewrite: a dropped or duplicated test passes CI silently (a missing test doesn't fail — it just vanishes), so a green suite alone does NOT prove conservation. Record the exact test count at the end of Task 0 first (`bun test` prints it). Then, for each target file below: cut its describe block(s) out of `vim.test.ts`, paste into the new file, run `bun test`, and confirm the **total count is unchanged**. The original file shrinks toward empty as you go; delete it last (Step 3). + +Move each describe block to the file that matches the module under test, importing from `../../src/vim` (barrel) and helpers from `../support` / `../fixtures`: + +- `text.test.ts` ← `endOfWord`; add direct unit tests for `charKind`, `isWhitespace`, `currentLineRange` (now exported from `text.ts`, importable via the barrel or directly from `../../src/vim/text`). +- `state.test.ts` ← `createVimState`, `toggleVimMode`. +- `util.test.ts` ← `translateKey`. +- `insert.test.ts` ← `handleInsertKey`. +- `normal.test.ts` ← the `handleNormalKey` blocks (motions, g prefix, e motion, operators, dG/cG, shortcuts, special keys, replace, insert entries, yy, history, visual-mode entry). +- `visual.test.ts` ← `handleVisualKey` blocks (motions, operators, exit/passthrough). +- `integration.test.ts` ← `Ctrl+O one-shot`, `plugin init`, and `undo snapshot` blocks (these drive the full pipeline / `applyActions`). Keep the `version sync` block here or in a small `test/version.test.ts`. + +Each split file re-declares the shared setup it needs — `let state; beforeEach(() => { state = createVimState(); state.mode = "normal"; })` and a `createVimState` import — since only the pure assertion helpers and prompt fixtures are centralized in `test/support.ts` / `test/fixtures.ts`. + +Consider doing Task 11 as its own PR, separate from the code-move commits, so a mis-split is easy to bisect. + +- [ ] **Step 3: Delete the old file, verify counts** + +Only after every block has moved and `vim.test.ts` holds no live tests: + +```bash +rm test/vim.test.ts +bun test +``` +Expected: the **exact** total from the end of Task 0 (the split moves tests, it does not add or drop them), plus only the new `text.ts` unit tests you deliberately added in Step 2. Completion criterion is count parity, not merely "green" — if the number dropped, a test was lost in the split. + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "test: split vim tests to mirror the module layout" +``` + +--- + +### Task 12: Update docs + +**Files:** +- Modify: `AGENTS.md` (Architecture section, line counts, conventions) +- Modify: `CHANGELOG.md` (`[Unreleased]`) + +- [ ] **Step 1: Update AGENTS.md** + +Replace the `src/vim.ts (645 lines)` architecture block with the new `src/vim/` tree and per-file responsibilities. Update the data-flow diagram to reference the handler modules. Note the import-discipline and barrel-firewall rules under Code Conventions. Adjust the "Keep vim.ts under 500 lines" note to reflect the split. (AGENTS.md's current counts are stale — `src/vim.ts` is actually 656 lines, not 645, and the test-file line count is likewise off; re-measure with `wc -l` rather than trusting the old numbers.) + +- [ ] **Step 2: Update CHANGELOG.md** + +Under `[Unreleased]`, add: `### Changed — Split the vim engine into a modular src/vim/ tree (no behavior change).` + +- [ ] **Step 3: Run the full gate + commit** + +```bash +just check # lint + tests +git add AGENTS.md CHANGELOG.md +git commit -m "docs: document the modular vim engine layout" +``` + +--- + +## Phase 4 — Consolidate pending state (deliberate behavioral change) + +This phase is a state-representation refactor, distinct from the file moves in Tasks 1-12. It replaces the two orthogonal pending fields (`pendingOp` + `pendingChar`) with a single discriminated `Pending` union, so the two-fields-must-stay-consistent burden disappears and text objects get a correct home (Phase 4 is a prerequisite for the text-object feature plan). Do it after Task 12, on the already-extracted `types.ts` / `state.ts` / handler modules, **as its own commit (ideally its own PR)** so the behavior-preserving refactor (Tasks 1-12) stays independently bisectable. + +**This is NOT covered by the plan's "no runtime behavior change" banner.** It preserves every currently-*tested* behavior (the tests from Task 0 stay green), but it is a real behavior change: it removes untested *dangling-state* sequences the two-field model permits. `dgg` and `drx` today leave a stale operator set after the `g`/`r` branch runs, because the fields are independent — so `dgg` then `w` currently *deletes a word*. Collapsing to one field makes the `g`/`r` branch overwrite the operator, so the dangle is impossible by construction. Treat this as a bug fix, and pin both the cleared pending state and the corrected next-key behavior with new tests in Step 4. Real `dgg`-deletes-to-top support is out of scope (a later feature); this phase keeps `gg`'s current user-visible result (move to top, no delete). + +### Task 13: Replace `pendingOp` + `pendingChar` with a `Pending` union + +**Files:** +- Modify: `src/vim/types.ts`, `src/vim/state.ts`, `src/vim/normal.ts`, `src/vim/visual.ts` +- Modify: `test/vim/normal.test.ts`, `test/vim/visual.test.ts`, `test/vim/state.test.ts`, `test/integration.test.ts` + +**Interfaces:** +- Produces: the `Pending` union; `Operator` narrowed to non-null `"d" | "c" | "y"`. + +- [ ] **Step 1: Define the union in `types.ts`** + +```ts +export type Operator = "d" | "c" | "y"; // nullability moves into Pending + +export type Pending = + | { kind: "none" } + | { kind: "operator"; op: Operator } // replaces pendingOp + | { kind: "goto" } // replaces pendingChar === "g" + | { kind: "replace" }; // replaces pendingChar === "r" +``` + +In `VimState`, replace `pendingOp: Operator` and `pendingChar: "r" | "g" | null` with a single `pending: Pending`. + +- [ ] **Step 2: Update `state.ts`** + +- `createVimState`: initialize `pending: { kind: "none" }`. +- `resetPending`: `state.pending = { kind: "none" }; state.count = 0;`. +- `toggleVimMode`: reset to `{ kind: "none" }` where it currently clears the two fields. +- `finishOneShotIfComplete`: change the guard `state.pendingOp !== null || state.pendingChar !== null || state.count > 0` to `state.pending.kind !== "none" || state.count > 0`. + +- [ ] **Step 3: Translate the handler read/write sites (behavior identical)** + +| Old | New | +|---|---| +| `state.pendingOp = key` (d/c/y) | `state.pending = { kind: "operator", op: key }` | +| `state.pendingOp === "d"` / truthy | `state.pending.kind === "operator"` (op via `state.pending.op`) | +| `state.pendingOp === key` (dd/cc/yy) | `state.pending.kind === "operator" && state.pending.op === key` | +| `state.pendingChar = "g"` | `state.pending = { kind: "goto" }` | +| `state.pendingChar === "g"` | `state.pending.kind === "goto"` | +| `state.pendingChar = "r"` | `state.pending = { kind: "replace" }` | +| `state.pendingChar === "r"` | `state.pending.kind === "replace"` | + +The `g`/`r` branches now set `{ kind: "goto" }` / `{ kind: "replace" }`, which also clears any pending operator — that is the dangling-state cleanup (previously the operator leaked). + +**Narrowing gotcha.** After narrowing on `state.pending.kind === "operator"`, capture the operator into a local (`const op = state.pending.op`) at the top of the branch. `enterInsert`/`resetPending` reassign `state.pending`, which de-narrows the union — a later `state.pending.op` read in the same branch will not type-check. Read `op` once, then use the local. + +- [ ] **Step 4: Update field-asserting tests + pin the cleanup** + +Update the ~25-30 assertions that read `state.pendingOp` / `state.pendingChar` directly (the operators, g-prefix, and replace blocks). Add tests pinning the removed dangling state — both the cleared `pending` AND the corrected next keystroke (the latter is what actually regressed the two-field bug): + +```ts +it("dgg does not leave a dangling operator", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + expect(state.pending).toEqual({ kind: "none" }); +}); + +it("dgg then w moves by word (the old dangling 'd' would have deleted it)", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + const result = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.word.forward"]); // NOT input.delete.word.forward +}); + +it("drx replaces the char and clears pending", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "r", ev("r"), mockPrompt); + const result = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(state.pending).toEqual({ kind: "none" }); + expect(result.actions.some((a) => a.type === "insertText")).toBe(true); +}); + +it("drx then w moves by word (no dangling delete-operator)", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "r", ev("r"), mockPrompt); + handleNormalKey(state, "x", ev("x"), mockPrompt); + const result = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(result.actions)).toEqual(["input.word.forward"]); +}); +``` + +Also add a test for the `finishOneShotIfComplete` guard rewrite from Step 2 (a Ctrl+O one-shot with an operator pending must stay in normal mode, not auto-return to insert): + +```ts +it("Ctrl+O one-shot stays in normal mode while an operator is pending", () => { + state.oneShotNormal = true; + const result = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, result); + expect(state.mode).toBe("normal"); +}); +``` + +- [ ] **Step 5: Verify + commit** + +```bash +bun test # PASS — all previously-green tests plus the new pins +git add -A +git commit -m "refactor: consolidate pending state into a discriminated union" +``` + +--- + +## Self-Review + +- **Coverage of the goal.** Tasks 2-9 relocate every symbol in the current `vim.ts`; Task 10 leaves the barrel as pure re-exports; Task 11 mirrors the tests; Task 12 syncs docs. No symbol is orphaned. +- **Firewall + import discipline.** Enforced as Global Constraints and checked mechanically in Task 10 Step 2. Handlers import siblings directly; the barrel never feeds back into the engine. +- **Type/name consistency.** The public surface (8 values + 4 types) is fixed in the "Public surface" section and reproduced identically in every barrel re-export step. `MOTIONS`/`SELECT_MOTIONS`/`DELETE_MOTION` stay internal to `tables.ts`. `finishUndoableChange`/`isInputEmpty` land only in `normal.ts`. +- **Behavior preservation.** Tasks 1-12 are verbatim moves; only Task 0 (tests) and Task 10 (dead-code deletion) change content. Task 13 (Phase 4) is a state-representation change that preserves all tested behavior and deliberately removes untested dangling-state sequences — gated by `bun test` plus new pins. +- **Sequencing.** Tasks 1-12 (file split) → Task 13 (pending union) → then the separate text-object feature. `text.ts` (Task 3), the extracted `VimState`/handler seams, and the `Pending` union (Task 13) are all in place so the feature lands as a clean diff whose only new pending node is `{ kind: "textObject" }`. diff --git a/docs/plans/2026-07-23-vim-text-objects.md b/docs/plans/2026-07-23-vim-text-objects.md new file mode 100644 index 0000000..88974d0 --- /dev/null +++ b/docs/plans/2026-07-23-vim-text-objects.md @@ -0,0 +1,583 @@ +# Vim Text Objects Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement word text objects — `diw ciw yiw daw caw yaw` in normal mode and `viw vaw` in visual mode (issue #57). + +**Architecture:** A text object is a pure noun that resolves to a `Range`; operators are verbs that already consume ranges. Add one pure `resolveTextObject` to `text.ts`, one `textObject` node to the `Pending` union, one charwise `applyOperatorToRange` helper (also adopted by the existing `e`/`G` operator branches), and the `i`/`a` dispatch branches to the normal and visual handlers. + +**Tech Stack:** TypeScript (strict), Bun test runner, OpenCode TUI plugin API. + +## Dependencies + +This plan assumes the modularization plan (`2026-07-23-vim-engine-modularization.md`) is **fully applied, including Phase 4**. It relies on: +- `src/vim/text.ts` (home of `charKind`, `isWhitespace`, `endOfWord`, `currentLineRange`). +- `src/vim/types.ts` with the `Pending` discriminated union and non-null `Operator`. +- `src/vim/normal.ts` / `src/vim/visual.ts` and the split `test/vim/*.test.ts` layout. + +Do not start this plan until `bun test` is green on the post-refactor tree. + +## Global Constraints + +- Every step ends with `bun test` green. TDD: write the failing test, watch it fail, implement, watch it pass, commit. +- **Purity.** `resolveTextObject` and the word rules stay pure in `text.ts` and reuse the *same* `charKind`/`isWhitespace` classifier as `w`/`e`. No second word definition. +- **Range convention.** `Range { start, end }` is inclusive; invariant `0 <= start <= end < text.length`. "No object under the cursor" is `null`, never a zero-width sentinel. +- **Scope of `applyOperatorToRange`.** Charwise, offset-based ranges only (`e`, `G`, text objects). Never route linewise (`dd`/`dj`) or host-command motions (`dw`) through it. +- **`i`/`a` stay insert/append** when no operator is pending and not in visual mode. The text-object meaning only applies when `pending.kind === "operator"` (normal) or in visual mode. +- No `any`. Keep each file under 500 lines. Cross-platform. + +## Design decisions (from the council review) + +- `resolveTextObject` takes `count` from the start (mirrors `endOfWord`); omitting it is a later signature break. +- `TextObjectKind` is a plain string union (`"word"`) for now — no roadmap comment in the type. When the first delimiter-carrying kind (quote/bracket) lands, migrate to a tagged union then. +- No lookup table for a single kind; use an inline `key === "w"` check until a second kind exists. +- The `textObject` pending node carries `op: Operator | null` (`null` = visual). This is why Phase 4's union matters: one node serves both contexts. +- Visual `viw` **replaces** the selection with the resolved range (it does not extend from `visualAnchor` like the visual `e` motion). +- `applyOperatorToRange` is cursor-agnostic; text-object callers append `cursorTo(range.start)` (a no-op for forward motions, which is why `e`/`G` don't). +- Word classification reuses `charKind` (JS `\w`), so most non-ASCII letters classify as punctuation — the same behavior `w`/`e` already have. Text objects inherit it deliberately; revisit only if Unicode word support becomes a goal. + +--- + +### Task 1: `Range` + `resolveTextObject` for `iw` (inner word) + +**Files:** +- Modify: `src/vim/types.ts` (add `Range`, `TextObjectVariant`, `TextObjectKind`) +- Modify: `src/vim/text.ts` (add `resolveTextObject`) +- Modify: `test/vim/text.test.ts` + +**Interfaces:** +- Produces: `type Range = { start: number; end: number }`, `type TextObjectVariant = "inner" | "around"`, `type TextObjectKind = "word"`; `resolveTextObject(text, offset, kind, variant, count): Range | null` (this task handles `kind === "word"`, `variant === "inner"`, `count === 1`). + +- [ ] **Step 1: Write the failing tests** + +```ts +import { resolveTextObject } from "../../src/vim/text"; + +describe("resolveTextObject — inner word", () => { + it("word under cursor (start)", () => { + expect(resolveTextObject("hello world", 0, "word", "inner", 1)).toEqual({ start: 0, end: 4 }); + }); + it("word under cursor (mid-word)", () => { + expect(resolveTextObject("hello world", 2, "word", "inner", 1)).toEqual({ start: 0, end: 4 }); + }); + it("second word", () => { + expect(resolveTextObject("hello world", 6, "word", "inner", 1)).toEqual({ start: 6, end: 10 }); + }); + it("cursor on whitespace selects the whitespace run", () => { + expect(resolveTextObject("a b", 1, "word", "inner", 1)).toEqual({ start: 1, end: 3 }); + }); + it("punctuation run is its own object", () => { + expect(resolveTextObject("foo.bar", 3, "word", "inner", 1)).toEqual({ start: 3, end: 3 }); + expect(resolveTextObject("a...b", 1, "word", "inner", 1)).toEqual({ start: 1, end: 3 }); + }); + it("single-char word", () => { + expect(resolveTextObject("a b", 0, "word", "inner", 1)).toEqual({ start: 0, end: 0 }); + }); + it("empty buffer returns null", () => { + expect(resolveTextObject("", 0, "word", "inner", 1)).toBeNull(); + }); + it("word stops at newline", () => { + expect(resolveTextObject("ab\ncd", 0, "word", "inner", 1)).toEqual({ start: 0, end: 1 }); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `bun test test/vim/text.test.ts` +Expected: FAIL (`resolveTextObject` not exported). + +- [ ] **Step 3: Implement inner-word resolution** + +Add to `src/vim/text.ts`, reusing `charKind`: + +```ts +import type { Range, TextObjectKind, TextObjectVariant } from "./types"; + +export function resolveTextObject( + text: string, + offset: number, + kind: TextObjectKind, + variant: TextObjectVariant, + count = 1, +): Range | null { + if (text.length === 0) return null; + const pos = Math.min(Math.max(offset, 0), text.length - 1); + // kind === "word" only for now + const kindAt = charKind(text[pos]); + let start = pos; + let end = pos; + while (start > 0 && charKind(text[start - 1]) === kindAt) start--; + while (end < text.length - 1 && charKind(text[end + 1]) === kindAt) end++; + if (variant === "inner") return { start, end }; + return { start, end }; // "around" — replaced by aroundWord in Task 2 +} +``` + +(`Range`, `TextObjectVariant`, and `TextObjectKind` all land in `types.ts` in this task — pure, dependency-free aliases. Task 2 swaps the `"around"` placeholder for the real `aroundWord`.) + +- [ ] **Step 4: Run tests, verify pass** + +Run: `bun test test/vim/text.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/vim/types.ts src/vim/text.ts test/vim/text.test.ts +git commit -m "feat: resolveTextObject for inner word" +``` + +--- + +### Task 2: `aw` (around word) with trailing-else-leading whitespace + +**Files:** +- Modify: `src/vim/text.ts` (add `aroundWord`) +- Modify: `test/vim/text.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```ts +describe("resolveTextObject — a word", () => { + it("word plus trailing whitespace", () => { + expect(resolveTextObject("hello world", 0, "word", "around", 1)).toEqual({ start: 0, end: 5 }); + }); + it("last word takes leading whitespace when no trailing", () => { + expect(resolveTextObject("hello world", 6, "word", "around", 1)).toEqual({ start: 5, end: 10 }); + }); + it("lone word with neither side falls back to the word", () => { + expect(resolveTextObject("hello", 0, "word", "around", 1)).toEqual({ start: 0, end: 4 }); + }); + it("multiple trailing spaces are all included", () => { + expect(resolveTextObject("a b", 0, "word", "around", 1)).toEqual({ start: 0, end: 3 }); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `bun test test/vim/text.test.ts` +Expected: FAIL. + +- [ ] **Step 3: Implement `aroundWord`** + +```ts +function aroundWord(text: string, start: number, end: number): Range { + // trailing whitespace first + let e = end; + while (e < text.length - 1 && isWhitespace(text[e + 1]) && text[e + 1] !== "\n") e++; + if (e > end) return { start, end: e }; + // no trailing → take leading whitespace + let s = start; + while (s > 0 && isWhitespace(text[s - 1]) && text[s - 1] !== "\n") s--; + return { start: s, end }; +} +``` + +Then wire it into `resolveTextObject`, replacing the Task 1 `"around"` placeholder (`return { start, end };`) with the explicit branch: + +```ts +if (variant === "inner") return { start, end }; +return aroundWord(text, start, end); +``` + +(Do not leave this implicit. A subagent executor that reads "swaps the placeholder" without the concrete line may leave the placeholder in place; the `daw` test in Task 6 would then silently collapse to `diw`.) + +- [ ] **Step 4: Run tests, verify pass** — `bun test test/vim/text.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/vim/text.ts test/vim/text.test.ts +git commit -m "feat: resolveTextObject for a-word (trailing/leading whitespace)" +``` + +--- + +### Task 3: `count` support + +**Files:** +- Modify: `src/vim/text.ts` +- Modify: `test/vim/text.test.ts` + +`2iw` extends across alternating word/whitespace runs (vim semantics: each run — word, punct, or whitespace — is one object). + +- [ ] **Step 1: Write the failing tests** + +```ts +describe("resolveTextObject — count", () => { + it("2iw spans word + following whitespace", () => { + expect(resolveTextObject("one two", 0, "word", "inner", 2)).toEqual({ start: 0, end: 3 }); + }); + it("3iw spans word + whitespace + word", () => { + expect(resolveTextObject("one two", 0, "word", "inner", 3)).toEqual({ start: 0, end: 6 }); + }); + it("2iw spans a word then a punctuation run (kinds are re-read, not toggled)", () => { + expect(resolveTextObject("foo.bar", 0, "word", "inner", 2)).toEqual({ start: 0, end: 3 }); + }); +}); +``` + +- [ ] **Step 2: Run, verify fail.** `bun test test/vim/text.test.ts` → FAIL. + +- [ ] **Step 3: Implement count** — after finding the first run's `end`, extend `end` forward `count - 1` more runs. At each new boundary **re-read `charKind(text[end + 1])` and extend while the kind stays equal** — do NOT flip a two-state word/space toggle. There are three kinds (`word`/`punct`/`space`), so runs do not simply alternate: `2iw` on `"foo.bar"` is `foo` then the `.` punct run → `{0,3}`, which a boolean flip gets wrong. `count === 1` keeps Task 1/2 behavior. + + **count + `around` ordering:** compute the counted *inner* range first (extend `end` across `count - 1` runs), THEN apply `aroundWord` to the final `[start, end]`. State this explicitly so `d2aw` is deterministic. Note: `count === 1` already satisfies issue #57; `count > 1` for `around` is polish and is left untested, but the ordering must still be pinned in code so the behavior is defined rather than accidental. + +- [ ] **Step 4: Run, verify pass.** `bun test test/vim/text.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/vim/text.ts test/vim/text.test.ts +git commit -m "feat: count support for word text objects" +``` + +--- + +### Task 4: Text-object types + the `textObject` pending node + +**Files:** +- Modify: `src/vim/types.ts` +- Modify: `test/vim/state.test.ts` (createVimState still `{ kind: "none" }`) + +**Interfaces:** +- Produces: the new `Pending` member `{ kind: "textObject"; op: Operator | null; variant: TextObjectVariant }`. (`TextObjectVariant`/`TextObjectKind` were added in Task 1.) + +- [ ] **Step 1: Extend the types** + +```ts +// TextObjectVariant / TextObjectKind already exist from Task 1. +export type Pending = + | { kind: "none" } + | { kind: "operator"; op: Operator } + | { kind: "textObject"; op: Operator | null; variant: TextObjectVariant } // op:null = visual + | { kind: "goto" } + | { kind: "replace" }; +``` + +- [ ] **Step 2: Verify + commit** + +```bash +bun test # PASS (no behavior yet; type only) +git add src/vim/types.ts +git commit -m "feat: add textObject node to the Pending union" +``` + +--- + +### Task 5: Extract `applyOperatorToRange` (adopt in `e`/`G` first) + +**Files:** +- Modify: `src/vim/normal.ts` +- Modify: `test/vim/normal.test.ts` + +Behavior-preserving refactor: fold the duplicated operator-on-range logic from the `e` and `G` branches into one helper. Existing tests must stay green with no output change. + +**Interfaces:** +- Produces: `applyOperatorToRange(state, op, range, text, actions): HandlerResult` (file-local to `normal.ts`). + +- [ ] **Step 1: Write the helper** + +```ts +function applyOperatorToRange( + state: VimState, op: Operator, range: Range, text: string, actions: Action[], +): HandlerResult { + if (op === "y") { + const slice = text.slice(range.start, range.end + 1); + state.yankRegister = slice; + actions.push({ type: "yank", text: slice }); + resetPending(state); + return { consume: true, actions }; + } + actions.push({ type: "deleteRange", start: range.start, end: range.end }); + if (op === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); +} +``` + +- [ ] **Step 2: Rewrite the `e` and `G` operator branches to call it** + +Capture the operator locally (`const op = state.pending.op`, after the `kind === "operator"` narrowing) and add `Range` to `normal.ts`'s `import type … from "./types"`. Replace the **entire** inline body of each branch — including its `y` special-case, which the helper now owns — with: +```ts +const target = endOfWord(prompt.getPlainText(), offset, n); // (e) — G uses Math.max(0, text.length - 1) +return applyOperatorToRange(state, op, { start: offset, end: target }, prompt.getPlainText(), actions); +``` +`range.start === offset === cursor` here, so no `cursorTo` and the emitted actions are byte-identical to today. + +**`yG` caveat.** `yG` never reaches the `G` sub-branch — it is caught earlier by the generic `pendingOp === "y" && key in MOTIONS` select path (`SELECT_MOTIONS[G]` + `yankSelection`, no snapshot). So the `G` branch you are rewriting only ever runs for `d`/`c`, and `applyOperatorToRange`'s `y`-branch is dead for `G`. Keep that earlier `y`-select branch intact and above the `G` rewrite; do NOT route `yG` through `applyOperatorToRange` (that would swap a selection-yank for a slice-yank and drop the toast). For `e`, by contrast, the helper's `y`-branch legitimately replaces the current `ye` slice-yank (byte-identical). + +- [ ] **Step 3: Run tests, verify no change** + +Run: `bun test test/vim/normal.test.ts` +Expected: PASS, identical assertions (the `de`, `dG`, `ye`, `cG` tests). + +- [ ] **Step 4: Commit** + +```bash +git add src/vim/normal.ts +git commit -m "refactor: extract applyOperatorToRange, adopt in e/G branches" +``` + +--- + +### Task 6: Normal-mode text objects + +**Files:** +- Modify: `src/vim/normal.ts` +- Modify: `test/vim/normal.test.ts` + +**Interfaces:** +- Consumes: `resolveTextObject`, `Range` from `./text`/`./types`; `applyOperatorToRange` (Task 5); `Pending` (Task 4). + +- [ ] **Step 1: Write the failing tests** + +```ts +describe("handleNormalKey — text objects", () => { + const on = (text: string, cur: number) => ({ ...mockPrompt, getPlainText: () => text, getCursorOffset: () => cur }); + + it("diw deletes the inner word as one range", () => { + const p = on("hello world", 2); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.pending).toEqual({ kind: "none" }); + }); + + it("ciw deletes the inner word, enters insert, cursor at word start", () => { + const p = on("hello world", 2); + handleNormalKey(state, "c", ev("c"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(cursorTos(r.actions)).toEqual([0]); + expect(r.actions.some((a) => a.type === "mode" && a.mode === "insert")).toBe(true); + }); + + it("yiw yanks the inner word and moves the cursor to word start", () => { + const p = on("hello world", 2); + handleNormalKey(state, "y", ev("y"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(state.yankRegister).toBe("hello"); + expect(cursorTos(r.actions)).toEqual([0]); + }); + + it("daw deletes word plus trailing whitespace", () => { + const p = on("hello world", 0); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "a", ev("a"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 5 }]); + }); + + it("d2iw deletes across two objects (word + whitespace)", () => { + const p = on("one two", 0); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "2", ev("2"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 3 }]); + expect(state.pending).toEqual({ kind: "none" }); + }); + + it("di on empty buffer no-ops and clears pending", () => { + const p = on("", 0); + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + const r = handleNormalKey(state, "w", ev("w"), p); + expect(deleteRanges(r.actions)).toEqual([]); + expect(state.pending).toEqual({ kind: "none" }); + }); + + it("i with no operator still enters insert", () => { + const r = handleNormalKey(state, "i", ev("i"), mockPrompt); + expect(r.actions.some((a) => a.type === "mode" && a.mode === "insert")).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests, verify they fail** — `bun test test/vim/normal.test.ts` → FAIL. + +- [ ] **Step 3: Implement the branches** + +**Placement (critical).** The resolve branch must run BEFORE the standalone `key in MOTIONS` branch and (after Phase 4) the `pending.kind === "operator" && key in MOTIONS` branch — otherwise `w` is consumed as a motion before it can resolve as a text object. Put it immediately AFTER the `const actions: Action[] = []` declaration (so `actions` is in scope) and before the count-accumulation / motion branches. It reuses that `actions`; it does not declare a new one. + +Resolve a pending text object: +```ts +if (state.pending.kind === "textObject") { + const { op, variant } = state.pending; + const text = prompt.getPlainText(); + const n = consumeCount(state); + const range = key === "w" ? resolveTextObject(text, prompt.getCursorOffset(), "word", variant, n) : null; + if (!range) { resetPending(state); return { consume: true, actions }; } + if (op === null) { resetPending(state); return { consume: true, actions }; } // load-bearing narrow (see note); visual op:null never reaches here + const res = applyOperatorToRange(state, op, range, text, actions); + res.actions.push({ type: "cursorTo", offset: range.start }); + return res; +} +``` + +Notes: +- **`actions` scope.** `const actions: Action[] = []` is declared partway down `handleNormalKey`, after the tab / `replace` / `goto` pending checks (which each use their own local `actions`). This branch references `actions`, so it MUST sit after that declaration. Do not place it "beside goto/replace" — those run above the declaration, so referencing `actions` there is a temporal-dead-zone error and will not compile. +- **`op === null` is not dead code.** After `const { op } = state.pending`, `op` is `Operator | null` (the `textObject` node carries `op: Operator | null` so one node serves both normal and visual). The early return narrows `op` to non-null `Operator` for the `applyOperatorToRange` call; removing it is a type error. (Alternative if you dislike the runtime-dead guard: split the union into `{kind:"operatorTextObject"; op: Operator}` + `{kind:"visualTextObject"}` and drop the null case — more honest typing at the cost of one extra member and a second dispatch branch.) +- **`cursorTo(range.start)` is intentional for all three operators, including `y`.** Vim leaves the cursor at the start of the yanked/changed region, so `yiw` with a mid-word cursor moves to the word start; for `ye`/`yG` it is a no-op (`range.start === cursor`). Keep it unconditional. + +Then, inside the operator-pending section (before the standalone `i`/`a` insert entries), intercept the variant prefix: +```ts +if (state.pending.kind === "operator" && (key === "i" || key === "a")) { + state.pending = { kind: "textObject", op: state.pending.op, variant: key === "i" ? "inner" : "around" }; + return { consume: true, actions }; +} +``` +Leave the standalone `i`/`a` insert-entry branches unchanged — they now only run when `pending.kind !== "operator"`. + +- [ ] **Step 4: Run tests, verify pass** — `bun test test/vim/normal.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/vim/normal.ts test/vim/normal.test.ts +git commit -m "feat: normal-mode word text objects (diw/ciw/yiw/daw...)" +``` + +--- + +### Task 7: Visual-mode text objects + +**Files:** +- Modify: `src/vim/visual.ts` +- Modify: `test/vim/visual.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```ts +describe("handleVisualKey — text objects", () => { + const on = (text: string, cur: number) => ({ ...mockPrompt, getPlainText: () => text, getCursorOffset: () => cur }); + beforeEach(() => { state.mode = "visual"; state.visualAnchor = 2; }); + + it("viw selects the inner word range (replaces selection)", () => { + const p = on("hello world", 2); + handleVisualKey(state, "i", ev("i"), p); + const r = handleVisualKey(state, "w", ev("w"), p); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.visualAnchor).toBe(0); + expect(cursorTos(r.actions)).toEqual([4]); + expect(state.mode).toBe("visual"); + }); + + it("vaw selects word plus trailing whitespace", () => { + const p = on("hello world", 0); + state.visualAnchor = 0; + handleVisualKey(state, "a", ev("a"), p); + const r = handleVisualKey(state, "w", ev("w"), p); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 5 }]); + }); +}); +``` + +- [ ] **Step 2: Run, verify fail** — `bun test test/vim/visual.test.ts` → FAIL. + +- [ ] **Step 3: Implement the visual branches** + +```ts +if (state.pending.kind === "textObject") { + const { variant } = state.pending; + const range = key === "w" + ? resolveTextObject(prompt.getPlainText(), prompt.getCursorOffset(), "word", variant, consumeCount(state)) + : null; + resetPending(state); + if (!range) return { consume: true, actions }; + state.visualAnchor = range.start; + actions.push({ type: "selectRange", start: range.start, end: range.end }); + actions.push({ type: "cursorTo", offset: range.end }); + return { consume: true, actions }; +} +if (key === "i" || key === "a") { + state.pending = { kind: "textObject", op: null, variant: key === "i" ? "inner" : "around" }; + return { consume: true, actions }; +} +``` +**Placement (critical).** `w` is a key in `SELECT_MOTIONS` (`input.select.word.forward`), so both branches must go BEFORE the `if (key in SELECT_MOTIONS)` check — otherwise `viw`'s `w` is swallowed by the select-motion dispatch and never resolves. Put them AFTER the `escape` / `v` exit check (so `vi` then `` still exits visual) but BEFORE the `key in SELECT_MOTIONS` branch. "Before the catch-all consume" is too loose — do not rely on it. (`i`/`a` were previously swallowed by the catch-all in visual, so nothing regresses.) Add a `viw` test asserting `selectRange` (not `input.select.word.forward`) to pin the ordering in the suite. + +- [ ] **Step 4: Run, verify pass** — `bun test test/vim/visual.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/vim/visual.ts test/vim/visual.test.ts +git commit -m "feat: visual-mode word text objects (viw/vaw)" +``` + +--- + +### Task 8: Interaction hardening + +**Files:** +- Modify: `test/vim/normal.test.ts`, `test/integration.test.ts` +- Modify: `src/vim/*` only if a test surfaces a gap + +- [ ] **Step 1: Write interaction tests** + +```ts +it("escape after 'di' cancels the pending text object", () => { + const p = { ...mockPrompt, getPlainText: () => "hello", getCursorOffset: () => 0 }; + handleNormalKey(state, "d", ev("d"), p); + handleNormalKey(state, "i", ev("i"), p); + handleNormalKey(state, "escape", ev("escape"), p); + expect(state.pending).toEqual({ kind: "none" }); +}); + +it("Ctrl+O one-shot does not auto-return to insert mid text-object (d then i)", () => { + // pending.kind === "textObject" must count as 'still pending' in finishOneShotIfComplete + state.oneShotNormal = true; + const p = { ...mockPrompt, getPlainText: () => "hello world", getCursorOffset: () => 0 }; + let r = handleNormalKey(state, "d", ev("d"), p); finishOneShotIfComplete(state, r); + r = handleNormalKey(state, "i", ev("i"), p); finishOneShotIfComplete(state, r); + expect(state.mode).toBe("normal"); // not yet returned to insert +}); +``` + +- [ ] **Step 2: Run** — `bun test` → confirm behavior. If the one-shot test fails, verify `finishOneShotIfComplete`'s guard reads `state.pending.kind !== "none"` (it should, after Phase 4). Escape handling: the normal-mode `escape` branch already calls `resetPending`, which clears the `textObject` node. + +- [ ] **Step 3: Full suite** — `bun test` → PASS. + +- [ ] **Step 4: Commit** + +```bash +git add test/ +git commit -m "test: text-object interactions with escape and one-shot mode" +``` + +--- + +### Task 9: Docs + issue + +**Files:** +- Modify: `README.md` (keybinding tables; remove any text-object entry from "Known gaps") +- Modify: `CHANGELOG.md` (`[Unreleased]`) +- Modify: `AGENTS.md` if the resolver/helper is worth a note + +- [ ] **Step 1: README** — add `iw`/`aw` with `d`/`c`/`y`/`v` to the tables. +- [ ] **Step 2: CHANGELOG** — `### Added — Word text objects: diw, ciw, yiw, daw, viw, vaw (#57).` +- [ ] **Step 3: Gate + commit** + +```bash +just check +git add README.md CHANGELOG.md AGENTS.md +git commit -m "docs: document word text objects (#57)" +``` + +--- + +## Self-Review + +- **Spec coverage.** `iw`/`aw` resolution (Tasks 1-3), types + pending node (Task 4), operator helper (Task 5), normal `d/c/y` (Task 6), visual `v` (Task 7), interactions (Task 8), docs (Task 9). The issue's `diw ciw viw daw` are covered by Tasks 6-7. +- **Type/name consistency.** `resolveTextObject(text, offset, kind, variant, count)` is defined once (Task 1) and every call site passes `count` (Tasks 6-7). `Range` is defined in Task 1 and reused by the helper and both handlers. The `textObject` pending node's `op: Operator | null` is set with the operator in normal (Task 6) and `null` in visual (Task 7), and read in exactly those two resolve branches. +- **Reused word definition.** The resolver calls the same `charKind`/`isWhitespace` as `w`/`e`; no divergent classifier. +- **`applyOperatorToRange` scope.** Charwise only; adopted by `e`/`G` behavior-preservingly (Task 5) before text objects use it (Task 6). Visual selection is a separate branch (Task 7), not routed through the helper. +- **`i`/`a` safety.** Intercepted only when `pending.kind === "operator"` (normal) or in visual mode; the standalone insert/append entries are otherwise untouched (Task 6 Step 3, verified by the "i with no operator still enters insert" test). +- **Cursor + undo.** `c`/text-object callers append `cursorTo(range.start)`; `d`/`c` flow through `finishUndoableChange` (via the helper); `y` skips the snapshot. Empty-buffer and escape paths clear pending and emit no destructive actions. From 381320208de3c408c6a51685fc503545d97a40be Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:02:07 +0300 Subject: [PATCH 02/18] test: pin uncovered normal-mode branches before refactor --- src/vim.ts | 1 + test/vim.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/vim.ts b/src/vim.ts index f3e01d6..fbca88f 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -408,6 +408,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return finishUndoableChange(actions); } + // unreachable: every MOTIONS key reaching here has a DELETE_MOTION entry (j/k/G handled above) resetPending(state); return { consume: true, actions }; } diff --git a/test/vim.test.ts b/test/vim.test.ts index 70dc454..d8a983f 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -669,6 +669,22 @@ describe("handleNormalKey — special keys", () => { expect(cmds(r.actions)).toEqual(["input.redo"]); }); + it("Enter in normal mode submits the prompt", () => { + const r = handleNormalKey(state, "return", ev("return"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.submit"]); + }); + + it("x deletes the character under the cursor", () => { + const r = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete"]); + }); + + it("3x deletes three characters", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + const r = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete", "input.delete", "input.delete"]); + }); + it("p with yankRegister set pastes", () => { state.yankRegister = "yanked text\n"; const r = handleNormalKey(state, "p", ev("p"), mockPrompt); @@ -1245,6 +1261,14 @@ describe("Ctrl+O one-shot normal mode", () => { finishOneShotIfComplete(state, r); expect(state.mode).toBe("normal"); }); + + it("finishOneShotIfComplete does not double-append insert when the result already enters insert", () => { + state.oneShotNormal = true; + const result = { consume: true, actions: [{ type: "mode", mode: "insert" } as const] }; + finishOneShotIfComplete(state, result); + expect(state.oneShotNormal).toBe(false); + expect(result.actions.filter((a) => a.type === "mode" && a.mode === "insert").length).toBe(1); + }); }); describe("version sync", () => { From e4ca1579afe33f09b0eeab3921640e3f8eabf94f Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:04:19 +0300 Subject: [PATCH 03/18] refactor: move vim.ts into src/vim/ directory barrel --- src/{vim.ts => vim/index.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{vim.ts => vim/index.ts} (100%) diff --git a/src/vim.ts b/src/vim/index.ts similarity index 100% rename from src/vim.ts rename to src/vim/index.ts From 2c8e27c9e18408111296e81cf4f78628553272c8 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:04:44 +0300 Subject: [PATCH 04/18] refactor: extract vim types into types.ts --- src/vim/index.ts | 50 ++---------------------------------------------- src/vim/types.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 48 deletions(-) create mode 100644 src/vim/types.ts diff --git a/src/vim/index.ts b/src/vim/index.ts index fbca88f..e8b5bc1 100644 --- a/src/vim/index.ts +++ b/src/vim/index.ts @@ -1,52 +1,6 @@ -export type Mode = "normal" | "insert" | "visual" | "(insert)"; -export type Operator = "d" | "c" | "y" | null; - -export type Action = - | { type: "cmd"; cmd: string } - | { type: "mode"; mode: Mode } - | { type: "toast"; message: string; duration?: number } - | { type: "yank"; text: string } - | { type: "insertText"; text: string } - | { type: "yankSelection" } - | { type: "clearSelection" } - | { type: "deleteRange"; start: number; end: number } - | { type: "saveUndoSnapshot" } - | { type: "undo" } - | { type: "cursorTo"; offset: number } - | { type: "selectRange"; start: number; end: number }; - -export type HandlerResult = { - consume: boolean; - actions: Action[]; -}; - -export type VimState = { - mode: Mode; - pendingOp: Operator; - pendingChar: "r" | "g" | null; - count: number; - yankRegister: string; - oneShotNormal: boolean; - disabled: boolean; - visualAnchor?: number; -}; +import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; -export type KeyEvent = { - name: string; - shift?: boolean; - ctrl?: boolean; - meta?: boolean; - super?: boolean; - eventType?: string; -}; - -export type PromptAccess = { - getLine: (n: number) => string; - getLineCount: () => number; - getCursorLine: () => number; - getCursorOffset: () => number; - getPlainText: () => string; -}; +export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; export const MOTIONS: Record = { h: "input.move.left", diff --git a/src/vim/types.ts b/src/vim/types.ts new file mode 100644 index 0000000..7ca9f76 --- /dev/null +++ b/src/vim/types.ts @@ -0,0 +1,49 @@ +export type Mode = "normal" | "insert" | "visual" | "(insert)"; +export type Operator = "d" | "c" | "y" | null; + +export type Action = + | { type: "cmd"; cmd: string } + | { type: "mode"; mode: Mode } + | { type: "toast"; message: string; duration?: number } + | { type: "yank"; text: string } + | { type: "insertText"; text: string } + | { type: "yankSelection" } + | { type: "clearSelection" } + | { type: "deleteRange"; start: number; end: number } + | { type: "saveUndoSnapshot" } + | { type: "undo" } + | { type: "cursorTo"; offset: number } + | { type: "selectRange"; start: number; end: number }; + +export type HandlerResult = { + consume: boolean; + actions: Action[]; +}; + +export type VimState = { + mode: Mode; + pendingOp: Operator; + pendingChar: "r" | "g" | null; + count: number; + yankRegister: string; + oneShotNormal: boolean; + disabled: boolean; + visualAnchor?: number; +}; + +export type KeyEvent = { + name: string; + shift?: boolean; + ctrl?: boolean; + meta?: boolean; + super?: boolean; + eventType?: string; +}; + +export type PromptAccess = { + getLine: (n: number) => string; + getLineCount: () => number; + getCursorLine: () => number; + getCursorOffset: () => number; + getPlainText: () => string; +}; From 734129b7928d943bea7a74c9e1b003e65566c2a5 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:06:20 +0300 Subject: [PATCH 05/18] refactor: extract pure text algorithms into text.ts --- src/vim/index.ts | 39 ++------------------------------------- src/vim/text.ts | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 37 deletions(-) create mode 100644 src/vim/text.ts diff --git a/src/vim/index.ts b/src/vim/index.ts index e8b5bc1..85d80fe 100644 --- a/src/vim/index.ts +++ b/src/vim/index.ts @@ -1,5 +1,7 @@ +import { currentLineRange, endOfWord } from "./text"; import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +export { endOfWord } from "./text"; export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; export const MOTIONS: Record = { @@ -74,35 +76,6 @@ export function toggleVimMode(state: VimState): HandlerResult { return { consume: true, actions: [{ type: "toast", message: "Vim mode enabled" }] }; } -export function endOfWord(text: string, offset: number, count = 1): number { - const len = text.length; - if (len === 0) return 0; - let pos = offset; - for (let step = 0; step < count; step++) { - // If inside a word/punct run, advance one to start looking for next end - if (pos < len - 1 && charKind(text[pos]) !== "space") { - pos++; - } - // Skip whitespace - while (pos < len && isWhitespace(text[pos])) pos++; - if (pos >= len) return len - 1; - // Find end of current word class run - const kind = charKind(text[pos]); - while (pos + 1 < len && charKind(text[pos + 1]) === kind) pos++; - } - return Math.min(pos, len - 1); -} - -function isWhitespace(ch: string): boolean { - return ch === " " || ch === "\t" || ch === "\n" || ch === "\r"; -} - -function charKind(ch: string): "word" | "punct" | "space" { - if (isWhitespace(ch)) return "space"; - if (/\w/.test(ch)) return "word"; - return "punct"; -} - export function translateKey(ev: KeyEvent): string { let key = ev.name; if (ev.shift && ev.name.length === 1) { @@ -569,14 +542,6 @@ function finishUndoableChange(actions: Action[]): HandlerResult { return { consume: true, actions: [{ type: "saveUndoSnapshot" }, ...actions] }; } -function currentLineRange(text: string, offset: number): { start: number; end: number } { - if (text.length === 0) return { start: 0, end: 0 }; - const safeOffset = Math.min(Math.max(offset, 0), text.length - 1); - const start = text.lastIndexOf("\n", safeOffset - 1) + 1; - const newline = text.indexOf("\n", safeOffset); - return { start, end: newline === -1 ? text.length - 1 : newline }; -} - function consumeCount(state: VimState): number { const n = state.count || 1; state.count = 0; diff --git a/src/vim/text.ts b/src/vim/text.ts new file mode 100644 index 0000000..b389d51 --- /dev/null +++ b/src/vim/text.ts @@ -0,0 +1,36 @@ +export function endOfWord(text: string, offset: number, count = 1): number { + const len = text.length; + if (len === 0) return 0; + let pos = offset; + for (let step = 0; step < count; step++) { + // If inside a word/punct run, advance one to start looking for next end + if (pos < len - 1 && charKind(text[pos]) !== "space") { + pos++; + } + // Skip whitespace + while (pos < len && isWhitespace(text[pos])) pos++; + if (pos >= len) return len - 1; + // Find end of current word class run + const kind = charKind(text[pos]); + while (pos + 1 < len && charKind(text[pos + 1]) === kind) pos++; + } + return Math.min(pos, len - 1); +} + +export function isWhitespace(ch: string): boolean { + return ch === " " || ch === "\t" || ch === "\n" || ch === "\r"; +} + +export function charKind(ch: string): "word" | "punct" | "space" { + if (isWhitespace(ch)) return "space"; + if (/\w/.test(ch)) return "word"; + return "punct"; +} + +export function currentLineRange(text: string, offset: number): { start: number; end: number } { + if (text.length === 0) return { start: 0, end: 0 }; + const safeOffset = Math.min(Math.max(offset, 0), text.length - 1); + const start = text.lastIndexOf("\n", safeOffset - 1) + 1; + const newline = text.indexOf("\n", safeOffset); + return { start, end: newline === -1 ? text.length - 1 : newline }; +} From 47be3a195b9c8c638621b78b35684891c185c952 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:06:41 +0300 Subject: [PATCH 06/18] refactor: extract keybinding tables into tables.ts --- src/vim/index.ts | 37 +------------------------------------ src/vim/tables.ts | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 36 deletions(-) create mode 100644 src/vim/tables.ts diff --git a/src/vim/index.ts b/src/vim/index.ts index 85d80fe..43846d7 100644 --- a/src/vim/index.ts +++ b/src/vim/index.ts @@ -1,45 +1,10 @@ +import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables"; import { currentLineRange, endOfWord } from "./text"; import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; export { endOfWord } from "./text"; export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; -export const MOTIONS: Record = { - h: "input.move.left", - l: "input.move.right", - j: "input.move.down", - k: "input.move.up", - w: "input.word.forward", - b: "input.word.backward", - "0": "input.line.home", - "^": "input.line.home", - $: "input.line.end", - G: "input.buffer.end", -}; - -export const SELECT_MOTIONS: Record = { - h: "input.select.left", - l: "input.select.right", - j: "input.select.down", - k: "input.select.up", - w: "input.select.word.forward", - b: "input.select.word.backward", - "0": "input.select.line.home", - "^": "input.select.line.home", - $: "input.select.line.end", - G: "input.select.buffer.end", -}; - -const DELETE_MOTION: Record = { - w: "input.delete.word.forward", - b: "input.delete.word.backward", - $: "input.delete.to.line.end", - "0": "input.delete.to.line.start", - "^": "input.delete.to.line.start", - h: "input.backspace", - l: "input.delete", -}; - const PASS: HandlerResult = { consume: false, actions: [] }; const _CONSUME: HandlerResult = { consume: true, actions: [] }; diff --git a/src/vim/tables.ts b/src/vim/tables.ts new file mode 100644 index 0000000..9c01aa7 --- /dev/null +++ b/src/vim/tables.ts @@ -0,0 +1,35 @@ +export const MOTIONS: Record = { + h: "input.move.left", + l: "input.move.right", + j: "input.move.down", + k: "input.move.up", + w: "input.word.forward", + b: "input.word.backward", + "0": "input.line.home", + "^": "input.line.home", + $: "input.line.end", + G: "input.buffer.end", +}; + +export const SELECT_MOTIONS: Record = { + h: "input.select.left", + l: "input.select.right", + j: "input.select.down", + k: "input.select.up", + w: "input.select.word.forward", + b: "input.select.word.backward", + "0": "input.select.line.home", + "^": "input.select.line.home", + $: "input.select.line.end", + G: "input.select.buffer.end", +}; + +export const DELETE_MOTION: Record = { + w: "input.delete.word.forward", + b: "input.delete.word.backward", + $: "input.delete.to.line.end", + "0": "input.delete.to.line.start", + "^": "input.delete.to.line.start", + h: "input.backspace", + l: "input.delete", +}; From 8c543a773f76fb4a62a9ac580ea2fc0b4ee749c9 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:07:28 +0300 Subject: [PATCH 07/18] refactor: extract translateKey/PASS/pushN into util.ts --- src/vim/index.ts | 19 ++----------------- src/vim/util.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 17 deletions(-) create mode 100644 src/vim/util.ts diff --git a/src/vim/index.ts b/src/vim/index.ts index 43846d7..199da5f 100644 --- a/src/vim/index.ts +++ b/src/vim/index.ts @@ -1,11 +1,12 @@ import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables"; import { currentLineRange, endOfWord } from "./text"; import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { PASS, pushN } from "./util"; export { endOfWord } from "./text"; export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; +export { translateKey } from "./util"; -const PASS: HandlerResult = { consume: false, actions: [] }; const _CONSUME: HandlerResult = { consume: true, actions: [] }; export function createVimState(): VimState { @@ -41,18 +42,6 @@ export function toggleVimMode(state: VimState): HandlerResult { return { consume: true, actions: [{ type: "toast", message: "Vim mode enabled" }] }; } -export function translateKey(ev: KeyEvent): string { - let key = ev.name; - if (ev.shift && ev.name.length === 1) { - if (/[a-z]/.test(ev.name)) key = ev.name.toUpperCase(); - else if (ev.name === "4") key = "$"; - else if (ev.name === "6") key = "^"; - else if (ev.name === "[") key = "{"; - else if (ev.name === "]") key = "}"; - } - return key; -} - export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { if (ev.name === "escape") { state.mode = "normal"; @@ -532,10 +521,6 @@ function exitVisual(state: VimState, actions: Action[]) { enterNormal(state, actions); } -function pushN(actions: Action[], cmd: string, n: number) { - for (let i = 0; i < n; i++) actions.push({ type: "cmd", cmd }); -} - function isInputEmpty(prompt: PromptAccess): boolean { return prompt.getLineCount() === 1 && prompt.getLine(0) === ""; } diff --git a/src/vim/util.ts b/src/vim/util.ts new file mode 100644 index 0000000..e24a561 --- /dev/null +++ b/src/vim/util.ts @@ -0,0 +1,19 @@ +import type { Action, HandlerResult, KeyEvent } from "./types"; + +export const PASS: HandlerResult = { consume: false, actions: [] }; + +export function translateKey(ev: KeyEvent): string { + let key = ev.name; + if (ev.shift && ev.name.length === 1) { + if (/[a-z]/.test(ev.name)) key = ev.name.toUpperCase(); + else if (ev.name === "4") key = "$"; + else if (ev.name === "6") key = "^"; + else if (ev.name === "[") key = "{"; + else if (ev.name === "]") key = "}"; + } + return key; +} + +export function pushN(actions: Action[], cmd: string, n: number) { + for (let i = 0; i < n; i++) actions.push({ type: "cmd", cmd }); +} From 19b3e48a06aff32124d31deb4194e7f8ebbc6716 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:08:36 +0300 Subject: [PATCH 08/18] refactor: extract VimState lifecycle into state.ts --- src/vim/index.ts | 80 ++---------------------------------------------- src/vim/state.ts | 79 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 78 deletions(-) create mode 100644 src/vim/state.ts diff --git a/src/vim/index.ts b/src/vim/index.ts index 199da5f..8f18685 100644 --- a/src/vim/index.ts +++ b/src/vim/index.ts @@ -1,47 +1,16 @@ +import { consumeCount, enterInsert, enterNormal, exitVisual, resetPending } from "./state"; import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables"; import { currentLineRange, endOfWord } from "./text"; import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; import { PASS, pushN } from "./util"; +export { createVimState, finishOneShotIfComplete, toggleVimMode } from "./state"; export { endOfWord } from "./text"; export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; export { translateKey } from "./util"; const _CONSUME: HandlerResult = { consume: true, actions: [] }; -export function createVimState(): VimState { - return { - mode: "insert", - pendingOp: null, - pendingChar: null, - count: 0, - yankRegister: "", - oneShotNormal: false, - disabled: false, - }; -} - -export function toggleVimMode(state: VimState): HandlerResult { - state.disabled = !state.disabled; - if (state.disabled) { - // Reset to clean insert mode so cursor style updates and no stale - // pending state carries over when re-enabled. - state.mode = "insert"; - state.pendingOp = null; - state.pendingChar = null; - state.count = 0; - state.oneShotNormal = false; - return { - consume: true, - actions: [ - { type: "toast", message: "Vim mode disabled" }, - { type: "mode", mode: "insert" }, - ], - }; - } - return { consume: true, actions: [{ type: "toast", message: "Vim mode enabled" }] }; -} - export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { if (ev.name === "escape") { state.mode = "normal"; @@ -472,55 +441,10 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom // ── Helpers ────────────────────────────────────────────────── -export function finishOneShotIfComplete(state: VimState, result: HandlerResult): void { - if (!state.oneShotNormal) return; - if (!result.consume) return; - if (state.pendingOp !== null || state.pendingChar !== null || state.count > 0) return; - const alreadyEnteringInsert = result.actions.some((a) => a.type === "mode" && a.mode === "insert"); - if (alreadyEnteringInsert) { - state.oneShotNormal = false; - return; - } - state.oneShotNormal = false; - state.mode = "insert"; - result.actions.push({ type: "mode", mode: "insert" }); -} - -function resetPending(state: VimState) { - state.pendingOp = null; - state.pendingChar = null; - state.count = 0; -} - function finishUndoableChange(actions: Action[]): HandlerResult { return { consume: true, actions: [{ type: "saveUndoSnapshot" }, ...actions] }; } -function consumeCount(state: VimState): number { - const n = state.count || 1; - state.count = 0; - return n; -} - -function enterInsert(state: VimState, actions: Action[]) { - resetPending(state); - state.mode = "insert"; - state.oneShotNormal = false; - actions.push({ type: "mode", mode: "insert" }); -} - -function enterNormal(state: VimState, actions: Action[]) { - state.mode = "normal"; - state.count = 0; - state.oneShotNormal = false; - actions.push({ type: "mode", mode: "normal" }); -} - -function exitVisual(state: VimState, actions: Action[]) { - actions.push({ type: "clearSelection" }); - enterNormal(state, actions); -} - function isInputEmpty(prompt: PromptAccess): boolean { return prompt.getLineCount() === 1 && prompt.getLine(0) === ""; } diff --git a/src/vim/state.ts b/src/vim/state.ts new file mode 100644 index 0000000..32f35ad --- /dev/null +++ b/src/vim/state.ts @@ -0,0 +1,79 @@ +import type { Action, HandlerResult, VimState } from "./types"; + +export function createVimState(): VimState { + return { + mode: "insert", + pendingOp: null, + pendingChar: null, + count: 0, + yankRegister: "", + oneShotNormal: false, + disabled: false, + }; +} + +export function toggleVimMode(state: VimState): HandlerResult { + state.disabled = !state.disabled; + if (state.disabled) { + // Reset to clean insert mode so cursor style updates and no stale + // pending state carries over when re-enabled. + state.mode = "insert"; + state.pendingOp = null; + state.pendingChar = null; + state.count = 0; + state.oneShotNormal = false; + return { + consume: true, + actions: [ + { type: "toast", message: "Vim mode disabled" }, + { type: "mode", mode: "insert" }, + ], + }; + } + return { consume: true, actions: [{ type: "toast", message: "Vim mode enabled" }] }; +} + +export function finishOneShotIfComplete(state: VimState, result: HandlerResult): void { + if (!state.oneShotNormal) return; + if (!result.consume) return; + if (state.pendingOp !== null || state.pendingChar !== null || state.count > 0) return; + const alreadyEnteringInsert = result.actions.some((a) => a.type === "mode" && a.mode === "insert"); + if (alreadyEnteringInsert) { + state.oneShotNormal = false; + return; + } + state.oneShotNormal = false; + state.mode = "insert"; + result.actions.push({ type: "mode", mode: "insert" }); +} + +export function resetPending(state: VimState) { + state.pendingOp = null; + state.pendingChar = null; + state.count = 0; +} + +export function consumeCount(state: VimState): number { + const n = state.count || 1; + state.count = 0; + return n; +} + +export function enterInsert(state: VimState, actions: Action[]) { + resetPending(state); + state.mode = "insert"; + state.oneShotNormal = false; + actions.push({ type: "mode", mode: "insert" }); +} + +export function enterNormal(state: VimState, actions: Action[]) { + state.mode = "normal"; + state.count = 0; + state.oneShotNormal = false; + actions.push({ type: "mode", mode: "normal" }); +} + +export function exitVisual(state: VimState, actions: Action[]) { + actions.push({ type: "clearSelection" }); + enterNormal(state, actions); +} From 365ce03aeaf3d6b1f56598cb4fa633bd53a92744 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:09:05 +0300 Subject: [PATCH 09/18] refactor: extract handleInsertKey into insert.ts --- src/vim/index.ts | 31 +------------------------------ src/vim/insert.ts | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 30 deletions(-) create mode 100644 src/vim/insert.ts diff --git a/src/vim/index.ts b/src/vim/index.ts index 8f18685..96fe484 100644 --- a/src/vim/index.ts +++ b/src/vim/index.ts @@ -4,6 +4,7 @@ import { currentLineRange, endOfWord } from "./text"; import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; import { PASS, pushN } from "./util"; +export { handleInsertKey } from "./insert"; export { createVimState, finishOneShotIfComplete, toggleVimMode } from "./state"; export { endOfWord } from "./text"; export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; @@ -11,36 +12,6 @@ export { translateKey } from "./util"; const _CONSUME: HandlerResult = { consume: true, actions: [] }; -export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { - if (ev.name === "escape") { - state.mode = "normal"; - const actions: Action[] = []; - // Vim moves cursor one left when leaving insert mode, - // unless at position 0 or start of line. - const offset = prompt.getCursorOffset(); - if (offset > 0 && prompt.getPlainText()[offset - 1] !== "\n") { - actions.push({ type: "cursorTo", offset: offset - 1 }); - } - actions.push({ type: "mode", mode: "normal" }); - return { consume: true, actions }; - } - if (ev.name === "return" && ev.ctrl) { - return { consume: true, actions: [{ type: "cmd", cmd: "input.submit" }] }; - } - if (ev.name === "return") { - return { consume: true, actions: [{ type: "cmd", cmd: "input.newline" }] }; - } - if (ev.name === "tab") { - return { consume: true, actions: [{ type: "insertText", text: "\t" }] }; - } - if (ev.name === "o" && ev.ctrl) { - state.mode = "normal"; - state.oneShotNormal = true; - return { consume: true, actions: [{ type: "mode", mode: "(insert)" }] }; - } - return PASS; -} - export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { if (ev.meta || ev.super) return PASS; if (ev.ctrl) { diff --git a/src/vim/insert.ts b/src/vim/insert.ts new file mode 100644 index 0000000..31acf32 --- /dev/null +++ b/src/vim/insert.ts @@ -0,0 +1,32 @@ +import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { PASS } from "./util"; + +export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { + if (ev.name === "escape") { + state.mode = "normal"; + const actions: Action[] = []; + // Vim moves cursor one left when leaving insert mode, + // unless at position 0 or start of line. + const offset = prompt.getCursorOffset(); + if (offset > 0 && prompt.getPlainText()[offset - 1] !== "\n") { + actions.push({ type: "cursorTo", offset: offset - 1 }); + } + actions.push({ type: "mode", mode: "normal" }); + return { consume: true, actions }; + } + if (ev.name === "return" && ev.ctrl) { + return { consume: true, actions: [{ type: "cmd", cmd: "input.submit" }] }; + } + if (ev.name === "return") { + return { consume: true, actions: [{ type: "cmd", cmd: "input.newline" }] }; + } + if (ev.name === "tab") { + return { consume: true, actions: [{ type: "insertText", text: "\t" }] }; + } + if (ev.name === "o" && ev.ctrl) { + state.mode = "normal"; + state.oneShotNormal = true; + return { consume: true, actions: [{ type: "mode", mode: "(insert)" }] }; + } + return PASS; +} From 012f0000bea7d3870355a77e1264cb3d581bcd83 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:10:34 +0300 Subject: [PATCH 10/18] refactor: extract handleNormalKey into normal.ts --- src/vim/index.ts | 342 +--------------------------------------------- src/vim/normal.ts | 338 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+), 338 deletions(-) create mode 100644 src/vim/normal.ts diff --git a/src/vim/index.ts b/src/vim/index.ts index 96fe484..beccc17 100644 --- a/src/vim/index.ts +++ b/src/vim/index.ts @@ -1,10 +1,11 @@ -import { consumeCount, enterInsert, enterNormal, exitVisual, resetPending } from "./state"; -import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables"; -import { currentLineRange, endOfWord } from "./text"; +import { consumeCount, enterInsert, enterNormal, exitVisual } from "./state"; +import { SELECT_MOTIONS } from "./tables"; +import { endOfWord } from "./text"; import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; import { PASS, pushN } from "./util"; export { handleInsertKey } from "./insert"; +export { handleNormalKey } from "./normal"; export { createVimState, finishOneShotIfComplete, toggleVimMode } from "./state"; export { endOfWord } from "./text"; export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; @@ -12,331 +13,6 @@ export { translateKey } from "./util"; const _CONSUME: HandlerResult = { consume: true, actions: [] }; -export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { - if (ev.meta || ev.super) return PASS; - if (ev.ctrl) { - if (ev.name === "r") { - resetPending(state); - return { consume: true, actions: [{ type: "cmd", cmd: "input.redo" }] }; - } - return PASS; - } - - if (ev.name === "escape") { - if (state.oneShotNormal) { - state.oneShotNormal = false; - state.mode = "insert"; - resetPending(state); - return { consume: true, actions: [{ type: "mode", mode: "insert" }] }; - } - resetPending(state); - return PASS; - } - - // Pending character argument (r{char}) - if (state.pendingChar === "r") { - const n = consumeCount(state); - const actions: Action[] = []; - pushN(actions, "input.delete", n); - actions.push({ type: "insertText", text: key.repeat(n) }); - state.pendingChar = null; - return finishUndoableChange(actions); - } - - // Pending g prefix (gg, ge, etc.) - if (state.pendingChar === "g") { - state.pendingChar = null; - const actions: Action[] = []; - if (key === "g") { - consumeCount(state); - actions.push({ type: "cursorTo", offset: 0 }); - } else { - resetPending(state); - } - return { consume: true, actions }; - } - - if (ev.name === "tab") return PASS; - - // Everything below is consumed - const actions: Action[] = []; - - if (/[1-9]/.test(key) || (key === "0" && state.count > 0)) { - state.count = state.count * 10 + parseInt(key, 10); - return { consume: true, actions }; - } - - if (ev.name === "return") { - actions.push({ type: "cmd", cmd: "input.submit" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === ":") { - actions.push({ type: "cmd", cmd: "command.palette.show" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "/") { - actions.push({ type: "cmd", cmd: "session.timeline" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "[") { - actions.push({ type: "cmd", cmd: "session.half.page.up" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "]") { - actions.push({ type: "cmd", cmd: "session.half.page.down" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "{") { - actions.push({ type: "cmd", cmd: "session.message.previous" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "}") { - actions.push({ type: "cmd", cmd: "session.message.next" }); - resetPending(state); - return { consume: true, actions }; - } - - if (key === "p") { - if (state.yankRegister) actions.push({ type: "yank", text: state.yankRegister }); - actions.push({ type: "cmd", cmd: "prompt.paste" }); - resetPending(state); - return finishUndoableChange(actions); - } - - if (key === "X") { - pushN(actions, "input.backspace", consumeCount(state)); - return finishUndoableChange(actions); - } - - if (key === "J") { - const n = consumeCount(state); - for (let i = 0; i < n; i++) { - actions.push({ type: "cmd", cmd: "input.line.end" }); - actions.push({ type: "cmd", cmd: "input.delete" }); - } - return finishUndoableChange(actions); - } - - // Operators: d, c, y - if (key === "d" || key === "c" || key === "y") { - if (state.pendingOp === key) { - const n = consumeCount(state); - if (key === "y") { - const cursorLine = prompt.getCursorLine(); - const lines: string[] = []; - for (let i = 0; i < n; i++) lines.push(prompt.getLine(cursorLine + i)); - const text = `${lines.join("\n")}\n`; - state.yankRegister = text; - actions.push({ type: "yank", text }); - actions.push({ type: "toast", message: `${n} line${n > 1 ? "s" : ""} yanked`, duration: 1000 }); - resetPending(state); - } else { - pushN(actions, "input.delete.line", n); - if (key === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - return { consume: true, actions }; - } - state.pendingOp = key; - return { consume: true, actions }; - } - - if (key === "D") { - actions.push({ type: "cmd", cmd: "input.delete.to.line.end" }); - resetPending(state); - return finishUndoableChange(actions); - } - - if (key === "C") { - actions.push({ type: "cmd", cmd: "input.delete.to.line.end" }); - enterInsert(state, actions); - return finishUndoableChange(actions); - } - - // Pending operator + e (end-of-word needs special handling) - if (state.pendingOp && key === "e") { - const n = consumeCount(state); - const offset = prompt.getCursorOffset(); - const target = endOfWord(prompt.getPlainText(), offset, n); - if (state.pendingOp === "y") { - const text = prompt.getPlainText().slice(offset, target + 1); - state.yankRegister = text; - actions.push({ type: "yank", text }); - resetPending(state); - return { consume: true, actions }; - } - actions.push({ type: "deleteRange", start: offset, end: target }); - if (state.pendingOp === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - - // Pending operator + motion - if (state.pendingOp && key in MOTIONS) { - const n = consumeCount(state); - - if (state.pendingOp === "y") { - const selectCmd = SELECT_MOTIONS[key]; - if (selectCmd) { - pushN(actions, selectCmd, n); - actions.push({ type: "yankSelection" }); - } - resetPending(state); - return { consume: true, actions }; - } - - if (key === "j") { - pushN(actions, "input.delete.line", n + 1); - if (state.pendingOp === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - if (key === "k") { - pushN(actions, "input.move.up", n); - pushN(actions, "input.delete.line", n + 1); - if (state.pendingOp === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - if (key === "G") { - consumeCount(state); - const offset = prompt.getCursorOffset(); - const text = prompt.getPlainText(); - actions.push({ type: "deleteRange", start: offset, end: Math.max(0, text.length - 1) }); - if (state.pendingOp === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - - const deleteCmd = DELETE_MOTION[key]; - if (deleteCmd) { - pushN(actions, deleteCmd, n); - if (state.pendingOp === "c") enterInsert(state, actions); - else resetPending(state); - return finishUndoableChange(actions); - } - - // unreachable: every MOTIONS key reaching here has a DELETE_MOTION entry (j/k/G handled above) - resetPending(state); - return { consume: true, actions }; - } - - // Standalone e (end-of-word) - if (key === "e") { - const n = consumeCount(state); - const target = endOfWord(prompt.getPlainText(), prompt.getCursorOffset(), n); - actions.push({ type: "cursorTo", offset: target }); - return { consume: true, actions }; - } - - // Standalone motions - if (key in MOTIONS) { - const n = consumeCount(state); - if ((key === "j" || key === "k") && isInputEmpty(prompt)) { - const cmd = key === "k" ? "prompt.history.previous" : "prompt.history.next"; - pushN(actions, cmd, n); - return { consume: true, actions }; - } - pushN(actions, MOTIONS[key], n); - return { consume: true, actions }; - } - - // g prefix — wait for second keypress - if (key === "g") { - state.pendingChar = "g"; - return { consume: true, actions }; - } - - if (key === "x") { - pushN(actions, "input.delete", consumeCount(state)); - return finishUndoableChange(actions); - } - - if (key === "r") { - state.pendingChar = "r"; - return { consume: true, actions }; - } - - if (key === "u") { - actions.push({ type: "undo" }); - resetPending(state); - return { consume: true, actions }; - } - - // Visual mode entry - if (key === "V") { - const range = currentLineRange(prompt.getPlainText(), prompt.getCursorOffset()); - state.mode = "visual"; - state.visualAnchor = prompt.getCursorOffset(); - state.oneShotNormal = false; - resetPending(state); - return { - consume: true, - actions: [ - { type: "selectRange", start: range.start, end: range.end }, - { type: "mode", mode: "visual" }, - ], - }; - } - - if (key === "v") { - state.mode = "visual"; - state.visualAnchor = prompt.getCursorOffset(); - state.oneShotNormal = false; - resetPending(state); - return { consume: true, actions: [{ type: "mode", mode: "visual" }] }; - } - - // Insert entries - if (key === "i") { - enterInsert(state, actions); - return { consume: true, actions }; - } - - if (key === "a") { - actions.push({ type: "cmd", cmd: "input.move.right" }); - enterInsert(state, actions); - return { consume: true, actions }; - } - - if (key === "A") { - actions.push({ type: "cmd", cmd: "input.line.end" }); - enterInsert(state, actions); - return { consume: true, actions }; - } - - if (key === "o") { - actions.push({ type: "cmd", cmd: "input.line.end" }); - actions.push({ type: "cmd", cmd: "input.newline" }); - enterInsert(state, actions); - return { consume: true, actions }; - } - - if (key === "O") { - actions.push({ type: "cmd", cmd: "input.line.home" }); - actions.push({ type: "cmd", cmd: "input.newline" }); - actions.push({ type: "cmd", cmd: "input.move.up" }); - enterInsert(state, actions); - return { consume: true, actions }; - } - - // Unbound key — already consumed - return { consume: true, actions }; -} - export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { if (ev.meta || ev.super) return PASS; if (ev.ctrl) return PASS; @@ -409,13 +85,3 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom // Unbound key — consume to prevent typing return { consume: true, actions }; } - -// ── Helpers ────────────────────────────────────────────────── - -function finishUndoableChange(actions: Action[]): HandlerResult { - return { consume: true, actions: [{ type: "saveUndoSnapshot" }, ...actions] }; -} - -function isInputEmpty(prompt: PromptAccess): boolean { - return prompt.getLineCount() === 1 && prompt.getLine(0) === ""; -} diff --git a/src/vim/normal.ts b/src/vim/normal.ts new file mode 100644 index 0000000..91048ed --- /dev/null +++ b/src/vim/normal.ts @@ -0,0 +1,338 @@ +import { consumeCount, enterInsert, resetPending } from "./state"; +import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables"; +import { currentLineRange, endOfWord } from "./text"; +import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { PASS, pushN } from "./util"; + +export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { + if (ev.meta || ev.super) return PASS; + if (ev.ctrl) { + if (ev.name === "r") { + resetPending(state); + return { consume: true, actions: [{ type: "cmd", cmd: "input.redo" }] }; + } + return PASS; + } + + if (ev.name === "escape") { + if (state.oneShotNormal) { + state.oneShotNormal = false; + state.mode = "insert"; + resetPending(state); + return { consume: true, actions: [{ type: "mode", mode: "insert" }] }; + } + resetPending(state); + return PASS; + } + + // Pending character argument (r{char}) + if (state.pendingChar === "r") { + const n = consumeCount(state); + const actions: Action[] = []; + pushN(actions, "input.delete", n); + actions.push({ type: "insertText", text: key.repeat(n) }); + state.pendingChar = null; + return finishUndoableChange(actions); + } + + // Pending g prefix (gg, ge, etc.) + if (state.pendingChar === "g") { + state.pendingChar = null; + const actions: Action[] = []; + if (key === "g") { + consumeCount(state); + actions.push({ type: "cursorTo", offset: 0 }); + } else { + resetPending(state); + } + return { consume: true, actions }; + } + + if (ev.name === "tab") return PASS; + + // Everything below is consumed + const actions: Action[] = []; + + if (/[1-9]/.test(key) || (key === "0" && state.count > 0)) { + state.count = state.count * 10 + parseInt(key, 10); + return { consume: true, actions }; + } + + if (ev.name === "return") { + actions.push({ type: "cmd", cmd: "input.submit" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === ":") { + actions.push({ type: "cmd", cmd: "command.palette.show" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "/") { + actions.push({ type: "cmd", cmd: "session.timeline" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "[") { + actions.push({ type: "cmd", cmd: "session.half.page.up" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "]") { + actions.push({ type: "cmd", cmd: "session.half.page.down" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "{") { + actions.push({ type: "cmd", cmd: "session.message.previous" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "}") { + actions.push({ type: "cmd", cmd: "session.message.next" }); + resetPending(state); + return { consume: true, actions }; + } + + if (key === "p") { + if (state.yankRegister) actions.push({ type: "yank", text: state.yankRegister }); + actions.push({ type: "cmd", cmd: "prompt.paste" }); + resetPending(state); + return finishUndoableChange(actions); + } + + if (key === "X") { + pushN(actions, "input.backspace", consumeCount(state)); + return finishUndoableChange(actions); + } + + if (key === "J") { + const n = consumeCount(state); + for (let i = 0; i < n; i++) { + actions.push({ type: "cmd", cmd: "input.line.end" }); + actions.push({ type: "cmd", cmd: "input.delete" }); + } + return finishUndoableChange(actions); + } + + // Operators: d, c, y + if (key === "d" || key === "c" || key === "y") { + if (state.pendingOp === key) { + const n = consumeCount(state); + if (key === "y") { + const cursorLine = prompt.getCursorLine(); + const lines: string[] = []; + for (let i = 0; i < n; i++) lines.push(prompt.getLine(cursorLine + i)); + const text = `${lines.join("\n")}\n`; + state.yankRegister = text; + actions.push({ type: "yank", text }); + actions.push({ type: "toast", message: `${n} line${n > 1 ? "s" : ""} yanked`, duration: 1000 }); + resetPending(state); + } else { + pushN(actions, "input.delete.line", n); + if (key === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + return { consume: true, actions }; + } + state.pendingOp = key; + return { consume: true, actions }; + } + + if (key === "D") { + actions.push({ type: "cmd", cmd: "input.delete.to.line.end" }); + resetPending(state); + return finishUndoableChange(actions); + } + + if (key === "C") { + actions.push({ type: "cmd", cmd: "input.delete.to.line.end" }); + enterInsert(state, actions); + return finishUndoableChange(actions); + } + + // Pending operator + e (end-of-word needs special handling) + if (state.pendingOp && key === "e") { + const n = consumeCount(state); + const offset = prompt.getCursorOffset(); + const target = endOfWord(prompt.getPlainText(), offset, n); + if (state.pendingOp === "y") { + const text = prompt.getPlainText().slice(offset, target + 1); + state.yankRegister = text; + actions.push({ type: "yank", text }); + resetPending(state); + return { consume: true, actions }; + } + actions.push({ type: "deleteRange", start: offset, end: target }); + if (state.pendingOp === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + + // Pending operator + motion + if (state.pendingOp && key in MOTIONS) { + const n = consumeCount(state); + + if (state.pendingOp === "y") { + const selectCmd = SELECT_MOTIONS[key]; + if (selectCmd) { + pushN(actions, selectCmd, n); + actions.push({ type: "yankSelection" }); + } + resetPending(state); + return { consume: true, actions }; + } + + if (key === "j") { + pushN(actions, "input.delete.line", n + 1); + if (state.pendingOp === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + if (key === "k") { + pushN(actions, "input.move.up", n); + pushN(actions, "input.delete.line", n + 1); + if (state.pendingOp === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + if (key === "G") { + consumeCount(state); + const offset = prompt.getCursorOffset(); + const text = prompt.getPlainText(); + actions.push({ type: "deleteRange", start: offset, end: Math.max(0, text.length - 1) }); + if (state.pendingOp === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + + const deleteCmd = DELETE_MOTION[key]; + if (deleteCmd) { + pushN(actions, deleteCmd, n); + if (state.pendingOp === "c") enterInsert(state, actions); + else resetPending(state); + return finishUndoableChange(actions); + } + + // unreachable: every MOTIONS key reaching here has a DELETE_MOTION entry (j/k/G handled above) + resetPending(state); + return { consume: true, actions }; + } + + // Standalone e (end-of-word) + if (key === "e") { + const n = consumeCount(state); + const target = endOfWord(prompt.getPlainText(), prompt.getCursorOffset(), n); + actions.push({ type: "cursorTo", offset: target }); + return { consume: true, actions }; + } + + // Standalone motions + if (key in MOTIONS) { + const n = consumeCount(state); + if ((key === "j" || key === "k") && isInputEmpty(prompt)) { + const cmd = key === "k" ? "prompt.history.previous" : "prompt.history.next"; + pushN(actions, cmd, n); + return { consume: true, actions }; + } + pushN(actions, MOTIONS[key], n); + return { consume: true, actions }; + } + + // g prefix — wait for second keypress + if (key === "g") { + state.pendingChar = "g"; + return { consume: true, actions }; + } + + if (key === "x") { + pushN(actions, "input.delete", consumeCount(state)); + return finishUndoableChange(actions); + } + + if (key === "r") { + state.pendingChar = "r"; + return { consume: true, actions }; + } + + if (key === "u") { + actions.push({ type: "undo" }); + resetPending(state); + return { consume: true, actions }; + } + + // Visual mode entry + if (key === "V") { + const range = currentLineRange(prompt.getPlainText(), prompt.getCursorOffset()); + state.mode = "visual"; + state.visualAnchor = prompt.getCursorOffset(); + state.oneShotNormal = false; + resetPending(state); + return { + consume: true, + actions: [ + { type: "selectRange", start: range.start, end: range.end }, + { type: "mode", mode: "visual" }, + ], + }; + } + + if (key === "v") { + state.mode = "visual"; + state.visualAnchor = prompt.getCursorOffset(); + state.oneShotNormal = false; + resetPending(state); + return { consume: true, actions: [{ type: "mode", mode: "visual" }] }; + } + + // Insert entries + if (key === "i") { + enterInsert(state, actions); + return { consume: true, actions }; + } + + if (key === "a") { + actions.push({ type: "cmd", cmd: "input.move.right" }); + enterInsert(state, actions); + return { consume: true, actions }; + } + + if (key === "A") { + actions.push({ type: "cmd", cmd: "input.line.end" }); + enterInsert(state, actions); + return { consume: true, actions }; + } + + if (key === "o") { + actions.push({ type: "cmd", cmd: "input.line.end" }); + actions.push({ type: "cmd", cmd: "input.newline" }); + enterInsert(state, actions); + return { consume: true, actions }; + } + + if (key === "O") { + actions.push({ type: "cmd", cmd: "input.line.home" }); + actions.push({ type: "cmd", cmd: "input.newline" }); + actions.push({ type: "cmd", cmd: "input.move.up" }); + enterInsert(state, actions); + return { consume: true, actions }; + } + + // Unbound key — already consumed + return { consume: true, actions }; +} + +function finishUndoableChange(actions: Action[]): HandlerResult { + return { consume: true, actions: [{ type: "saveUndoSnapshot" }, ...actions] }; +} + +function isInputEmpty(prompt: PromptAccess): boolean { + return prompt.getLineCount() === 1 && prompt.getLine(0) === ""; +} From 949bc4aa3322efe2e0118f195e830102a477dda7 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:11:07 +0300 Subject: [PATCH 11/18] refactor: extract handleVisualKey into visual.ts --- src/vim/index.ts | 80 ++--------------------------------------------- src/vim/visual.ts | 78 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 78 deletions(-) create mode 100644 src/vim/visual.ts diff --git a/src/vim/index.ts b/src/vim/index.ts index beccc17..399e121 100644 --- a/src/vim/index.ts +++ b/src/vim/index.ts @@ -1,8 +1,4 @@ -import { consumeCount, enterInsert, enterNormal, exitVisual } from "./state"; -import { SELECT_MOTIONS } from "./tables"; -import { endOfWord } from "./text"; -import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; -import { PASS, pushN } from "./util"; +import type { HandlerResult } from "./types"; export { handleInsertKey } from "./insert"; export { handleNormalKey } from "./normal"; @@ -10,78 +6,6 @@ export { createVimState, finishOneShotIfComplete, toggleVimMode } from "./state" export { endOfWord } from "./text"; export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; export { translateKey } from "./util"; +export { handleVisualKey } from "./visual"; const _CONSUME: HandlerResult = { consume: true, actions: [] }; - -export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { - if (ev.meta || ev.super) return PASS; - if (ev.ctrl) return PASS; - - const actions: Action[] = []; - - // Pending g prefix in visual mode - if (state.pendingChar === "g") { - state.pendingChar = null; - if (key === "g") { - actions.push({ type: "cmd", cmd: "input.select.buffer.home" }); - state.count = 0; - return { consume: true, actions }; - } - // Unknown g-combo or escape — fall through to normal visual handling - } - - // Count accumulation - if (/[1-9]/.test(key) || (key === "0" && state.count > 0)) { - state.count = state.count * 10 + parseInt(key, 10); - return { consume: true, actions }; - } - - // Exit visual mode - if (ev.name === "escape" || key === "v") { - exitVisual(state, actions); - return { consume: true, actions }; - } - - // Operators act on selection - if (key === "d" || key === "x") { - actions.push({ type: "cmd", cmd: "input.backspace" }); - enterNormal(state, actions); - return { consume: true, actions }; - } - - if (key === "c") { - actions.push({ type: "cmd", cmd: "input.backspace" }); - enterInsert(state, actions); - return { consume: true, actions }; - } - - if (key === "y") { - actions.push({ type: "yankSelection" }); - enterNormal(state, actions); - return { consume: true, actions }; - } - - // e — extend selection to end of word (custom, not a host command) - if (key === "e") { - const n = consumeCount(state); - const target = endOfWord(prompt.getPlainText(), prompt.getCursorOffset(), n); - actions.push({ type: "selectRange", start: state.visualAnchor ?? 0, end: target }); - actions.push({ type: "cursorTo", offset: target }); - return { consume: true, actions }; - } - - // Motions extend selection - if (key in SELECT_MOTIONS) { - pushN(actions, SELECT_MOTIONS[key], consumeCount(state)); - return { consume: true, actions }; - } - - // g prefix — wait for second keypress - if (key === "g") { - state.pendingChar = "g"; - return { consume: true, actions }; - } - - // Unbound key — consume to prevent typing - return { consume: true, actions }; -} diff --git a/src/vim/visual.ts b/src/vim/visual.ts new file mode 100644 index 0000000..a35c4fb --- /dev/null +++ b/src/vim/visual.ts @@ -0,0 +1,78 @@ +import { consumeCount, enterInsert, enterNormal, exitVisual } from "./state"; +import { SELECT_MOTIONS } from "./tables"; +import { endOfWord } from "./text"; +import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types"; +import { PASS, pushN } from "./util"; + +export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult { + if (ev.meta || ev.super) return PASS; + if (ev.ctrl) return PASS; + + const actions: Action[] = []; + + // Pending g prefix in visual mode + if (state.pendingChar === "g") { + state.pendingChar = null; + if (key === "g") { + actions.push({ type: "cmd", cmd: "input.select.buffer.home" }); + state.count = 0; + return { consume: true, actions }; + } + // Unknown g-combo or escape — fall through to normal visual handling + } + + // Count accumulation + if (/[1-9]/.test(key) || (key === "0" && state.count > 0)) { + state.count = state.count * 10 + parseInt(key, 10); + return { consume: true, actions }; + } + + // Exit visual mode + if (ev.name === "escape" || key === "v") { + exitVisual(state, actions); + return { consume: true, actions }; + } + + // Operators act on selection + if (key === "d" || key === "x") { + actions.push({ type: "cmd", cmd: "input.backspace" }); + enterNormal(state, actions); + return { consume: true, actions }; + } + + if (key === "c") { + actions.push({ type: "cmd", cmd: "input.backspace" }); + enterInsert(state, actions); + return { consume: true, actions }; + } + + if (key === "y") { + actions.push({ type: "yankSelection" }); + enterNormal(state, actions); + return { consume: true, actions }; + } + + // e — extend selection to end of word (custom, not a host command) + if (key === "e") { + const n = consumeCount(state); + const target = endOfWord(prompt.getPlainText(), prompt.getCursorOffset(), n); + actions.push({ type: "selectRange", start: state.visualAnchor ?? 0, end: target }); + actions.push({ type: "cursorTo", offset: target }); + return { consume: true, actions }; + } + + // Motions extend selection + if (key in SELECT_MOTIONS) { + pushN(actions, SELECT_MOTIONS[key], consumeCount(state)); + return { consume: true, actions }; + } + + // g prefix — wait for second keypress + if (key === "g") { + state.pendingChar = "g"; + return { consume: true, actions }; + } + + // Unbound key — consume to prevent typing + return { consume: true, actions }; +} From a273e4dcc8859a5702426e036d103cb7a824cfdf Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:13:55 +0300 Subject: [PATCH 12/18] refactor: reduce vim barrel to public surface, drop dead _CONSUME --- src/vim/index.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/vim/index.ts b/src/vim/index.ts index 399e121..90e5ed4 100644 --- a/src/vim/index.ts +++ b/src/vim/index.ts @@ -1,5 +1,3 @@ -import type { HandlerResult } from "./types"; - export { handleInsertKey } from "./insert"; export { handleNormalKey } from "./normal"; export { createVimState, finishOneShotIfComplete, toggleVimMode } from "./state"; @@ -7,5 +5,3 @@ export { endOfWord } from "./text"; export type { Action, KeyEvent, PromptAccess, VimState } from "./types"; export { translateKey } from "./util"; export { handleVisualKey } from "./visual"; - -const _CONSUME: HandlerResult = { consume: true, actions: [] }; From 5cead1824aa05f5bc49200e421e128abc5f44746 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:21:29 +0300 Subject: [PATCH 13/18] test: split vim tests to mirror the module layout --- test/fixtures.ts | 17 + test/integration.test.ts | 411 +++++++++++ test/support.ts | 33 + test/vim.test.ts | 1493 -------------------------------------- test/vim/insert.test.ts | 92 +++ test/vim/normal.test.ts | 620 ++++++++++++++++ test/vim/state.test.ts | 71 ++ test/vim/text.test.ts | 136 ++++ test/vim/util.test.ts | 31 + test/vim/visual.test.ts | 195 +++++ 10 files changed, 1606 insertions(+), 1493 deletions(-) create mode 100644 test/fixtures.ts create mode 100644 test/integration.test.ts create mode 100644 test/support.ts delete mode 100644 test/vim.test.ts create mode 100644 test/vim/insert.test.ts create mode 100644 test/vim/normal.test.ts create mode 100644 test/vim/state.test.ts create mode 100644 test/vim/text.test.ts create mode 100644 test/vim/util.test.ts create mode 100644 test/vim/visual.test.ts diff --git a/test/fixtures.ts b/test/fixtures.ts new file mode 100644 index 0000000..53d418c --- /dev/null +++ b/test/fixtures.ts @@ -0,0 +1,17 @@ +import type { PromptAccess } from "../src/vim"; + +export const mockPrompt: PromptAccess = { + getLine: (n) => ["hello world", "second line", "third line"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 0, + getCursorOffset: () => 0, + getPlainText: () => "hello world\nsecond line\nthird line", +}; + +export const emptyPrompt: PromptAccess = { + getLine: () => "", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 0, + getPlainText: () => "", +}; diff --git a/test/integration.test.ts b/test/integration.test.ts new file mode 100644 index 0000000..e7ded01 --- /dev/null +++ b/test/integration.test.ts @@ -0,0 +1,411 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { createVimState, finishOneShotIfComplete, handleInsertKey, handleNormalKey, type VimState } from "../src/vim"; +import { mockPrompt } from "./fixtures"; +import { ev } from "./support"; + +let state: VimState; + +beforeEach(() => { + state = createVimState(); + state.mode = "normal"; +}); + +// ── Ctrl+O one-shot normal mode ─────────────────────────── + +describe("Ctrl+O one-shot normal mode", () => { + function enterOneShot() { + state.mode = "insert"; + handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); + } + + it("w auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("3w auto-returns to insert after count is consumed", () => { + enterOneShot(); + handleNormalKey(state, "3", ev("3"), mockPrompt); + expect(state.oneShotNormal).toBe(true); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + }); + + it("dw auto-returns to insert after operator+motion", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + }); + + it("dd auto-returns to insert", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r1); + const r2 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + }); + + it("r{char} auto-returns to insert", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "r", ev("r"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "a", ev("a"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + }); + + it("gg auto-returns to insert", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "g", ev("g"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "g", ev("g"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + }); + + it("cw enters insert directly without double mode switch", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "c", ev("c"), mockPrompt); + finishOneShotIfComplete(state, r1); + const r2 = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + const modeActions = r2.actions.filter((a) => a.type === "mode" && a.mode === "insert"); + expect(modeActions).toHaveLength(1); + }); + + it("u auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "u", ev("u"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it("p auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "p", ev("p"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it(": auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, ":", ev(":"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it("e auto-returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "e", ev("e"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("insert"); + }); + + it("escape during one-shot returns to insert", () => { + enterOneShot(); + const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); + expect(r.consume).toBe(true); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("v during one-shot cancels one-shot and enters visual", () => { + enterOneShot(); + handleNormalKey(state, "v", ev("v"), mockPrompt); + expect(state.mode).toBe("visual"); + expect(state.oneShotNormal).toBe(false); + }); + + it("sequential Ctrl+O usage works (flag resets cleanly)", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + // Second round + enterOneShot(); + expect(state.oneShotNormal).toBe(true); + const r2 = handleNormalKey(state, "b", ev("b"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + }); + + it("cc enters insert directly without double mode switch", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "c", ev("c"), mockPrompt); + finishOneShotIfComplete(state, r1); + const r2 = handleNormalKey(state, "c", ev("c"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + const modeActions = r2.actions.filter((a) => a.type === "mode" && a.mode === "insert"); + expect(modeActions).toHaveLength(1); + }); + + it("de auto-returns to insert (deleteRange path)", () => { + enterOneShot(); + const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, r1); + expect(state.mode).toBe("normal"); + const r2 = handleNormalKey(state, "e", ev("e"), mockPrompt); + finishOneShotIfComplete(state, r2); + expect(state.mode).toBe("insert"); + expect(state.oneShotNormal).toBe(false); + expect(r2.actions.some((a) => a.type === "deleteRange")).toBe(true); + }); + + it("does not auto-return when not in one-shot mode", () => { + state.mode = "normal"; + state.oneShotNormal = false; + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + finishOneShotIfComplete(state, r); + expect(state.mode).toBe("normal"); + }); + + it("finishOneShotIfComplete does not double-append insert when the result already enters insert", () => { + state.oneShotNormal = true; + const result = { consume: true, actions: [{ type: "mode", mode: "insert" } as const] }; + finishOneShotIfComplete(state, result); + expect(state.oneShotNormal).toBe(false); + expect(result.actions.filter((a) => a.type === "mode" && a.mode === "insert").length).toBe(1); + }); +}); + +describe("version sync", () => { + it("VERSION matches package.json", async () => { + const pkg = await import("../package.json"); + const { VERSION } = await import("../src/version"); + expect(VERSION).toBe(pkg.version); + }); +}); + +// ── plugin init sanity check ────────────────────────────── + +describe("plugin init", () => { + it("tui() does not throw with a minimal mock API", async () => { + const plugin = (await import("../src/index")).default; + expect(plugin.id).toBe("vimcode"); + + // Minimal mock matching what OpenCode passes to tui(). + // Intentionally sparse — some fields are undefined or stubs, + // which is exactly the hostile environment we need to survive. + const dispatchCommand = () => ({ ok: false }); + const api = { + renderer: undefined, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { intercept: () => {}, dispatchCommand }, + route: { current: { name: "home", params: {} } }, + state: { session: { question: () => [], permission: () => [] } }, + lifecycle: { onDispose: () => {} }, + kv: { get: async () => undefined }, // empty object — the scenario that crashed v0.7.0 + }; + + // Should not throw with a sparse mock API. + // biome-ignore lint/suspicious/noExplicitAny: mock API doesn't match full plugin types + await plugin.tui(api as any, undefined, undefined as any); + }); +}); + +// ── undo snapshot integration ───────────────────────────── + +describe("undo snapshot — deleteRange + u", () => { + // Exercises the full pipeline: key event → handler → applyActions → editor state. + // The contract: u after dG restores the full buffer in one step via + // editBuffer.setText, not the host's per-line input.undo. + + function createMockEditor(text: string, cursor: number) { + let editorText = text; + let editorCursor = cursor; + const calls: { method: string; args: unknown[] }[] = []; + const editor = { + get plainText() { + return editorText; + }, + get cursorOffset() { + return editorCursor; + }, + set cursorOffset(v: number) { + editorCursor = v; + }, + visualCursor: { logicalRow: 1 }, + cursorStyle: { style: "block" as const, blinking: true }, + insertText: () => {}, + setSelectionInclusive: () => {}, + editorView: { resetSelection: () => {} }, + editBuffer: { + deleteRange: (sl: number, sc: number, el: number, ec: number) => { + calls.push({ method: "deleteRange", args: [sl, sc, el, ec] }); + editorText = editorText.substring(0, cursor); + }, + setText: (t: string) => { + calls.push({ method: "setText", args: [t] }); + editorText = t; + }, + }, + }; + return { editor, calls, getText: () => editorText, getCursor: () => editorCursor }; + } + + async function setup(text: string, cursor: number) { + const plugin = (await import("../src/index")).default; + const { editor, calls, getText, getCursor } = createMockEditor(text, cursor); + const dispatched: string[] = []; + // biome-ignore lint/suspicious/noExplicitAny: test mock + let handler: (ctx: any) => void; + + const api = { + renderer: { currentFocusedEditor: editor, currentFocusedRenderable: editor }, + ui: { toast: () => {}, dialog: { open: false } }, + keymap: { + intercept: (_e: string, h: typeof handler) => { + handler = h; + }, + dispatchCommand: (cmd: string) => { + dispatched.push(cmd); + return { ok: false }; + }, + }, + route: { current: { name: "home", params: {} } }, + state: { session: { question: () => [], permission: () => [] } }, + lifecycle: { onDispose: () => {} }, + kv: {}, + }; + + // biome-ignore lint/suspicious/noExplicitAny: mock API + await plugin.tui(api as any, undefined, undefined as any); + + const press = (name: string, opts: Record = {}) => { + handler?.({ event: { name, eventType: "press", ...opts }, consume: () => {} }); + }; + + // Enter normal mode + press("escape"); + + return { press, calls, dispatched, getText, getCursor }; + } + + it("u after dG restores the full buffer via editBuffer.setText", async () => { + const original = "hello world\nsecond line\nthird line"; + const { press, calls, dispatched, getCursor } = await setup(original, 12); + + press("d"); + press("g", { shift: true }); + expect(calls.some((c) => c.method === "deleteRange")).toBe(true); + + calls.length = 0; + press("u"); + + expect(calls).toContainEqual({ method: "setText", args: [original] }); + expect(getCursor()).toBe(12); + expect(dispatched).not.toContain("input.undo"); + }); + + it("u after dG then a motion falls back to host input.undo", async () => { + const { press, calls, dispatched } = await setup("hello world\nsecond line\nthird line", 12); + + press("d"); + press("g", { shift: true }); + expect(calls.some((c) => c.method === "deleteRange")).toBe(true); + + // h dispatches input.move.left (a cmd action), invalidating the snapshot + press("h"); + + calls.length = 0; + dispatched.length = 0; + press("u"); + + expect(calls.every((c) => c.method !== "setText")).toBe(true); + // input.undo is dispatched via setTimeout + await new Promise((r) => setTimeout(r, 20)); + expect(dispatched).toContain("input.undo"); + }); + + it("u after 3dw restores the full buffer via editBuffer.setText", async () => { + const original = "hello world second line third line"; + const { press, calls, dispatched, getCursor } = await setup(original, 0); + + press("3"); + press("d"); + press("w"); + + calls.length = 0; + press("u"); + + expect(calls).toContainEqual({ method: "setText", args: [original] }); + expect(getCursor()).toBe(0); + expect(dispatched).not.toContain("input.undo"); + }); + + it("u after 3dw then dd unwinds the snapshot stack one step per press", async () => { + const original = "hello world second line third line"; + const { press, calls, dispatched } = await setup(original, 0); + + // Two stacked undoable changes → two snapshots on the stack. + press("3"); + press("d"); + press("w"); + press("d"); + press("d"); + + // First u pops the dd snapshot, second pops the 3dw snapshot — each a + // local restore via setText, never the host's input.undo. + calls.length = 0; + dispatched.length = 0; + press("u"); + expect(calls.some((c) => c.method === "setText")).toBe(true); + expect(dispatched).not.toContain("input.undo"); + + calls.length = 0; + press("u"); + expect(calls.some((c) => c.method === "setText")).toBe(true); + expect(dispatched).not.toContain("input.undo"); + + // Stack is now empty — a third u falls through to host undo. + calls.length = 0; + press("u"); + expect(calls.every((c) => c.method !== "setText")).toBe(true); + await new Promise((r) => setTimeout(r, 20)); + expect(dispatched).toContain("input.undo"); + }); + + it("u after 3dw then an insert-mode edit falls back to host input.undo", async () => { + const { press, calls, dispatched } = await setup("hello world second line third line", 0); + + press("3"); + press("d"); + press("w"); + + // Enter insert and modify the buffer. The insert edit emits an + // insertText action, which clears the vim snapshot stack. + press("i"); + press("tab"); + press("escape"); + + calls.length = 0; + dispatched.length = 0; + press("u"); + + expect(calls.every((c) => c.method !== "setText")).toBe(true); + // input.undo is dispatched via setTimeout + await new Promise((r) => setTimeout(r, 20)); + expect(dispatched).toContain("input.undo"); + }); +}); diff --git a/test/support.ts b/test/support.ts new file mode 100644 index 0000000..fbfb60a --- /dev/null +++ b/test/support.ts @@ -0,0 +1,33 @@ +import type { Action } from "../src/vim"; + +export function cmds(actions: Action[]): string[] { + return actions.filter((a): a is Extract => a.type === "cmd").map((a) => a.cmd); +} + +export function cursorTos(actions: Action[]): number[] { + return actions.filter((a): a is Extract => a.type === "cursorTo").map((a) => a.offset); +} + +export function deleteRanges(actions: Action[]): Array<{ start: number; end: number }> { + return actions + .filter((a): a is Extract => a.type === "deleteRange") + .map((a) => ({ start: a.start, end: a.end })); +} + +export function saveUndoSnapshots(actions: Action[]): Action[] { + return actions.filter((a) => a.type === "saveUndoSnapshot"); +} + +export function selectRanges(actions: Action[]): Array<{ start: number; end: number }> { + return actions + .filter((a): a is Extract => a.type === "selectRange") + .map((a) => ({ start: a.start, end: a.end })); +} + +export const ev = (name: string, opts?: { shift?: boolean; ctrl?: boolean; meta?: boolean; super?: boolean }) => ({ + name, + shift: opts?.shift ?? false, + ctrl: opts?.ctrl ?? false, + meta: opts?.meta ?? false, + super: opts?.super ?? false, +}); diff --git a/test/vim.test.ts b/test/vim.test.ts deleted file mode 100644 index d8a983f..0000000 --- a/test/vim.test.ts +++ /dev/null @@ -1,1493 +0,0 @@ -import { beforeEach, describe, expect, it } from "bun:test"; -import { - type Action, - createVimState, - endOfWord, - finishOneShotIfComplete, - handleInsertKey, - handleNormalKey, - handleVisualKey, - type PromptAccess, - toggleVimMode, - translateKey, - type VimState, -} from "../src/vim"; - -function cmds(actions: Action[]): string[] { - return actions.filter((a): a is Extract => a.type === "cmd").map((a) => a.cmd); -} - -function cursorTos(actions: Action[]): number[] { - return actions.filter((a): a is Extract => a.type === "cursorTo").map((a) => a.offset); -} - -function deleteRanges(actions: Action[]): Array<{ start: number; end: number }> { - return actions - .filter((a): a is Extract => a.type === "deleteRange") - .map((a) => ({ start: a.start, end: a.end })); -} - -function saveUndoSnapshots(actions: Action[]): Action[] { - return actions.filter((a) => a.type === "saveUndoSnapshot"); -} - -function selectRanges(actions: Action[]): Array<{ start: number; end: number }> { - return actions - .filter((a): a is Extract => a.type === "selectRange") - .map((a) => ({ start: a.start, end: a.end })); -} - -const ev = (name: string, opts?: { shift?: boolean; ctrl?: boolean; meta?: boolean; super?: boolean }) => ({ - name, - shift: opts?.shift ?? false, - ctrl: opts?.ctrl ?? false, - meta: opts?.meta ?? false, - super: opts?.super ?? false, -}); - -const mockPrompt: PromptAccess = { - getLine: (n) => ["hello world", "second line", "third line"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 0, - getCursorOffset: () => 0, - getPlainText: () => "hello world\nsecond line\nthird line", -}; - -const emptyPrompt: PromptAccess = { - getLine: () => "", - getLineCount: () => 1, - getCursorLine: () => 0, - getCursorOffset: () => 0, - getPlainText: () => "", -}; - -let state: VimState; - -beforeEach(() => { - state = createVimState(); - state.mode = "normal"; -}); - -// ── createVimState ─────────────────────────────────────────── - -describe("createVimState", () => { - it("initializes disabled: false", () => { - const s = createVimState(); - expect(s.disabled).toBe(false); - }); -}); - -// ── toggleVimMode ──────────────────────────────────────────── - -describe("toggleVimMode", () => { - it("flips disabled from false to true", () => { - const s = createVimState(); - s.disabled = false; - toggleVimMode(s); - expect(s.disabled).toBe(true); - }); - - it("flips disabled from true to false", () => { - const s = createVimState(); - s.disabled = true; - toggleVimMode(s); - expect(s.disabled).toBe(false); - }); - - it("resets mode to insert when disabling", () => { - const s = createVimState(); - s.mode = "normal"; - s.pendingOp = "d"; - s.count = 3; - toggleVimMode(s); - expect(s.mode).toBe("insert"); - expect(s.pendingOp).toBeNull(); - expect(s.pendingChar).toBeNull(); - expect(s.count).toBe(0); - expect(s.oneShotNormal).toBe(false); - }); - - it("returns a toast and mode action when disabling", () => { - const s = createVimState(); - s.disabled = false; - const r = toggleVimMode(s); - expect(r.actions).toContainEqual({ type: "toast", message: "Vim mode disabled" }); - expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); - }); - - it("returns a toast action with 'Vim mode enabled' when enabling", () => { - const s = createVimState(); - s.disabled = true; - const r = toggleVimMode(s); - expect(r.actions).toContainEqual({ type: "toast", message: "Vim mode enabled" }); - }); - - it("does not reset mode when enabling", () => { - const s = createVimState(); - s.disabled = true; - s.mode = "normal"; - toggleVimMode(s); - expect(s.mode).toBe("normal"); - }); - - it("returns consume: true", () => { - const s = createVimState(); - const r = toggleVimMode(s); - expect(r.consume).toBe(true); - }); -}); - -// ── endOfWord ────────────────────────────────────────────── - -describe("endOfWord", () => { - it("from start of word, moves to last char", () => { - expect(endOfWord("hello world", 0)).toBe(4); - }); - - it("from middle of word, moves to last char", () => { - expect(endOfWord("hello world", 2)).toBe(4); - }); - - it("from end of word, moves to end of next word", () => { - expect(endOfWord("hello world", 4)).toBe(10); - }); - - it("from whitespace, skips to end of next word", () => { - expect(endOfWord("hello world", 5)).toBe(10); - }); - - it("stops at punctuation boundary", () => { - expect(endOfWord("hello.world", 0)).toBe(4); - }); - - it("from punctuation, moves to end of punctuation run", () => { - expect(endOfWord("hello...world", 5)).toBe(7); - }); - - it("from end of punctuation, moves to end of next word", () => { - expect(endOfWord("a.b", 1)).toBe(2); - }); - - it("at end of text, stays put", () => { - expect(endOfWord("hello", 4)).toBe(4); - }); - - it("handles count > 1", () => { - expect(endOfWord("one two three", 0, 2)).toBe(6); - }); - - it("handles multiple whitespace", () => { - expect(endOfWord("hello world", 0)).toBe(4); - expect(endOfWord("hello world", 4)).toBe(12); - }); - - it("handles newlines as whitespace", () => { - expect(endOfWord("hello\nworld", 4)).toBe(10); - }); - - it("clamps at end of text", () => { - expect(endOfWord("hi", 0, 5)).toBe(1); - }); -}); - -// ── translateKey ──────────────────────────────────────────── - -describe("translateKey", () => { - it("lowercase passes through", () => { - expect(translateKey(ev("h"))).toBe("h"); - }); - - it("shift+letter uppercases", () => { - expect(translateKey(ev("g", { shift: true }))).toBe("G"); - }); - - it("shift+4 → $", () => { - expect(translateKey(ev("4", { shift: true }))).toBe("$"); - }); - - it("shift+6 → ^", () => { - expect(translateKey(ev("6", { shift: true }))).toBe("^"); - }); - - it("shift+[ → {", () => { - expect(translateKey(ev("[", { shift: true }))).toBe("{"); - }); - - it("shift+] → }", () => { - expect(translateKey(ev("]", { shift: true }))).toBe("}"); - }); -}); - -// ── handleInsertKey ───────────────────────────────────────── - -describe("handleInsertKey", () => { - beforeEach(() => { - state.mode = "insert"; - }); - - it("escape → consume, mode normal", () => { - const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); - expect(r.consume).toBe(true); - expect(state.mode).toBe("normal"); - expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); - }); - - it("enter → consume, input.newline", () => { - const r = handleInsertKey(state, "return", ev("return"), mockPrompt); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toContain("input.newline"); - }); - - it("ctrl+enter → consume, input.submit", () => { - const r = handleInsertKey(state, "return", ev("return", { ctrl: true }), mockPrompt); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toContain("input.submit"); - }); - - it("tab → consume, insertText tab", () => { - const r = handleInsertKey(state, "tab", ev("tab"), mockPrompt); - expect(r.consume).toBe(true); - expect(r.actions).toContainEqual({ type: "insertText", text: "\t" }); - }); - - it("regular key → passthrough", () => { - const r = handleInsertKey(state, "a", ev("a"), mockPrompt); - expect(r.consume).toBe(false); - }); - - it("escape mid-line moves cursor one left", () => { - const midLinePrompt: PromptAccess = { - getLine: () => "hello world", - getLineCount: () => 1, - getCursorLine: () => 0, - getCursorOffset: () => 5, - getPlainText: () => "hello world", - }; - const r = handleInsertKey(state, "escape", ev("escape"), midLinePrompt); - expect(r.consume).toBe(true); - expect(cursorTos(r.actions)).toEqual([4]); - }); - - it("escape at position 0 does not move cursor", () => { - const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); - expect(cursorTos(r.actions)).toEqual([]); - }); - - it("escape at start of line does not move cursor", () => { - const startOfLinePrompt: PromptAccess = { - getLine: (n) => ["hello world", "second line"][n] ?? "", - getLineCount: () => 2, - getCursorLine: () => 1, - getCursorOffset: () => 12, - getPlainText: () => "hello world\nsecond line", - }; - const r = handleInsertKey(state, "escape", ev("escape"), startOfLinePrompt); - expect(cursorTos(r.actions)).toEqual([]); - }); - - it("ctrl+o enters normal mode with oneShotNormal flag", () => { - const r = handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); - expect(r.consume).toBe(true); - expect(state.mode).toBe("normal"); - expect(state.oneShotNormal).toBe(true); - expect(r.actions).toContainEqual({ type: "mode", mode: "(insert)" }); - }); - - it("ctrl+o emits (insert) mode action", () => { - const r = handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); - expect(r.actions.some((a) => a.type === "mode" && a.mode === "(insert)")).toBe(true); - }); -}); - -// ── handleNormalKey — motions ─────────────────────────────── - -describe("handleNormalKey — motions", () => { - it("h dispatches input.move.left", () => { - const r = handleNormalKey(state, "h", ev("h"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.left"]); - }); - - it("j dispatches input.move.down", () => { - const r = handleNormalKey(state, "j", ev("j"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.down"]); - }); - - it("k dispatches input.move.up", () => { - const r = handleNormalKey(state, "k", ev("k"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.up"]); - }); - - it("l dispatches input.move.right", () => { - const r = handleNormalKey(state, "l", ev("l"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.right"]); - }); - - it("3j dispatches input.move.down 3 times", () => { - handleNormalKey(state, "3", ev("3"), mockPrompt); - const r = handleNormalKey(state, "j", ev("j"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.down", "input.move.down", "input.move.down"]); - }); - - it("G dispatches input.buffer.end", () => { - const r = handleNormalKey(state, "G", ev("g", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.buffer.end"]); - }); - - it("0 dispatches input.line.home", () => { - const r = handleNormalKey(state, "0", ev("0"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.line.home"]); - }); - - it("0 after count > 0 accumulates as digit", () => { - handleNormalKey(state, "1", ev("1"), mockPrompt); - handleNormalKey(state, "0", ev("0"), mockPrompt); - expect(state.count).toBe(10); - }); - - it("g sets pendingChar, no actions", () => { - const r = handleNormalKey(state, "g", ev("g"), mockPrompt); - expect(r.consume).toBe(true); - expect(r.actions).toEqual([]); - expect(state.pendingChar).toBe("g"); - }); -}); - -// ── handleNormalKey — g prefix ───────────────────────────── - -describe("handleNormalKey — g prefix", () => { - it("gg moves cursor to buffer start", () => { - handleNormalKey(state, "g", ev("g"), mockPrompt); - const r = handleNormalKey(state, "g", ev("g"), mockPrompt); - expect(r.consume).toBe(true); - expect(cursorTos(r.actions)).toEqual([0]); - expect(state.pendingChar).toBeNull(); - }); - - it("g then Escape cancels pending, no movement", () => { - handleNormalKey(state, "g", ev("g"), mockPrompt); - const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); - expect(state.pendingChar).toBeNull(); - expect(r.actions).toEqual([]); - }); - - it("g then unknown key cancels pending, no movement", () => { - handleNormalKey(state, "g", ev("g"), mockPrompt); - const r = handleNormalKey(state, "z", ev("z"), mockPrompt); - expect(r.consume).toBe(true); - expect(state.pendingChar).toBeNull(); - expect(cursorTos(r.actions)).toEqual([]); - expect(cmds(r.actions)).toEqual([]); - }); - - it("5gg consumes count without crash", () => { - handleNormalKey(state, "5", ev("5"), mockPrompt); - handleNormalKey(state, "g", ev("g"), mockPrompt); - const r = handleNormalKey(state, "g", ev("g"), mockPrompt); - expect(r.consume).toBe(true); - expect(state.count).toBe(0); - }); -}); - -// ── handleNormalKey — e motion ───────────────────────────── - -describe("handleNormalKey — e motion", () => { - const ePrompt: PromptAccess = { - getLine: (n) => ["hello world", "second line"][n] ?? "", - getLineCount: () => 2, - getCursorLine: () => 0, - getCursorOffset: () => 0, - getPlainText: () => "hello world\nsecond line", - }; - - it("e returns cursorTo at end of current word", () => { - const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(r.consume).toBe(true); - expect(cursorTos(r.actions)).toEqual([4]); - }); - - it("2e returns cursorTo at end of second word", () => { - handleNormalKey(state, "2", ev("2"), ePrompt); - const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(cursorTos(r.actions)).toEqual([10]); - }); - - it("de deletes from cursor to end of word", () => { - handleNormalKey(state, "d", ev("d"), ePrompt); - const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); - expect(state.mode).toBe("normal"); - // The deleteRange goes through finishUndoableChange, so the snapshot - // comes from a single source (the saveUndoSnapshot action), not index.ts. - expect(saveUndoSnapshots(r.actions)).toHaveLength(1); - }); - - it("ce deletes from cursor to end of word and enters insert", () => { - handleNormalKey(state, "c", ev("c"), ePrompt); - const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); - expect(state.mode).toBe("insert"); - }); - - it("ye yanks from cursor to end of word", () => { - handleNormalKey(state, "y", ev("y"), ePrompt); - const r = handleNormalKey(state, "e", ev("e"), ePrompt); - expect(state.yankRegister).toBe("hello"); - expect(r.actions.some((a) => a.type === "yank" && a.text === "hello")).toBe(true); - }); -}); - -// ── handleNormalKey — operators ───────────────────────────── - -describe("handleNormalKey — operators", () => { - it("dd dispatches input.delete.line", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - expect(state.pendingOp).toBe("d"); - const r = handleNormalKey(state, "d", ev("d"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.line"]); - }); - - it("2dd dispatches input.delete.line twice", () => { - handleNormalKey(state, "2", ev("2"), mockPrompt); - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "d", ev("d"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); - }); - - it("dw dispatches input.delete.word.forward", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.word.forward"]); - }); - - it("3dw saves one undo snapshot around the repeated deletes", () => { - handleNormalKey(state, "3", ev("3"), mockPrompt); - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - expect(saveUndoSnapshots(r.actions)).toHaveLength(1); - expect(cmds(r.actions)).toEqual([ - "input.delete.word.forward", - "input.delete.word.forward", - "input.delete.word.forward", - ]); - }); - - it("d$ dispatches input.delete.to.line.end", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "$", ev("4", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); - }); - - it("d0 dispatches input.delete.to.line.start", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "0", ev("0"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.to.line.start"]); - }); - - it("dj dispatches input.delete.line twice", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "j", ev("j"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); - }); - - it("dk dispatches input.move.up + input.delete.line twice", () => { - handleNormalKey(state, "d", ev("d"), mockPrompt); - const r = handleNormalKey(state, "k", ev("k"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.up", "input.delete.line", "input.delete.line"]); - }); - - it("cc dispatches input.delete.line, enters insert", () => { - handleNormalKey(state, "c", ev("c"), mockPrompt); - const r = handleNormalKey(state, "c", ev("c"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.line"]); - expect(state.mode).toBe("insert"); - }); - - it("cw dispatches input.delete.word.forward, enters insert", () => { - handleNormalKey(state, "c", ev("c"), mockPrompt); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.word.forward"]); - expect(state.mode).toBe("insert"); - }); - - it("yy sets yankRegister and toasts", () => { - handleNormalKey(state, "y", ev("y"), mockPrompt); - const r = handleNormalKey(state, "y", ev("y"), mockPrompt); - expect(state.yankRegister).toBe("hello world\n"); - expect(r.actions.some((a) => a.type === "yank")).toBe(true); - expect(r.actions.some((a) => a.type === "toast")).toBe(true); - }); - - it("yw selects word forward and yanks", () => { - handleNormalKey(state, "y", ev("y"), mockPrompt); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.select.word.forward"]); - expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); - }); - - it("y$ selects to line end and yanks", () => { - handleNormalKey(state, "y", ev("y"), mockPrompt); - const r = handleNormalKey(state, "$", ev("4", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.select.line.end"]); - expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); - }); - - it("y3w selects 3 words and yanks", () => { - handleNormalKey(state, "y", ev("y"), mockPrompt); - handleNormalKey(state, "3", ev("3"), mockPrompt); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - expect(cmds(r.actions)).toEqual([ - "input.select.word.forward", - "input.select.word.forward", - "input.select.word.forward", - ]); - expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); - }); -}); - -// ── handleNormalKey — dG and cG ───────────────────────────── - -describe("handleNormalKey — dG and cG", () => { - const midPrompt: PromptAccess = { - getLine: (n) => ["hello world", "second line", "third line"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 1, - getCursorOffset: () => 12, - getPlainText: () => "hello world\nsecond line\nthird line", - }; - - it("dG deletes from cursor to buffer end", () => { - handleNormalKey(state, "d", ev("d"), midPrompt); - const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 33 }]); - expect(state.mode).toBe("normal"); - // Single snapshot source: the saveUndoSnapshot action, not a second - // push inside the deleteRange handler. - expect(saveUndoSnapshots(r.actions)).toHaveLength(1); - }); - - it("cG deletes from cursor to buffer end, enters insert", () => { - handleNormalKey(state, "c", ev("c"), midPrompt); - const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 33 }]); - expect(state.mode).toBe("insert"); - }); - - it("yG still works (no regression)", () => { - handleNormalKey(state, "y", ev("y"), midPrompt); - const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); - expect(cmds(r.actions)).toContain("input.select.buffer.end"); - expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); - }); - - it("dG on empty buffer doesn't crash", () => { - handleNormalKey(state, "d", ev("d"), emptyPrompt); - const r = handleNormalKey(state, "G", ev("g", { shift: true }), emptyPrompt); - expect(r.consume).toBe(true); - expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 0 }]); - }); - - it("dG with cursor at end of buffer", () => { - const endPrompt: PromptAccess = { - getLine: (n) => ["hello"][n] ?? "", - getLineCount: () => 1, - getCursorLine: () => 0, - getCursorOffset: () => 4, - getPlainText: () => "hello", - }; - handleNormalKey(state, "d", ev("d"), endPrompt); - const r = handleNormalKey(state, "G", ev("g", { shift: true }), endPrompt); - expect(deleteRanges(r.actions)).toEqual([{ start: 4, end: 4 }]); - }); -}); - -// ── handleNormalKey — shortcuts ───────────────────────────── - -describe("handleNormalKey — shortcuts", () => { - it("D dispatches input.delete.to.line.end", () => { - const r = handleNormalKey(state, "D", ev("d", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); - }); - - it("C dispatches input.delete.to.line.end and enters insert", () => { - const r = handleNormalKey(state, "C", ev("c", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); - expect(state.mode).toBe("insert"); - }); -}); - -// ── handleNormalKey — special keys ────────────────────────── - -describe("handleNormalKey — special keys", () => { - it(": dispatches command.palette.show", () => { - const r = handleNormalKey(state, ":", ev(":"), mockPrompt); - expect(cmds(r.actions)).toEqual(["command.palette.show"]); - }); - - it("/ dispatches session.timeline", () => { - const r = handleNormalKey(state, "/", ev("/"), mockPrompt); - expect(cmds(r.actions)).toEqual(["session.timeline"]); - }); - - it("[ dispatches session.half.page.up", () => { - const r = handleNormalKey(state, "[", ev("["), mockPrompt); - expect(cmds(r.actions)).toEqual(["session.half.page.up"]); - }); - - it("] dispatches session.half.page.down", () => { - const r = handleNormalKey(state, "]", ev("]"), mockPrompt); - expect(cmds(r.actions)).toEqual(["session.half.page.down"]); - }); - - it("{ dispatches session.message.previous", () => { - const r = handleNormalKey(state, "{", ev("[", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["session.message.previous"]); - }); - - it("} dispatches session.message.next", () => { - const r = handleNormalKey(state, "}", ev("]", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["session.message.next"]); - }); - - it("X dispatches input.backspace", () => { - const r = handleNormalKey(state, "X", ev("x", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.backspace"]); - }); - - it("J dispatches input.line.end + input.delete", () => { - const r = handleNormalKey(state, "J", ev("j", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.line.end", "input.delete"]); - }); - - it("u triggers undo", () => { - const r = handleNormalKey(state, "u", ev("u"), mockPrompt); - expect(r.actions.some((a) => a.type === "undo")).toBe(true); - }); - - it("ctrl+r dispatches input.redo", () => { - const r = handleNormalKey(state, "r", ev("r", { ctrl: true }), mockPrompt); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toEqual(["input.redo"]); - }); - - it("Enter in normal mode submits the prompt", () => { - const r = handleNormalKey(state, "return", ev("return"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.submit"]); - }); - - it("x deletes the character under the cursor", () => { - const r = handleNormalKey(state, "x", ev("x"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete"]); - }); - - it("3x deletes three characters", () => { - handleNormalKey(state, "3", ev("3"), mockPrompt); - const r = handleNormalKey(state, "x", ev("x"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete", "input.delete", "input.delete"]); - }); - - it("p with yankRegister set pastes", () => { - state.yankRegister = "yanked text\n"; - const r = handleNormalKey(state, "p", ev("p"), mockPrompt); - expect(r.actions.some((a) => a.type === "yank")).toBe(true); - expect(cmds(r.actions)).toContain("prompt.paste"); - }); - - it("meta combo → passthrough", () => { - const r = handleNormalKey(state, "c", ev("c", { meta: true }), mockPrompt); - expect(r.consume).toBe(false); - }); - - it("escape → passthrough, resets pendingOp", () => { - state.pendingOp = "d"; - const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); - expect(r.consume).toBe(false); - expect(state.pendingOp).toBeNull(); - }); -}); - -// ── handleNormalKey — replace (r) ────────────────────────── - -describe("handleNormalKey — replace (r)", () => { - it("r sets pendingChar, consumes key, no commands", () => { - const r = handleNormalKey(state, "r", ev("r"), mockPrompt); - expect(r.consume).toBe(true); - expect(r.actions).toEqual([]); - expect(state.pendingChar).toBe("r"); - }); - - it("r then a → input.delete + insertText('a'), stays normal", () => { - handleNormalKey(state, "r", ev("r"), mockPrompt); - const r = handleNormalKey(state, "a", ev("a"), mockPrompt); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toEqual(["input.delete"]); - expect(r.actions).toContainEqual({ type: "insertText", text: "a" }); - expect(state.mode).toBe("normal"); - expect(state.pendingChar).toBeNull(); - }); - - it("3ra → 3x input.delete + insertText('aaa')", () => { - handleNormalKey(state, "3", ev("3"), mockPrompt); - handleNormalKey(state, "r", ev("r"), mockPrompt); - const r = handleNormalKey(state, "a", ev("a"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete", "input.delete", "input.delete"]); - expect(r.actions).toContainEqual({ type: "insertText", text: "aaa" }); - }); - - it("r then escape → cancels, no commands", () => { - handleNormalKey(state, "r", ev("r"), mockPrompt); - const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); - expect(state.pendingChar).toBeNull(); - expect(cmds(r.actions)).toEqual([]); - }); - - it("r then digit → replaces with digit, not count", () => { - handleNormalKey(state, "r", ev("r"), mockPrompt); - const r = handleNormalKey(state, "5", ev("5"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.delete"]); - expect(r.actions).toContainEqual({ type: "insertText", text: "5" }); - expect(state.count).toBe(0); - }); -}); - -// ── handleNormalKey — insert entries ──────────────────────── - -describe("handleNormalKey — insert entries", () => { - it("i enters insert", () => { - const r = handleNormalKey(state, "i", ev("i"), mockPrompt); - expect(state.mode).toBe("insert"); - expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); - }); - - it("a dispatches input.move.right, enters insert", () => { - const r = handleNormalKey(state, "a", ev("a"), mockPrompt); - expect(cmds(r.actions)).toContain("input.move.right"); - expect(state.mode).toBe("insert"); - }); - - it("A dispatches input.line.end, enters insert", () => { - const r = handleNormalKey(state, "A", ev("a", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toContain("input.line.end"); - expect(state.mode).toBe("insert"); - }); - - it("o dispatches input.line.end + input.newline, enters insert", () => { - const r = handleNormalKey(state, "o", ev("o"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.line.end", "input.newline"]); - expect(state.mode).toBe("insert"); - }); - - it("O dispatches input.line.home + input.newline + input.move.up, enters insert", () => { - const r = handleNormalKey(state, "O", ev("o", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.line.home", "input.newline", "input.move.up"]); - expect(state.mode).toBe("insert"); - }); -}); - -// ── handleNormalKey — yy uses cursor position ───────────── - -describe("handleNormalKey — yy uses cursor position", () => { - it("yy yanks the line at getCursorLine, not a tracked counter", () => { - const prompt: PromptAccess = { - getLine: (n) => ["first", "second", "third"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 1, - }; - handleNormalKey(state, "y", ev("y"), prompt); - const r = handleNormalKey(state, "y", ev("y"), prompt); - expect(state.yankRegister).toBe("second\n"); - expect(r.actions.some((a) => a.type === "yank" && a.text === "second\n")).toBe(true); - }); - - it("2yy from cursor line 1 yanks lines 1 and 2", () => { - const prompt: PromptAccess = { - getLine: (n) => ["first", "second", "third"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 1, - }; - handleNormalKey(state, "2", ev("2"), prompt); - handleNormalKey(state, "y", ev("y"), prompt); - handleNormalKey(state, "y", ev("y"), prompt); - expect(state.yankRegister).toBe("second\nthird\n"); - }); - - it("yy on last line yanks that line", () => { - const prompt: PromptAccess = { - getLine: (n) => ["first", "second", "third"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 2, - }; - handleNormalKey(state, "y", ev("y"), prompt); - handleNormalKey(state, "y", ev("y"), prompt); - expect(state.yankRegister).toBe("third\n"); - }); -}); - -// ── handleNormalKey — history scrolling ───────────────────── - -describe("handleNormalKey — history scrolling", () => { - it("j dispatches prompt.history.next when input is empty", () => { - const r = handleNormalKey(state, "j", ev("j"), emptyPrompt); - expect(cmds(r.actions)).toEqual(["prompt.history.next"]); - }); - - it("k dispatches prompt.history.previous when input is empty", () => { - const r = handleNormalKey(state, "k", ev("k"), emptyPrompt); - expect(cmds(r.actions)).toEqual(["prompt.history.previous"]); - }); - - it("j dispatches input.move.down when input is non-empty", () => { - const r = handleNormalKey(state, "j", ev("j"), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.move.down"]); - }); - - it("3k dispatches prompt.history.previous 3 times when empty", () => { - handleNormalKey(state, "3", ev("3"), emptyPrompt); - const r = handleNormalKey(state, "k", ev("k"), emptyPrompt); - expect(cmds(r.actions)).toEqual(["prompt.history.previous", "prompt.history.previous", "prompt.history.previous"]); - }); - - it("dj still deletes lines when input is empty", () => { - handleNormalKey(state, "d", ev("d"), emptyPrompt); - const r = handleNormalKey(state, "j", ev("j"), emptyPrompt); - expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); - }); -}); - -// ── handleNormalKey — visual mode entry ──────────────────── - -describe("handleNormalKey — visual mode entry", () => { - it("v enters visual mode", () => { - const r = handleNormalKey(state, "v", ev("v"), mockPrompt); - expect(r.consume).toBe(true); - expect(state.mode).toBe("visual"); - expect(r.actions).toContainEqual({ type: "mode", mode: "visual" }); - }); - - it("v clears pending operator", () => { - state.pendingOp = "d"; - handleNormalKey(state, "v", ev("v"), mockPrompt); - expect(state.pendingOp).toBeNull(); - expect(state.mode).toBe("visual"); - }); - - it("V selects the current line and enters visual mode", () => { - const prompt: PromptAccess = { - getLine: (n) => ["first", "second", "third"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 1, - getCursorOffset: () => 8, - getPlainText: () => "first\nsecond\nthird", - }; - - const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); - - expect(r.consume).toBe(true); - expect(state.mode).toBe("visual"); - expect(selectRanges(r.actions)).toEqual([{ start: 6, end: 12 }]); - expect(r.actions).toContainEqual({ type: "mode", mode: "visual" }); - }); - - it("V selects the first line including its newline", () => { - const r = handleNormalKey(state, "V", ev("v", { shift: true }), mockPrompt); - - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 11 }]); - }); - - it("V selects the last line without requiring a trailing newline", () => { - const prompt: PromptAccess = { - getLine: (n) => ["first", "second", "third"][n] ?? "", - getLineCount: () => 3, - getCursorLine: () => 2, - getCursorOffset: () => 15, - getPlainText: () => "first\nsecond\nthird", - }; - - const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); - - expect(selectRanges(r.actions)).toEqual([{ start: 13, end: 17 }]); - }); -}); - -// ── handleVisualKey — motions ────────────────────────────── - -describe("handleVisualKey — motions", () => { - beforeEach(() => { - state.mode = "visual"; - }); - - it("h dispatches input.select.left", () => { - const r = handleVisualKey(state, "h", ev("h")); - expect(cmds(r.actions)).toEqual(["input.select.left"]); - }); - - it("l dispatches input.select.right", () => { - const r = handleVisualKey(state, "l", ev("l")); - expect(cmds(r.actions)).toEqual(["input.select.right"]); - }); - - it("j dispatches input.select.down", () => { - const r = handleVisualKey(state, "j", ev("j")); - expect(cmds(r.actions)).toEqual(["input.select.down"]); - }); - - it("k dispatches input.select.up", () => { - const r = handleVisualKey(state, "k", ev("k")); - expect(cmds(r.actions)).toEqual(["input.select.up"]); - }); - - it("w dispatches input.select.word.forward", () => { - const r = handleVisualKey(state, "w", ev("w")); - expect(cmds(r.actions)).toEqual(["input.select.word.forward"]); - }); - - it("$ dispatches input.select.line.end", () => { - const r = handleVisualKey(state, "$", ev("4", { shift: true })); - expect(cmds(r.actions)).toEqual(["input.select.line.end"]); - }); - - it("3l dispatches input.select.right 3 times", () => { - handleVisualKey(state, "3", ev("3")); - const r = handleVisualKey(state, "l", ev("l")); - expect(cmds(r.actions)).toEqual(["input.select.right", "input.select.right", "input.select.right"]); - }); - - it("G dispatches input.select.buffer.end", () => { - const r = handleVisualKey(state, "G", ev("g", { shift: true }), mockPrompt); - expect(cmds(r.actions)).toEqual(["input.select.buffer.end"]); - }); - - it("e selects from visual anchor to end of word", () => { - // "hello world" with cursor at 0, anchor at 0 → end of "hello" is offset 4 - state.visualAnchor = 0; - const r = handleVisualKey(state, "e", ev("e"), mockPrompt); - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); - }); - - it("2e selects from visual anchor to end of 2nd word", () => { - // "hello world" with cursor at 0, anchor at 0 → end of "world" is offset 10 - state.visualAnchor = 0; - handleVisualKey(state, "2", ev("2"), mockPrompt); - const r = handleVisualKey(state, "e", ev("e"), mockPrompt); - expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 10 }]); - }); - - it("e pressed twice extends selection to successive word ends", () => { - // "hello world" cursor at 0, anchor at 0 - // First e → end of "hello" (offset 4), must also emit cursorTo - // Second e (cursor now at 4) → end of "world" (offset 10) - state.visualAnchor = 0; - let cursorPos = 0; - const prompt: PromptAccess = { ...mockPrompt, getCursorOffset: () => cursorPos }; - - const r1 = handleVisualKey(state, "e", ev("e"), prompt); - expect(selectRanges(r1.actions)).toEqual([{ start: 0, end: 4 }]); - expect(cursorTos(r1.actions)).toEqual([4]); - - // Simulate effect layer applying the cursorTo action - cursorPos = cursorTos(r1.actions)[0]; - - const r2 = handleVisualKey(state, "e", ev("e"), prompt); - expect(selectRanges(r2.actions)).toEqual([{ start: 0, end: 10 }]); - expect(cursorTos(r2.actions)).toEqual([10]); - }); - - it("g sets pendingChar, no actions", () => { - const r = handleVisualKey(state, "g", ev("g")); - expect(r.consume).toBe(true); - expect(r.actions).toEqual([]); - expect(state.pendingChar).toBe("g"); - }); - - it("gg selects to buffer home", () => { - handleVisualKey(state, "g", ev("g")); - const r = handleVisualKey(state, "g", ev("g")); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toEqual(["input.select.buffer.home"]); - expect(state.pendingChar).toBeNull(); - }); - - it("g then Escape in visual cancels pending, stays visual", () => { - handleVisualKey(state, "g", ev("g")); - handleVisualKey(state, "escape", ev("escape")); - expect(state.pendingChar).toBeNull(); - // escape also exits visual mode - expect(state.mode).toBe("normal"); - }); -}); - -// ── handleVisualKey — operators ──────────────────────────── - -describe("handleVisualKey — operators", () => { - beforeEach(() => { - state.mode = "visual"; - }); - - it("d deletes selection and enters normal mode", () => { - const r = handleVisualKey(state, "d", ev("d")); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toContain("input.backspace"); - expect(state.mode).toBe("normal"); - expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); - }); - - it("c deletes selection and enters insert mode", () => { - const r = handleVisualKey(state, "c", ev("c")); - expect(r.consume).toBe(true); - expect(cmds(r.actions)).toContain("input.backspace"); - expect(state.mode).toBe("insert"); - expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); - }); - - it("y yanks selection and enters normal mode", () => { - const r = handleVisualKey(state, "y", ev("y")); - expect(r.consume).toBe(true); - expect(r.actions).toContainEqual({ type: "yankSelection" }); - expect(state.mode).toBe("normal"); - expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); - }); - - it("x deletes selection (alias for d)", () => { - const r = handleVisualKey(state, "x", ev("x")); - expect(cmds(r.actions)).toContain("input.backspace"); - expect(state.mode).toBe("normal"); - }); -}); - -// ── handleVisualKey — exit and passthrough ───────────────── - -describe("handleVisualKey — exit and passthrough", () => { - beforeEach(() => { - state.mode = "visual"; - }); - - it("Escape exits visual mode and clears selection", () => { - const r = handleVisualKey(state, "escape", ev("escape")); - expect(r.consume).toBe(true); - expect(state.mode).toBe("normal"); - expect(r.actions).toContainEqual({ type: "clearSelection" }); - expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); - }); - - it("v exits visual mode and clears selection", () => { - const r = handleVisualKey(state, "v", ev("v")); - expect(r.consume).toBe(true); - expect(state.mode).toBe("normal"); - expect(r.actions).toContainEqual({ type: "clearSelection" }); - }); - - it("meta combo passes through", () => { - const r = handleVisualKey(state, "c", ev("c", { meta: true })); - expect(r.consume).toBe(false); - }); - - it("ctrl combo passes through", () => { - const r = handleVisualKey(state, "x", ev("x", { ctrl: true })); - expect(r.consume).toBe(false); - }); - - it("unrecognized key is consumed (no typing in visual)", () => { - const r = handleVisualKey(state, "z", ev("z")); - expect(r.consume).toBe(true); - expect(r.actions).toEqual([]); - }); -}); - -// ── Ctrl+O one-shot normal mode ─────────────────────────── - -describe("Ctrl+O one-shot normal mode", () => { - function enterOneShot() { - state.mode = "insert"; - handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); - } - - it("w auto-returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); - }); - - it("3w auto-returns to insert after count is consumed", () => { - enterOneShot(); - handleNormalKey(state, "3", ev("3"), mockPrompt); - expect(state.oneShotNormal).toBe(true); - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - }); - - it("dw auto-returns to insert after operator+motion", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); - finishOneShotIfComplete(state, r1); - expect(state.mode).toBe("normal"); - const r2 = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - }); - - it("dd auto-returns to insert", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); - finishOneShotIfComplete(state, r1); - const r2 = handleNormalKey(state, "d", ev("d"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - }); - - it("r{char} auto-returns to insert", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "r", ev("r"), mockPrompt); - finishOneShotIfComplete(state, r1); - expect(state.mode).toBe("normal"); - const r2 = handleNormalKey(state, "a", ev("a"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - }); - - it("gg auto-returns to insert", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "g", ev("g"), mockPrompt); - finishOneShotIfComplete(state, r1); - expect(state.mode).toBe("normal"); - const r2 = handleNormalKey(state, "g", ev("g"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - }); - - it("cw enters insert directly without double mode switch", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "c", ev("c"), mockPrompt); - finishOneShotIfComplete(state, r1); - const r2 = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - const modeActions = r2.actions.filter((a) => a.type === "mode" && a.mode === "insert"); - expect(modeActions).toHaveLength(1); - }); - - it("u auto-returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, "u", ev("u"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - }); - - it("p auto-returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, "p", ev("p"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - }); - - it(": auto-returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, ":", ev(":"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - }); - - it("e auto-returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, "e", ev("e"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("insert"); - }); - - it("escape during one-shot returns to insert", () => { - enterOneShot(); - const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); - expect(r.consume).toBe(true); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); - }); - - it("v during one-shot cancels one-shot and enters visual", () => { - enterOneShot(); - handleNormalKey(state, "v", ev("v"), mockPrompt); - expect(state.mode).toBe("visual"); - expect(state.oneShotNormal).toBe(false); - }); - - it("sequential Ctrl+O usage works (flag resets cleanly)", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r1); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - // Second round - enterOneShot(); - expect(state.oneShotNormal).toBe(true); - const r2 = handleNormalKey(state, "b", ev("b"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - }); - - it("cc enters insert directly without double mode switch", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "c", ev("c"), mockPrompt); - finishOneShotIfComplete(state, r1); - const r2 = handleNormalKey(state, "c", ev("c"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - const modeActions = r2.actions.filter((a) => a.type === "mode" && a.mode === "insert"); - expect(modeActions).toHaveLength(1); - }); - - it("de auto-returns to insert (deleteRange path)", () => { - enterOneShot(); - const r1 = handleNormalKey(state, "d", ev("d"), mockPrompt); - finishOneShotIfComplete(state, r1); - expect(state.mode).toBe("normal"); - const r2 = handleNormalKey(state, "e", ev("e"), mockPrompt); - finishOneShotIfComplete(state, r2); - expect(state.mode).toBe("insert"); - expect(state.oneShotNormal).toBe(false); - expect(r2.actions.some((a) => a.type === "deleteRange")).toBe(true); - }); - - it("does not auto-return when not in one-shot mode", () => { - state.mode = "normal"; - state.oneShotNormal = false; - const r = handleNormalKey(state, "w", ev("w"), mockPrompt); - finishOneShotIfComplete(state, r); - expect(state.mode).toBe("normal"); - }); - - it("finishOneShotIfComplete does not double-append insert when the result already enters insert", () => { - state.oneShotNormal = true; - const result = { consume: true, actions: [{ type: "mode", mode: "insert" } as const] }; - finishOneShotIfComplete(state, result); - expect(state.oneShotNormal).toBe(false); - expect(result.actions.filter((a) => a.type === "mode" && a.mode === "insert").length).toBe(1); - }); -}); - -describe("version sync", () => { - it("VERSION matches package.json", async () => { - const pkg = await import("../package.json"); - const { VERSION } = await import("../src/version"); - expect(VERSION).toBe(pkg.version); - }); -}); - -// ── plugin init sanity check ────────────────────────────── - -describe("plugin init", () => { - it("tui() does not throw with a minimal mock API", async () => { - const plugin = (await import("../src/index")).default; - expect(plugin.id).toBe("vimcode"); - - // Minimal mock matching what OpenCode passes to tui(). - // Intentionally sparse — some fields are undefined or stubs, - // which is exactly the hostile environment we need to survive. - const dispatchCommand = () => ({ ok: false }); - const api = { - renderer: undefined, - ui: { toast: () => {}, dialog: { open: false } }, - keymap: { intercept: () => {}, dispatchCommand }, - route: { current: { name: "home", params: {} } }, - state: { session: { question: () => [], permission: () => [] } }, - lifecycle: { onDispose: () => {} }, - kv: { get: async () => undefined }, // empty object — the scenario that crashed v0.7.0 - }; - - // Should not throw with a sparse mock API. - // biome-ignore lint/suspicious/noExplicitAny: mock API doesn't match full plugin types - await plugin.tui(api as any, undefined, undefined as any); - }); -}); - -// ── undo snapshot integration ───────────────────────────── - -describe("undo snapshot — deleteRange + u", () => { - // Exercises the full pipeline: key event → handler → applyActions → editor state. - // The contract: u after dG restores the full buffer in one step via - // editBuffer.setText, not the host's per-line input.undo. - - function createMockEditor(text: string, cursor: number) { - let editorText = text; - let editorCursor = cursor; - const calls: { method: string; args: unknown[] }[] = []; - const editor = { - get plainText() { - return editorText; - }, - get cursorOffset() { - return editorCursor; - }, - set cursorOffset(v: number) { - editorCursor = v; - }, - visualCursor: { logicalRow: 1 }, - cursorStyle: { style: "block" as const, blinking: true }, - insertText: () => {}, - setSelectionInclusive: () => {}, - editorView: { resetSelection: () => {} }, - editBuffer: { - deleteRange: (sl: number, sc: number, el: number, ec: number) => { - calls.push({ method: "deleteRange", args: [sl, sc, el, ec] }); - editorText = editorText.substring(0, cursor); - }, - setText: (t: string) => { - calls.push({ method: "setText", args: [t] }); - editorText = t; - }, - }, - }; - return { editor, calls, getText: () => editorText, getCursor: () => editorCursor }; - } - - async function setup(text: string, cursor: number) { - const plugin = (await import("../src/index")).default; - const { editor, calls, getText, getCursor } = createMockEditor(text, cursor); - const dispatched: string[] = []; - // biome-ignore lint/suspicious/noExplicitAny: test mock - let handler: (ctx: any) => void; - - const api = { - renderer: { currentFocusedEditor: editor, currentFocusedRenderable: editor }, - ui: { toast: () => {}, dialog: { open: false } }, - keymap: { - intercept: (_e: string, h: typeof handler) => { - handler = h; - }, - dispatchCommand: (cmd: string) => { - dispatched.push(cmd); - return { ok: false }; - }, - }, - route: { current: { name: "home", params: {} } }, - state: { session: { question: () => [], permission: () => [] } }, - lifecycle: { onDispose: () => {} }, - kv: {}, - }; - - // biome-ignore lint/suspicious/noExplicitAny: mock API - await plugin.tui(api as any, undefined, undefined as any); - - const press = (name: string, opts: Record = {}) => { - handler?.({ event: { name, eventType: "press", ...opts }, consume: () => {} }); - }; - - // Enter normal mode - press("escape"); - - return { press, calls, dispatched, getText, getCursor }; - } - - it("u after dG restores the full buffer via editBuffer.setText", async () => { - const original = "hello world\nsecond line\nthird line"; - const { press, calls, dispatched, getCursor } = await setup(original, 12); - - press("d"); - press("g", { shift: true }); - expect(calls.some((c) => c.method === "deleteRange")).toBe(true); - - calls.length = 0; - press("u"); - - expect(calls).toContainEqual({ method: "setText", args: [original] }); - expect(getCursor()).toBe(12); - expect(dispatched).not.toContain("input.undo"); - }); - - it("u after dG then a motion falls back to host input.undo", async () => { - const { press, calls, dispatched } = await setup("hello world\nsecond line\nthird line", 12); - - press("d"); - press("g", { shift: true }); - expect(calls.some((c) => c.method === "deleteRange")).toBe(true); - - // h dispatches input.move.left (a cmd action), invalidating the snapshot - press("h"); - - calls.length = 0; - dispatched.length = 0; - press("u"); - - expect(calls.every((c) => c.method !== "setText")).toBe(true); - // input.undo is dispatched via setTimeout - await new Promise((r) => setTimeout(r, 20)); - expect(dispatched).toContain("input.undo"); - }); - - it("u after 3dw restores the full buffer via editBuffer.setText", async () => { - const original = "hello world second line third line"; - const { press, calls, dispatched, getCursor } = await setup(original, 0); - - press("3"); - press("d"); - press("w"); - - calls.length = 0; - press("u"); - - expect(calls).toContainEqual({ method: "setText", args: [original] }); - expect(getCursor()).toBe(0); - expect(dispatched).not.toContain("input.undo"); - }); - - it("u after 3dw then dd unwinds the snapshot stack one step per press", async () => { - const original = "hello world second line third line"; - const { press, calls, dispatched } = await setup(original, 0); - - // Two stacked undoable changes → two snapshots on the stack. - press("3"); - press("d"); - press("w"); - press("d"); - press("d"); - - // First u pops the dd snapshot, second pops the 3dw snapshot — each a - // local restore via setText, never the host's input.undo. - calls.length = 0; - dispatched.length = 0; - press("u"); - expect(calls.some((c) => c.method === "setText")).toBe(true); - expect(dispatched).not.toContain("input.undo"); - - calls.length = 0; - press("u"); - expect(calls.some((c) => c.method === "setText")).toBe(true); - expect(dispatched).not.toContain("input.undo"); - - // Stack is now empty — a third u falls through to host undo. - calls.length = 0; - press("u"); - expect(calls.every((c) => c.method !== "setText")).toBe(true); - await new Promise((r) => setTimeout(r, 20)); - expect(dispatched).toContain("input.undo"); - }); - - it("u after 3dw then an insert-mode edit falls back to host input.undo", async () => { - const { press, calls, dispatched } = await setup("hello world second line third line", 0); - - press("3"); - press("d"); - press("w"); - - // Enter insert and modify the buffer. The insert edit emits an - // insertText action, which clears the vim snapshot stack. - press("i"); - press("tab"); - press("escape"); - - calls.length = 0; - dispatched.length = 0; - press("u"); - - expect(calls.every((c) => c.method !== "setText")).toBe(true); - // input.undo is dispatched via setTimeout - await new Promise((r) => setTimeout(r, 20)); - expect(dispatched).toContain("input.undo"); - }); -}); diff --git a/test/vim/insert.test.ts b/test/vim/insert.test.ts new file mode 100644 index 0000000..c6f5ae0 --- /dev/null +++ b/test/vim/insert.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { createVimState, handleInsertKey, type PromptAccess, type VimState } from "../../src/vim"; +import { mockPrompt } from "../fixtures"; +import { cmds, cursorTos, ev } from "../support"; + +let state: VimState; + +beforeEach(() => { + state = createVimState(); + state.mode = "normal"; +}); + +// ── handleInsertKey ───────────────────────────────────────── + +describe("handleInsertKey", () => { + beforeEach(() => { + state.mode = "insert"; + }); + + it("escape → consume, mode normal", () => { + const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); + expect(r.consume).toBe(true); + expect(state.mode).toBe("normal"); + expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); + }); + + it("enter → consume, input.newline", () => { + const r = handleInsertKey(state, "return", ev("return"), mockPrompt); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toContain("input.newline"); + }); + + it("ctrl+enter → consume, input.submit", () => { + const r = handleInsertKey(state, "return", ev("return", { ctrl: true }), mockPrompt); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toContain("input.submit"); + }); + + it("tab → consume, insertText tab", () => { + const r = handleInsertKey(state, "tab", ev("tab"), mockPrompt); + expect(r.consume).toBe(true); + expect(r.actions).toContainEqual({ type: "insertText", text: "\t" }); + }); + + it("regular key → passthrough", () => { + const r = handleInsertKey(state, "a", ev("a"), mockPrompt); + expect(r.consume).toBe(false); + }); + + it("escape mid-line moves cursor one left", () => { + const midLinePrompt: PromptAccess = { + getLine: () => "hello world", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 5, + getPlainText: () => "hello world", + }; + const r = handleInsertKey(state, "escape", ev("escape"), midLinePrompt); + expect(r.consume).toBe(true); + expect(cursorTos(r.actions)).toEqual([4]); + }); + + it("escape at position 0 does not move cursor", () => { + const r = handleInsertKey(state, "escape", ev("escape"), mockPrompt); + expect(cursorTos(r.actions)).toEqual([]); + }); + + it("escape at start of line does not move cursor", () => { + const startOfLinePrompt: PromptAccess = { + getLine: (n) => ["hello world", "second line"][n] ?? "", + getLineCount: () => 2, + getCursorLine: () => 1, + getCursorOffset: () => 12, + getPlainText: () => "hello world\nsecond line", + }; + const r = handleInsertKey(state, "escape", ev("escape"), startOfLinePrompt); + expect(cursorTos(r.actions)).toEqual([]); + }); + + it("ctrl+o enters normal mode with oneShotNormal flag", () => { + const r = handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); + expect(r.consume).toBe(true); + expect(state.mode).toBe("normal"); + expect(state.oneShotNormal).toBe(true); + expect(r.actions).toContainEqual({ type: "mode", mode: "(insert)" }); + }); + + it("ctrl+o emits (insert) mode action", () => { + const r = handleInsertKey(state, "o", ev("o", { ctrl: true }), mockPrompt); + expect(r.actions.some((a) => a.type === "mode" && a.mode === "(insert)")).toBe(true); + }); +}); diff --git a/test/vim/normal.test.ts b/test/vim/normal.test.ts new file mode 100644 index 0000000..37e7fc6 --- /dev/null +++ b/test/vim/normal.test.ts @@ -0,0 +1,620 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { createVimState, handleNormalKey, type PromptAccess, type VimState } from "../../src/vim"; +import { emptyPrompt, mockPrompt } from "../fixtures"; +import { cmds, cursorTos, deleteRanges, ev, saveUndoSnapshots, selectRanges } from "../support"; + +let state: VimState; + +beforeEach(() => { + state = createVimState(); + state.mode = "normal"; +}); + +// ── handleNormalKey — motions ─────────────────────────────── + +describe("handleNormalKey — motions", () => { + it("h dispatches input.move.left", () => { + const r = handleNormalKey(state, "h", ev("h"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.left"]); + }); + + it("j dispatches input.move.down", () => { + const r = handleNormalKey(state, "j", ev("j"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.down"]); + }); + + it("k dispatches input.move.up", () => { + const r = handleNormalKey(state, "k", ev("k"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.up"]); + }); + + it("l dispatches input.move.right", () => { + const r = handleNormalKey(state, "l", ev("l"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.right"]); + }); + + it("3j dispatches input.move.down 3 times", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + const r = handleNormalKey(state, "j", ev("j"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.down", "input.move.down", "input.move.down"]); + }); + + it("G dispatches input.buffer.end", () => { + const r = handleNormalKey(state, "G", ev("g", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.buffer.end"]); + }); + + it("0 dispatches input.line.home", () => { + const r = handleNormalKey(state, "0", ev("0"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.line.home"]); + }); + + it("0 after count > 0 accumulates as digit", () => { + handleNormalKey(state, "1", ev("1"), mockPrompt); + handleNormalKey(state, "0", ev("0"), mockPrompt); + expect(state.count).toBe(10); + }); + + it("g sets pendingChar, no actions", () => { + const r = handleNormalKey(state, "g", ev("g"), mockPrompt); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + expect(state.pendingChar).toBe("g"); + }); +}); + +// ── handleNormalKey — g prefix ───────────────────────────── + +describe("handleNormalKey — g prefix", () => { + it("gg moves cursor to buffer start", () => { + handleNormalKey(state, "g", ev("g"), mockPrompt); + const r = handleNormalKey(state, "g", ev("g"), mockPrompt); + expect(r.consume).toBe(true); + expect(cursorTos(r.actions)).toEqual([0]); + expect(state.pendingChar).toBeNull(); + }); + + it("g then Escape cancels pending, no movement", () => { + handleNormalKey(state, "g", ev("g"), mockPrompt); + const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); + expect(state.pendingChar).toBeNull(); + expect(r.actions).toEqual([]); + }); + + it("g then unknown key cancels pending, no movement", () => { + handleNormalKey(state, "g", ev("g"), mockPrompt); + const r = handleNormalKey(state, "z", ev("z"), mockPrompt); + expect(r.consume).toBe(true); + expect(state.pendingChar).toBeNull(); + expect(cursorTos(r.actions)).toEqual([]); + expect(cmds(r.actions)).toEqual([]); + }); + + it("5gg consumes count without crash", () => { + handleNormalKey(state, "5", ev("5"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + const r = handleNormalKey(state, "g", ev("g"), mockPrompt); + expect(r.consume).toBe(true); + expect(state.count).toBe(0); + }); +}); + +// ── handleNormalKey — e motion ───────────────────────────── + +describe("handleNormalKey — e motion", () => { + const ePrompt: PromptAccess = { + getLine: (n) => ["hello world", "second line"][n] ?? "", + getLineCount: () => 2, + getCursorLine: () => 0, + getCursorOffset: () => 0, + getPlainText: () => "hello world\nsecond line", + }; + + it("e returns cursorTo at end of current word", () => { + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(r.consume).toBe(true); + expect(cursorTos(r.actions)).toEqual([4]); + }); + + it("2e returns cursorTo at end of second word", () => { + handleNormalKey(state, "2", ev("2"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(cursorTos(r.actions)).toEqual([10]); + }); + + it("de deletes from cursor to end of word", () => { + handleNormalKey(state, "d", ev("d"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.mode).toBe("normal"); + // The deleteRange goes through finishUndoableChange, so the snapshot + // comes from a single source (the saveUndoSnapshot action), not index.ts. + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + }); + + it("ce deletes from cursor to end of word and enters insert", () => { + handleNormalKey(state, "c", ev("c"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + expect(state.mode).toBe("insert"); + }); + + it("ye yanks from cursor to end of word", () => { + handleNormalKey(state, "y", ev("y"), ePrompt); + const r = handleNormalKey(state, "e", ev("e"), ePrompt); + expect(state.yankRegister).toBe("hello"); + expect(r.actions.some((a) => a.type === "yank" && a.text === "hello")).toBe(true); + }); +}); + +// ── handleNormalKey — operators ───────────────────────────── + +describe("handleNormalKey — operators", () => { + it("dd dispatches input.delete.line", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + expect(state.pendingOp).toBe("d"); + const r = handleNormalKey(state, "d", ev("d"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.line"]); + }); + + it("2dd dispatches input.delete.line twice", () => { + handleNormalKey(state, "2", ev("2"), mockPrompt); + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "d", ev("d"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); + }); + + it("dw dispatches input.delete.word.forward", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.word.forward"]); + }); + + it("3dw saves one undo snapshot around the repeated deletes", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + expect(cmds(r.actions)).toEqual([ + "input.delete.word.forward", + "input.delete.word.forward", + "input.delete.word.forward", + ]); + }); + + it("d$ dispatches input.delete.to.line.end", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "$", ev("4", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); + }); + + it("d0 dispatches input.delete.to.line.start", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "0", ev("0"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.to.line.start"]); + }); + + it("dj dispatches input.delete.line twice", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "j", ev("j"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); + }); + + it("dk dispatches input.move.up + input.delete.line twice", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + const r = handleNormalKey(state, "k", ev("k"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.up", "input.delete.line", "input.delete.line"]); + }); + + it("cc dispatches input.delete.line, enters insert", () => { + handleNormalKey(state, "c", ev("c"), mockPrompt); + const r = handleNormalKey(state, "c", ev("c"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.line"]); + expect(state.mode).toBe("insert"); + }); + + it("cw dispatches input.delete.word.forward, enters insert", () => { + handleNormalKey(state, "c", ev("c"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.word.forward"]); + expect(state.mode).toBe("insert"); + }); + + it("yy sets yankRegister and toasts", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + const r = handleNormalKey(state, "y", ev("y"), mockPrompt); + expect(state.yankRegister).toBe("hello world\n"); + expect(r.actions.some((a) => a.type === "yank")).toBe(true); + expect(r.actions.some((a) => a.type === "toast")).toBe(true); + }); + + it("yw selects word forward and yanks", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.select.word.forward"]); + expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); + }); + + it("y$ selects to line end and yanks", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + const r = handleNormalKey(state, "$", ev("4", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.select.line.end"]); + expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); + }); + + it("y3w selects 3 words and yanks", () => { + handleNormalKey(state, "y", ev("y"), mockPrompt); + handleNormalKey(state, "3", ev("3"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual([ + "input.select.word.forward", + "input.select.word.forward", + "input.select.word.forward", + ]); + expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); + }); +}); + +// ── handleNormalKey — dG and cG ───────────────────────────── + +describe("handleNormalKey — dG and cG", () => { + const midPrompt: PromptAccess = { + getLine: (n) => ["hello world", "second line", "third line"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 1, + getCursorOffset: () => 12, + getPlainText: () => "hello world\nsecond line\nthird line", + }; + + it("dG deletes from cursor to buffer end", () => { + handleNormalKey(state, "d", ev("d"), midPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 33 }]); + expect(state.mode).toBe("normal"); + // Single snapshot source: the saveUndoSnapshot action, not a second + // push inside the deleteRange handler. + expect(saveUndoSnapshots(r.actions)).toHaveLength(1); + }); + + it("cG deletes from cursor to buffer end, enters insert", () => { + handleNormalKey(state, "c", ev("c"), midPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 33 }]); + expect(state.mode).toBe("insert"); + }); + + it("yG still works (no regression)", () => { + handleNormalKey(state, "y", ev("y"), midPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt); + expect(cmds(r.actions)).toContain("input.select.buffer.end"); + expect(r.actions.some((a) => a.type === "yankSelection")).toBe(true); + }); + + it("dG on empty buffer doesn't crash", () => { + handleNormalKey(state, "d", ev("d"), emptyPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), emptyPrompt); + expect(r.consume).toBe(true); + expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 0 }]); + }); + + it("dG with cursor at end of buffer", () => { + const endPrompt: PromptAccess = { + getLine: (n) => ["hello"][n] ?? "", + getLineCount: () => 1, + getCursorLine: () => 0, + getCursorOffset: () => 4, + getPlainText: () => "hello", + }; + handleNormalKey(state, "d", ev("d"), endPrompt); + const r = handleNormalKey(state, "G", ev("g", { shift: true }), endPrompt); + expect(deleteRanges(r.actions)).toEqual([{ start: 4, end: 4 }]); + }); +}); + +// ── handleNormalKey — shortcuts ───────────────────────────── + +describe("handleNormalKey — shortcuts", () => { + it("D dispatches input.delete.to.line.end", () => { + const r = handleNormalKey(state, "D", ev("d", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); + }); + + it("C dispatches input.delete.to.line.end and enters insert", () => { + const r = handleNormalKey(state, "C", ev("c", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.to.line.end"]); + expect(state.mode).toBe("insert"); + }); +}); + +// ── handleNormalKey — special keys ────────────────────────── + +describe("handleNormalKey — special keys", () => { + it(": dispatches command.palette.show", () => { + const r = handleNormalKey(state, ":", ev(":"), mockPrompt); + expect(cmds(r.actions)).toEqual(["command.palette.show"]); + }); + + it("/ dispatches session.timeline", () => { + const r = handleNormalKey(state, "/", ev("/"), mockPrompt); + expect(cmds(r.actions)).toEqual(["session.timeline"]); + }); + + it("[ dispatches session.half.page.up", () => { + const r = handleNormalKey(state, "[", ev("["), mockPrompt); + expect(cmds(r.actions)).toEqual(["session.half.page.up"]); + }); + + it("] dispatches session.half.page.down", () => { + const r = handleNormalKey(state, "]", ev("]"), mockPrompt); + expect(cmds(r.actions)).toEqual(["session.half.page.down"]); + }); + + it("{ dispatches session.message.previous", () => { + const r = handleNormalKey(state, "{", ev("[", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["session.message.previous"]); + }); + + it("} dispatches session.message.next", () => { + const r = handleNormalKey(state, "}", ev("]", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["session.message.next"]); + }); + + it("X dispatches input.backspace", () => { + const r = handleNormalKey(state, "X", ev("x", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.backspace"]); + }); + + it("J dispatches input.line.end + input.delete", () => { + const r = handleNormalKey(state, "J", ev("j", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.line.end", "input.delete"]); + }); + + it("u triggers undo", () => { + const r = handleNormalKey(state, "u", ev("u"), mockPrompt); + expect(r.actions.some((a) => a.type === "undo")).toBe(true); + }); + + it("ctrl+r dispatches input.redo", () => { + const r = handleNormalKey(state, "r", ev("r", { ctrl: true }), mockPrompt); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toEqual(["input.redo"]); + }); + + it("Enter in normal mode submits the prompt", () => { + const r = handleNormalKey(state, "return", ev("return"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.submit"]); + }); + + it("x deletes the character under the cursor", () => { + const r = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete"]); + }); + + it("3x deletes three characters", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + const r = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete", "input.delete", "input.delete"]); + }); + + it("p with yankRegister set pastes", () => { + state.yankRegister = "yanked text\n"; + const r = handleNormalKey(state, "p", ev("p"), mockPrompt); + expect(r.actions.some((a) => a.type === "yank")).toBe(true); + expect(cmds(r.actions)).toContain("prompt.paste"); + }); + + it("meta combo → passthrough", () => { + const r = handleNormalKey(state, "c", ev("c", { meta: true }), mockPrompt); + expect(r.consume).toBe(false); + }); + + it("escape → passthrough, resets pendingOp", () => { + state.pendingOp = "d"; + const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); + expect(r.consume).toBe(false); + expect(state.pendingOp).toBeNull(); + }); +}); + +// ── handleNormalKey — replace (r) ────────────────────────── + +describe("handleNormalKey — replace (r)", () => { + it("r sets pendingChar, consumes key, no commands", () => { + const r = handleNormalKey(state, "r", ev("r"), mockPrompt); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + expect(state.pendingChar).toBe("r"); + }); + + it("r then a → input.delete + insertText('a'), stays normal", () => { + handleNormalKey(state, "r", ev("r"), mockPrompt); + const r = handleNormalKey(state, "a", ev("a"), mockPrompt); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toEqual(["input.delete"]); + expect(r.actions).toContainEqual({ type: "insertText", text: "a" }); + expect(state.mode).toBe("normal"); + expect(state.pendingChar).toBeNull(); + }); + + it("3ra → 3x input.delete + insertText('aaa')", () => { + handleNormalKey(state, "3", ev("3"), mockPrompt); + handleNormalKey(state, "r", ev("r"), mockPrompt); + const r = handleNormalKey(state, "a", ev("a"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete", "input.delete", "input.delete"]); + expect(r.actions).toContainEqual({ type: "insertText", text: "aaa" }); + }); + + it("r then escape → cancels, no commands", () => { + handleNormalKey(state, "r", ev("r"), mockPrompt); + const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); + expect(state.pendingChar).toBeNull(); + expect(cmds(r.actions)).toEqual([]); + }); + + it("r then digit → replaces with digit, not count", () => { + handleNormalKey(state, "r", ev("r"), mockPrompt); + const r = handleNormalKey(state, "5", ev("5"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.delete"]); + expect(r.actions).toContainEqual({ type: "insertText", text: "5" }); + expect(state.count).toBe(0); + }); +}); + +// ── handleNormalKey — insert entries ──────────────────────── + +describe("handleNormalKey — insert entries", () => { + it("i enters insert", () => { + const r = handleNormalKey(state, "i", ev("i"), mockPrompt); + expect(state.mode).toBe("insert"); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("a dispatches input.move.right, enters insert", () => { + const r = handleNormalKey(state, "a", ev("a"), mockPrompt); + expect(cmds(r.actions)).toContain("input.move.right"); + expect(state.mode).toBe("insert"); + }); + + it("A dispatches input.line.end, enters insert", () => { + const r = handleNormalKey(state, "A", ev("a", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toContain("input.line.end"); + expect(state.mode).toBe("insert"); + }); + + it("o dispatches input.line.end + input.newline, enters insert", () => { + const r = handleNormalKey(state, "o", ev("o"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.line.end", "input.newline"]); + expect(state.mode).toBe("insert"); + }); + + it("O dispatches input.line.home + input.newline + input.move.up, enters insert", () => { + const r = handleNormalKey(state, "O", ev("o", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.line.home", "input.newline", "input.move.up"]); + expect(state.mode).toBe("insert"); + }); +}); + +// ── handleNormalKey — yy uses cursor position ───────────── + +describe("handleNormalKey — yy uses cursor position", () => { + it("yy yanks the line at getCursorLine, not a tracked counter", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 1, + }; + handleNormalKey(state, "y", ev("y"), prompt); + const r = handleNormalKey(state, "y", ev("y"), prompt); + expect(state.yankRegister).toBe("second\n"); + expect(r.actions.some((a) => a.type === "yank" && a.text === "second\n")).toBe(true); + }); + + it("2yy from cursor line 1 yanks lines 1 and 2", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 1, + }; + handleNormalKey(state, "2", ev("2"), prompt); + handleNormalKey(state, "y", ev("y"), prompt); + handleNormalKey(state, "y", ev("y"), prompt); + expect(state.yankRegister).toBe("second\nthird\n"); + }); + + it("yy on last line yanks that line", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 2, + }; + handleNormalKey(state, "y", ev("y"), prompt); + handleNormalKey(state, "y", ev("y"), prompt); + expect(state.yankRegister).toBe("third\n"); + }); +}); + +// ── handleNormalKey — history scrolling ───────────────────── + +describe("handleNormalKey — history scrolling", () => { + it("j dispatches prompt.history.next when input is empty", () => { + const r = handleNormalKey(state, "j", ev("j"), emptyPrompt); + expect(cmds(r.actions)).toEqual(["prompt.history.next"]); + }); + + it("k dispatches prompt.history.previous when input is empty", () => { + const r = handleNormalKey(state, "k", ev("k"), emptyPrompt); + expect(cmds(r.actions)).toEqual(["prompt.history.previous"]); + }); + + it("j dispatches input.move.down when input is non-empty", () => { + const r = handleNormalKey(state, "j", ev("j"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.move.down"]); + }); + + it("3k dispatches prompt.history.previous 3 times when empty", () => { + handleNormalKey(state, "3", ev("3"), emptyPrompt); + const r = handleNormalKey(state, "k", ev("k"), emptyPrompt); + expect(cmds(r.actions)).toEqual(["prompt.history.previous", "prompt.history.previous", "prompt.history.previous"]); + }); + + it("dj still deletes lines when input is empty", () => { + handleNormalKey(state, "d", ev("d"), emptyPrompt); + const r = handleNormalKey(state, "j", ev("j"), emptyPrompt); + expect(cmds(r.actions)).toEqual(["input.delete.line", "input.delete.line"]); + }); +}); + +// ── handleNormalKey — visual mode entry ──────────────────── + +describe("handleNormalKey — visual mode entry", () => { + it("v enters visual mode", () => { + const r = handleNormalKey(state, "v", ev("v"), mockPrompt); + expect(r.consume).toBe(true); + expect(state.mode).toBe("visual"); + expect(r.actions).toContainEqual({ type: "mode", mode: "visual" }); + }); + + it("v clears pending operator", () => { + state.pendingOp = "d"; + handleNormalKey(state, "v", ev("v"), mockPrompt); + expect(state.pendingOp).toBeNull(); + expect(state.mode).toBe("visual"); + }); + + it("V selects the current line and enters visual mode", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 1, + getCursorOffset: () => 8, + getPlainText: () => "first\nsecond\nthird", + }; + + const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); + + expect(r.consume).toBe(true); + expect(state.mode).toBe("visual"); + expect(selectRanges(r.actions)).toEqual([{ start: 6, end: 12 }]); + expect(r.actions).toContainEqual({ type: "mode", mode: "visual" }); + }); + + it("V selects the first line including its newline", () => { + const r = handleNormalKey(state, "V", ev("v", { shift: true }), mockPrompt); + + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 11 }]); + }); + + it("V selects the last line without requiring a trailing newline", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 2, + getCursorOffset: () => 15, + getPlainText: () => "first\nsecond\nthird", + }; + + const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); + + expect(selectRanges(r.actions)).toEqual([{ start: 13, end: 17 }]); + }); +}); diff --git a/test/vim/state.test.ts b/test/vim/state.test.ts new file mode 100644 index 0000000..18e1896 --- /dev/null +++ b/test/vim/state.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "bun:test"; +import { createVimState, toggleVimMode } from "../../src/vim"; + +// ── createVimState ─────────────────────────────────────────── + +describe("createVimState", () => { + it("initializes disabled: false", () => { + const s = createVimState(); + expect(s.disabled).toBe(false); + }); +}); + +// ── toggleVimMode ──────────────────────────────────────────── + +describe("toggleVimMode", () => { + it("flips disabled from false to true", () => { + const s = createVimState(); + s.disabled = false; + toggleVimMode(s); + expect(s.disabled).toBe(true); + }); + + it("flips disabled from true to false", () => { + const s = createVimState(); + s.disabled = true; + toggleVimMode(s); + expect(s.disabled).toBe(false); + }); + + it("resets mode to insert when disabling", () => { + const s = createVimState(); + s.mode = "normal"; + s.pendingOp = "d"; + s.count = 3; + toggleVimMode(s); + expect(s.mode).toBe("insert"); + expect(s.pendingOp).toBeNull(); + expect(s.pendingChar).toBeNull(); + expect(s.count).toBe(0); + expect(s.oneShotNormal).toBe(false); + }); + + it("returns a toast and mode action when disabling", () => { + const s = createVimState(); + s.disabled = false; + const r = toggleVimMode(s); + expect(r.actions).toContainEqual({ type: "toast", message: "Vim mode disabled" }); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("returns a toast action with 'Vim mode enabled' when enabling", () => { + const s = createVimState(); + s.disabled = true; + const r = toggleVimMode(s); + expect(r.actions).toContainEqual({ type: "toast", message: "Vim mode enabled" }); + }); + + it("does not reset mode when enabling", () => { + const s = createVimState(); + s.disabled = true; + s.mode = "normal"; + toggleVimMode(s); + expect(s.mode).toBe("normal"); + }); + + it("returns consume: true", () => { + const s = createVimState(); + const r = toggleVimMode(s); + expect(r.consume).toBe(true); + }); +}); diff --git a/test/vim/text.test.ts b/test/vim/text.test.ts new file mode 100644 index 0000000..86c17b4 --- /dev/null +++ b/test/vim/text.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "bun:test"; +import { endOfWord } from "../../src/vim"; +import { charKind, currentLineRange, isWhitespace } from "../../src/vim/text"; + +// ── endOfWord ────────────────────────────────────────────── + +describe("endOfWord", () => { + it("from start of word, moves to last char", () => { + expect(endOfWord("hello world", 0)).toBe(4); + }); + + it("from middle of word, moves to last char", () => { + expect(endOfWord("hello world", 2)).toBe(4); + }); + + it("from end of word, moves to end of next word", () => { + expect(endOfWord("hello world", 4)).toBe(10); + }); + + it("from whitespace, skips to end of next word", () => { + expect(endOfWord("hello world", 5)).toBe(10); + }); + + it("stops at punctuation boundary", () => { + expect(endOfWord("hello.world", 0)).toBe(4); + }); + + it("from punctuation, moves to end of punctuation run", () => { + expect(endOfWord("hello...world", 5)).toBe(7); + }); + + it("from end of punctuation, moves to end of next word", () => { + expect(endOfWord("a.b", 1)).toBe(2); + }); + + it("at end of text, stays put", () => { + expect(endOfWord("hello", 4)).toBe(4); + }); + + it("handles count > 1", () => { + expect(endOfWord("one two three", 0, 2)).toBe(6); + }); + + it("handles multiple whitespace", () => { + expect(endOfWord("hello world", 0)).toBe(4); + expect(endOfWord("hello world", 4)).toBe(12); + }); + + it("handles newlines as whitespace", () => { + expect(endOfWord("hello\nworld", 4)).toBe(10); + }); + + it("clamps at end of text", () => { + expect(endOfWord("hi", 0, 5)).toBe(1); + }); +}); + +// ── isWhitespace ─────────────────────────────────────────── + +describe("isWhitespace", () => { + it("returns true for a space", () => { + expect(isWhitespace(" ")).toBe(true); + }); + + it("returns true for a tab", () => { + expect(isWhitespace("\t")).toBe(true); + }); + + it("returns true for a newline", () => { + expect(isWhitespace("\n")).toBe(true); + }); + + it("returns true for a carriage return", () => { + expect(isWhitespace("\r")).toBe(true); + }); + + it("returns false for a word character", () => { + expect(isWhitespace("a")).toBe(false); + }); + + it("returns false for punctuation", () => { + expect(isWhitespace(".")).toBe(false); + }); +}); + +// ── charKind ─────────────────────────────────────────────── + +describe("charKind", () => { + it("classifies a letter as word", () => { + expect(charKind("a")).toBe("word"); + }); + + it("classifies a digit as word", () => { + expect(charKind("7")).toBe("word"); + }); + + it("classifies an underscore as word", () => { + expect(charKind("_")).toBe("word"); + }); + + it("classifies whitespace as space", () => { + expect(charKind(" ")).toBe("space"); + expect(charKind("\n")).toBe("space"); + }); + + it("classifies punctuation as punct", () => { + expect(charKind(".")).toBe("punct"); + expect(charKind("-")).toBe("punct"); + }); +}); + +// ── currentLineRange ─────────────────────────────────────── + +describe("currentLineRange", () => { + it("returns {0,0} for empty text", () => { + expect(currentLineRange("", 0)).toEqual({ start: 0, end: 0 }); + }); + + it("spans a single line to its final char index", () => { + expect(currentLineRange("hello", 2)).toEqual({ start: 0, end: 4 }); + }); + + it("ends at the newline for a middle line", () => { + // "hello\nworld" — offset 8 is on the second (last) line; end clamps to len-1 + expect(currentLineRange("hello\nworld", 8)).toEqual({ start: 6, end: 10 }); + }); + + it("starts after the preceding newline", () => { + // offset 0 is on the first line, which ends at the first newline (index 5) + expect(currentLineRange("hello\nworld", 0)).toEqual({ start: 0, end: 5 }); + }); + + it("clamps an out-of-range offset into the text", () => { + expect(currentLineRange("hello", 99)).toEqual({ start: 0, end: 4 }); + }); +}); diff --git a/test/vim/util.test.ts b/test/vim/util.test.ts new file mode 100644 index 0000000..2352e7a --- /dev/null +++ b/test/vim/util.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import { translateKey } from "../../src/vim"; +import { ev } from "../support"; + +// ── translateKey ──────────────────────────────────────────── + +describe("translateKey", () => { + it("lowercase passes through", () => { + expect(translateKey(ev("h"))).toBe("h"); + }); + + it("shift+letter uppercases", () => { + expect(translateKey(ev("g", { shift: true }))).toBe("G"); + }); + + it("shift+4 → $", () => { + expect(translateKey(ev("4", { shift: true }))).toBe("$"); + }); + + it("shift+6 → ^", () => { + expect(translateKey(ev("6", { shift: true }))).toBe("^"); + }); + + it("shift+[ → {", () => { + expect(translateKey(ev("[", { shift: true }))).toBe("{"); + }); + + it("shift+] → }", () => { + expect(translateKey(ev("]", { shift: true }))).toBe("}"); + }); +}); diff --git a/test/vim/visual.test.ts b/test/vim/visual.test.ts new file mode 100644 index 0000000..b922698 --- /dev/null +++ b/test/vim/visual.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it } from "bun:test"; +import { createVimState, handleVisualKey, type PromptAccess, type VimState } from "../../src/vim"; +import { mockPrompt } from "../fixtures"; +import { cmds, cursorTos, ev, selectRanges } from "../support"; + +let state: VimState; + +beforeEach(() => { + state = createVimState(); + state.mode = "normal"; +}); + +// ── handleVisualKey — motions ────────────────────────────── + +describe("handleVisualKey — motions", () => { + beforeEach(() => { + state.mode = "visual"; + }); + + it("h dispatches input.select.left", () => { + const r = handleVisualKey(state, "h", ev("h")); + expect(cmds(r.actions)).toEqual(["input.select.left"]); + }); + + it("l dispatches input.select.right", () => { + const r = handleVisualKey(state, "l", ev("l")); + expect(cmds(r.actions)).toEqual(["input.select.right"]); + }); + + it("j dispatches input.select.down", () => { + const r = handleVisualKey(state, "j", ev("j")); + expect(cmds(r.actions)).toEqual(["input.select.down"]); + }); + + it("k dispatches input.select.up", () => { + const r = handleVisualKey(state, "k", ev("k")); + expect(cmds(r.actions)).toEqual(["input.select.up"]); + }); + + it("w dispatches input.select.word.forward", () => { + const r = handleVisualKey(state, "w", ev("w")); + expect(cmds(r.actions)).toEqual(["input.select.word.forward"]); + }); + + it("$ dispatches input.select.line.end", () => { + const r = handleVisualKey(state, "$", ev("4", { shift: true })); + expect(cmds(r.actions)).toEqual(["input.select.line.end"]); + }); + + it("3l dispatches input.select.right 3 times", () => { + handleVisualKey(state, "3", ev("3")); + const r = handleVisualKey(state, "l", ev("l")); + expect(cmds(r.actions)).toEqual(["input.select.right", "input.select.right", "input.select.right"]); + }); + + it("G dispatches input.select.buffer.end", () => { + const r = handleVisualKey(state, "G", ev("g", { shift: true }), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.select.buffer.end"]); + }); + + it("e selects from visual anchor to end of word", () => { + // "hello world" with cursor at 0, anchor at 0 → end of "hello" is offset 4 + state.visualAnchor = 0; + const r = handleVisualKey(state, "e", ev("e"), mockPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]); + }); + + it("2e selects from visual anchor to end of 2nd word", () => { + // "hello world" with cursor at 0, anchor at 0 → end of "world" is offset 10 + state.visualAnchor = 0; + handleVisualKey(state, "2", ev("2"), mockPrompt); + const r = handleVisualKey(state, "e", ev("e"), mockPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 10 }]); + }); + + it("e pressed twice extends selection to successive word ends", () => { + // "hello world" cursor at 0, anchor at 0 + // First e → end of "hello" (offset 4), must also emit cursorTo + // Second e (cursor now at 4) → end of "world" (offset 10) + state.visualAnchor = 0; + let cursorPos = 0; + const prompt: PromptAccess = { ...mockPrompt, getCursorOffset: () => cursorPos }; + + const r1 = handleVisualKey(state, "e", ev("e"), prompt); + expect(selectRanges(r1.actions)).toEqual([{ start: 0, end: 4 }]); + expect(cursorTos(r1.actions)).toEqual([4]); + + // Simulate effect layer applying the cursorTo action + cursorPos = cursorTos(r1.actions)[0]; + + const r2 = handleVisualKey(state, "e", ev("e"), prompt); + expect(selectRanges(r2.actions)).toEqual([{ start: 0, end: 10 }]); + expect(cursorTos(r2.actions)).toEqual([10]); + }); + + it("g sets pendingChar, no actions", () => { + const r = handleVisualKey(state, "g", ev("g")); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + expect(state.pendingChar).toBe("g"); + }); + + it("gg selects to buffer home", () => { + handleVisualKey(state, "g", ev("g")); + const r = handleVisualKey(state, "g", ev("g")); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toEqual(["input.select.buffer.home"]); + expect(state.pendingChar).toBeNull(); + }); + + it("g then Escape in visual cancels pending, stays visual", () => { + handleVisualKey(state, "g", ev("g")); + handleVisualKey(state, "escape", ev("escape")); + expect(state.pendingChar).toBeNull(); + // escape also exits visual mode + expect(state.mode).toBe("normal"); + }); +}); + +// ── handleVisualKey — operators ──────────────────────────── + +describe("handleVisualKey — operators", () => { + beforeEach(() => { + state.mode = "visual"; + }); + + it("d deletes selection and enters normal mode", () => { + const r = handleVisualKey(state, "d", ev("d")); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toContain("input.backspace"); + expect(state.mode).toBe("normal"); + expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); + }); + + it("c deletes selection and enters insert mode", () => { + const r = handleVisualKey(state, "c", ev("c")); + expect(r.consume).toBe(true); + expect(cmds(r.actions)).toContain("input.backspace"); + expect(state.mode).toBe("insert"); + expect(r.actions).toContainEqual({ type: "mode", mode: "insert" }); + }); + + it("y yanks selection and enters normal mode", () => { + const r = handleVisualKey(state, "y", ev("y")); + expect(r.consume).toBe(true); + expect(r.actions).toContainEqual({ type: "yankSelection" }); + expect(state.mode).toBe("normal"); + expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); + }); + + it("x deletes selection (alias for d)", () => { + const r = handleVisualKey(state, "x", ev("x")); + expect(cmds(r.actions)).toContain("input.backspace"); + expect(state.mode).toBe("normal"); + }); +}); + +// ── handleVisualKey — exit and passthrough ───────────────── + +describe("handleVisualKey — exit and passthrough", () => { + beforeEach(() => { + state.mode = "visual"; + }); + + it("Escape exits visual mode and clears selection", () => { + const r = handleVisualKey(state, "escape", ev("escape")); + expect(r.consume).toBe(true); + expect(state.mode).toBe("normal"); + expect(r.actions).toContainEqual({ type: "clearSelection" }); + expect(r.actions).toContainEqual({ type: "mode", mode: "normal" }); + }); + + it("v exits visual mode and clears selection", () => { + const r = handleVisualKey(state, "v", ev("v")); + expect(r.consume).toBe(true); + expect(state.mode).toBe("normal"); + expect(r.actions).toContainEqual({ type: "clearSelection" }); + }); + + it("meta combo passes through", () => { + const r = handleVisualKey(state, "c", ev("c", { meta: true })); + expect(r.consume).toBe(false); + }); + + it("ctrl combo passes through", () => { + const r = handleVisualKey(state, "x", ev("x", { ctrl: true })); + expect(r.consume).toBe(false); + }); + + it("unrecognized key is consumed (no typing in visual)", () => { + const r = handleVisualKey(state, "z", ev("z")); + expect(r.consume).toBe(true); + expect(r.actions).toEqual([]); + }); +}); From 89e9d9c9f45c5735224535a43c00bb44e16e2f35 Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:26:22 +0300 Subject: [PATCH 14/18] docs: document the modular vim engine layout --- AGENTS.md | 42 +++++++++++++++++++++++++++++++----------- CHANGELOG.md | 4 ++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a07105a..de2e81f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,14 +57,32 @@ 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 (395 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 (49 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 (79 lines) VimState lifecycle + transitions + insert.ts (32 lines) handleInsertKey + normal.ts (338 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 (71) createVimState, toggleVimMode + util.test.ts (31) translateKey + insert.test.ts (92) handleInsertKey + normal.test.ts (620) handleNormalKey branches + visual.test.ts (195) handleVisualKey branches + integration.test.ts (411) 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:** @@ -75,7 +93,7 @@ KeyEvent → translateKey() → handleInsertKey/handleNormalKey/handleVisualKey( (count, pendingOp, pendingChar, 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)` @@ -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) @@ -110,7 +128,7 @@ 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 `pendingOp` is set 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" }` @@ -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). @@ -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 sections — those 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 firewall — never 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. diff --git a/CHANGELOG.md b/CHANGELOG.md index feeba26..a2aa6b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ 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. + ## [0.16.0] — 2026-08-31 ### Added From 24c1ebf7e6bc0d5821a72f1a2c27bdfcbf516d6f Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:31:08 +0300 Subject: [PATCH 15/18] refactor: consolidate pending state into a discriminated union --- AGENTS.md | 6 ++-- src/vim/normal.ts | 36 +++++++++++----------- src/vim/state.ts | 11 +++---- src/vim/types.ts | 7 +++-- src/vim/visual.ts | 6 ++-- test/integration.test.ts | 7 +++++ test/vim/normal.test.ts | 65 ++++++++++++++++++++++++++++++---------- test/vim/state.test.ts | 5 ++-- test/vim/visual.test.ts | 8 ++--- 9 files changed, 96 insertions(+), 55 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index de2e81f..1f4b934 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ test/ 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 `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. @@ -128,12 +128,12 @@ Handlers in `src/vim/` (`insert.ts`, `normal.ts`, `visual.ts`) are pure — they ### Adding an operator+motion combo -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 `pendingOp` is set and a motion key arrives, `handleNormalKey` (in `src/vim/normal.ts`) 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 diff --git a/src/vim/normal.ts b/src/vim/normal.ts index 91048ed..0c14fae 100644 --- a/src/vim/normal.ts +++ b/src/vim/normal.ts @@ -26,18 +26,18 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom } // Pending character argument (r{char}) - if (state.pendingChar === "r") { + if (state.pending.kind === "replace") { const n = consumeCount(state); const actions: Action[] = []; pushN(actions, "input.delete", n); actions.push({ type: "insertText", text: key.repeat(n) }); - state.pendingChar = null; + state.pending = { kind: "none" }; return finishUndoableChange(actions); } // Pending g prefix (gg, ge, etc.) - if (state.pendingChar === "g") { - state.pendingChar = null; + if (state.pending.kind === "goto") { + state.pending = { kind: "none" }; const actions: Action[] = []; if (key === "g") { consumeCount(state); @@ -123,7 +123,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom // Operators: d, c, y if (key === "d" || key === "c" || key === "y") { - if (state.pendingOp === key) { + if (state.pending.kind === "operator" && state.pending.op === key) { const n = consumeCount(state); if (key === "y") { const cursorLine = prompt.getCursorLine(); @@ -142,7 +142,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom } return { consume: true, actions }; } - state.pendingOp = key; + state.pending = { kind: "operator", op: key }; return { consume: true, actions }; } @@ -159,11 +159,12 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom } // Pending operator + e (end-of-word needs special handling) - if (state.pendingOp && key === "e") { + if (state.pending.kind === "operator" && key === "e") { + const op = state.pending.op; const n = consumeCount(state); const offset = prompt.getCursorOffset(); const target = endOfWord(prompt.getPlainText(), offset, n); - if (state.pendingOp === "y") { + if (op === "y") { const text = prompt.getPlainText().slice(offset, target + 1); state.yankRegister = text; actions.push({ type: "yank", text }); @@ -171,16 +172,17 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom return { consume: true, actions }; } actions.push({ type: "deleteRange", start: offset, end: target }); - if (state.pendingOp === "c") enterInsert(state, actions); + if (op === "c") enterInsert(state, actions); else resetPending(state); return finishUndoableChange(actions); } // Pending operator + motion - if (state.pendingOp && key in MOTIONS) { + if (state.pending.kind === "operator" && key in MOTIONS) { + const op = state.pending.op; const n = consumeCount(state); - if (state.pendingOp === "y") { + if (op === "y") { const selectCmd = SELECT_MOTIONS[key]; if (selectCmd) { pushN(actions, selectCmd, n); @@ -192,14 +194,14 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom if (key === "j") { pushN(actions, "input.delete.line", n + 1); - if (state.pendingOp === "c") enterInsert(state, actions); + if (op === "c") enterInsert(state, actions); else resetPending(state); return finishUndoableChange(actions); } if (key === "k") { pushN(actions, "input.move.up", n); pushN(actions, "input.delete.line", n + 1); - if (state.pendingOp === "c") enterInsert(state, actions); + if (op === "c") enterInsert(state, actions); else resetPending(state); return finishUndoableChange(actions); } @@ -208,7 +210,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom const offset = prompt.getCursorOffset(); const text = prompt.getPlainText(); actions.push({ type: "deleteRange", start: offset, end: Math.max(0, text.length - 1) }); - if (state.pendingOp === "c") enterInsert(state, actions); + if (op === "c") enterInsert(state, actions); else resetPending(state); return finishUndoableChange(actions); } @@ -216,7 +218,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom const deleteCmd = DELETE_MOTION[key]; if (deleteCmd) { pushN(actions, deleteCmd, n); - if (state.pendingOp === "c") enterInsert(state, actions); + if (op === "c") enterInsert(state, actions); else resetPending(state); return finishUndoableChange(actions); } @@ -248,7 +250,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom // g prefix — wait for second keypress if (key === "g") { - state.pendingChar = "g"; + state.pending = { kind: "goto" }; return { consume: true, actions }; } @@ -258,7 +260,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom } if (key === "r") { - state.pendingChar = "r"; + state.pending = { kind: "replace" }; return { consume: true, actions }; } diff --git a/src/vim/state.ts b/src/vim/state.ts index 32f35ad..b84a4dc 100644 --- a/src/vim/state.ts +++ b/src/vim/state.ts @@ -3,8 +3,7 @@ import type { Action, HandlerResult, VimState } from "./types"; export function createVimState(): VimState { return { mode: "insert", - pendingOp: null, - pendingChar: null, + pending: { kind: "none" }, count: 0, yankRegister: "", oneShotNormal: false, @@ -18,8 +17,7 @@ export function toggleVimMode(state: VimState): HandlerResult { // Reset to clean insert mode so cursor style updates and no stale // pending state carries over when re-enabled. state.mode = "insert"; - state.pendingOp = null; - state.pendingChar = null; + state.pending = { kind: "none" }; state.count = 0; state.oneShotNormal = false; return { @@ -36,7 +34,7 @@ export function toggleVimMode(state: VimState): HandlerResult { export function finishOneShotIfComplete(state: VimState, result: HandlerResult): void { if (!state.oneShotNormal) return; if (!result.consume) return; - if (state.pendingOp !== null || state.pendingChar !== null || state.count > 0) return; + if (state.pending.kind !== "none" || state.count > 0) return; const alreadyEnteringInsert = result.actions.some((a) => a.type === "mode" && a.mode === "insert"); if (alreadyEnteringInsert) { state.oneShotNormal = false; @@ -48,8 +46,7 @@ export function finishOneShotIfComplete(state: VimState, result: HandlerResult): } export function resetPending(state: VimState) { - state.pendingOp = null; - state.pendingChar = null; + state.pending = { kind: "none" }; state.count = 0; } diff --git a/src/vim/types.ts b/src/vim/types.ts index 7ca9f76..e5d8098 100644 --- a/src/vim/types.ts +++ b/src/vim/types.ts @@ -1,5 +1,7 @@ export type Mode = "normal" | "insert" | "visual" | "(insert)"; -export type Operator = "d" | "c" | "y" | null; +export type Operator = "d" | "c" | "y"; + +export type Pending = { kind: "none" } | { kind: "operator"; op: Operator } | { kind: "goto" } | { kind: "replace" }; export type Action = | { type: "cmd"; cmd: string } @@ -22,8 +24,7 @@ export type HandlerResult = { export type VimState = { mode: Mode; - pendingOp: Operator; - pendingChar: "r" | "g" | null; + pending: Pending; count: number; yankRegister: string; oneShotNormal: boolean; diff --git a/src/vim/visual.ts b/src/vim/visual.ts index a35c4fb..dc9a5b6 100644 --- a/src/vim/visual.ts +++ b/src/vim/visual.ts @@ -11,8 +11,8 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom const actions: Action[] = []; // Pending g prefix in visual mode - if (state.pendingChar === "g") { - state.pendingChar = null; + if (state.pending.kind === "goto") { + state.pending = { kind: "none" }; if (key === "g") { actions.push({ type: "cmd", cmd: "input.select.buffer.home" }); state.count = 0; @@ -69,7 +69,7 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom // g prefix — wait for second keypress if (key === "g") { - state.pendingChar = "g"; + state.pending = { kind: "goto" }; return { consume: true, actions }; } diff --git a/test/integration.test.ts b/test/integration.test.ts index e7ded01..14ea12e 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -187,6 +187,13 @@ describe("Ctrl+O one-shot normal mode", () => { expect(state.oneShotNormal).toBe(false); expect(result.actions.filter((a) => a.type === "mode" && a.mode === "insert").length).toBe(1); }); + + it("Ctrl+O one-shot stays in normal mode while an operator is pending", () => { + state.oneShotNormal = true; + const result = handleNormalKey(state, "d", ev("d"), mockPrompt); + finishOneShotIfComplete(state, result); + expect(state.mode).toBe("normal"); + }); }); describe("version sync", () => { diff --git a/test/vim/normal.test.ts b/test/vim/normal.test.ts index 37e7fc6..aa794aa 100644 --- a/test/vim/normal.test.ts +++ b/test/vim/normal.test.ts @@ -55,11 +55,11 @@ describe("handleNormalKey — motions", () => { expect(state.count).toBe(10); }); - it("g sets pendingChar, no actions", () => { + it("g sets pending goto, no actions", () => { const r = handleNormalKey(state, "g", ev("g"), mockPrompt); expect(r.consume).toBe(true); expect(r.actions).toEqual([]); - expect(state.pendingChar).toBe("g"); + expect(state.pending).toEqual({ kind: "goto" }); }); }); @@ -71,13 +71,13 @@ describe("handleNormalKey — g prefix", () => { const r = handleNormalKey(state, "g", ev("g"), mockPrompt); expect(r.consume).toBe(true); expect(cursorTos(r.actions)).toEqual([0]); - expect(state.pendingChar).toBeNull(); + expect(state.pending).toEqual({ kind: "none" }); }); it("g then Escape cancels pending, no movement", () => { handleNormalKey(state, "g", ev("g"), mockPrompt); const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); - expect(state.pendingChar).toBeNull(); + expect(state.pending).toEqual({ kind: "none" }); expect(r.actions).toEqual([]); }); @@ -85,7 +85,7 @@ describe("handleNormalKey — g prefix", () => { handleNormalKey(state, "g", ev("g"), mockPrompt); const r = handleNormalKey(state, "z", ev("z"), mockPrompt); expect(r.consume).toBe(true); - expect(state.pendingChar).toBeNull(); + expect(state.pending).toEqual({ kind: "none" }); expect(cursorTos(r.actions)).toEqual([]); expect(cmds(r.actions)).toEqual([]); }); @@ -152,7 +152,7 @@ describe("handleNormalKey — e motion", () => { describe("handleNormalKey — operators", () => { it("dd dispatches input.delete.line", () => { handleNormalKey(state, "d", ev("d"), mockPrompt); - expect(state.pendingOp).toBe("d"); + expect(state.pending).toEqual({ kind: "operator", op: "d" }); const r = handleNormalKey(state, "d", ev("d"), mockPrompt); expect(cmds(r.actions)).toEqual(["input.delete.line"]); }); @@ -408,22 +408,22 @@ describe("handleNormalKey — special keys", () => { expect(r.consume).toBe(false); }); - it("escape → passthrough, resets pendingOp", () => { - state.pendingOp = "d"; + it("escape → passthrough, resets pending operator", () => { + state.pending = { kind: "operator", op: "d" }; const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); expect(r.consume).toBe(false); - expect(state.pendingOp).toBeNull(); + expect(state.pending).toEqual({ kind: "none" }); }); }); // ── handleNormalKey — replace (r) ────────────────────────── describe("handleNormalKey — replace (r)", () => { - it("r sets pendingChar, consumes key, no commands", () => { + it("r sets pending replace, consumes key, no commands", () => { const r = handleNormalKey(state, "r", ev("r"), mockPrompt); expect(r.consume).toBe(true); expect(r.actions).toEqual([]); - expect(state.pendingChar).toBe("r"); + expect(state.pending).toEqual({ kind: "replace" }); }); it("r then a → input.delete + insertText('a'), stays normal", () => { @@ -433,7 +433,7 @@ describe("handleNormalKey — replace (r)", () => { expect(cmds(r.actions)).toEqual(["input.delete"]); expect(r.actions).toContainEqual({ type: "insertText", text: "a" }); expect(state.mode).toBe("normal"); - expect(state.pendingChar).toBeNull(); + expect(state.pending).toEqual({ kind: "none" }); }); it("3ra → 3x input.delete + insertText('aaa')", () => { @@ -447,7 +447,7 @@ describe("handleNormalKey — replace (r)", () => { it("r then escape → cancels, no commands", () => { handleNormalKey(state, "r", ev("r"), mockPrompt); const r = handleNormalKey(state, "escape", ev("escape"), mockPrompt); - expect(state.pendingChar).toBeNull(); + expect(state.pending).toEqual({ kind: "none" }); expect(cmds(r.actions)).toEqual([]); }); @@ -575,9 +575,9 @@ describe("handleNormalKey — visual mode entry", () => { }); it("v clears pending operator", () => { - state.pendingOp = "d"; + state.pending = { kind: "operator", op: "d" }; handleNormalKey(state, "v", ev("v"), mockPrompt); - expect(state.pendingOp).toBeNull(); + expect(state.pending).toEqual({ kind: "none" }); expect(state.mode).toBe("visual"); }); @@ -618,3 +618,38 @@ describe("handleNormalKey — visual mode entry", () => { expect(selectRanges(r.actions)).toEqual([{ start: 13, end: 17 }]); }); }); + +// ── handleNormalKey — pending cleanup ────────────────────── + +describe("handleNormalKey — pending cleanup", () => { + it("dgg does not leave a dangling operator", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + expect(state.pending).toEqual({ kind: "none" }); + }); + + it("dgg then w moves by word (the old dangling 'd' would have deleted it)", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + handleNormalKey(state, "g", ev("g"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.word.forward"]); // NOT input.delete.word.forward + }); + + it("drx replaces the char and clears pending", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "r", ev("r"), mockPrompt); + const r = handleNormalKey(state, "x", ev("x"), mockPrompt); + expect(state.pending).toEqual({ kind: "none" }); + expect(r.actions.some((a) => a.type === "insertText")).toBe(true); + }); + + it("drx then w moves by word (no dangling delete-operator)", () => { + handleNormalKey(state, "d", ev("d"), mockPrompt); + handleNormalKey(state, "r", ev("r"), mockPrompt); + handleNormalKey(state, "x", ev("x"), mockPrompt); + const r = handleNormalKey(state, "w", ev("w"), mockPrompt); + expect(cmds(r.actions)).toEqual(["input.word.forward"]); + }); +}); diff --git a/test/vim/state.test.ts b/test/vim/state.test.ts index 18e1896..4a06f42 100644 --- a/test/vim/state.test.ts +++ b/test/vim/state.test.ts @@ -30,12 +30,11 @@ describe("toggleVimMode", () => { it("resets mode to insert when disabling", () => { const s = createVimState(); s.mode = "normal"; - s.pendingOp = "d"; + s.pending = { kind: "operator", op: "d" }; s.count = 3; toggleVimMode(s); expect(s.mode).toBe("insert"); - expect(s.pendingOp).toBeNull(); - expect(s.pendingChar).toBeNull(); + expect(s.pending).toEqual({ kind: "none" }); expect(s.count).toBe(0); expect(s.oneShotNormal).toBe(false); }); diff --git a/test/vim/visual.test.ts b/test/vim/visual.test.ts index b922698..edaa5f8 100644 --- a/test/vim/visual.test.ts +++ b/test/vim/visual.test.ts @@ -93,11 +93,11 @@ describe("handleVisualKey — motions", () => { expect(cursorTos(r2.actions)).toEqual([10]); }); - it("g sets pendingChar, no actions", () => { + it("g sets pending goto, no actions", () => { const r = handleVisualKey(state, "g", ev("g")); expect(r.consume).toBe(true); expect(r.actions).toEqual([]); - expect(state.pendingChar).toBe("g"); + expect(state.pending).toEqual({ kind: "goto" }); }); it("gg selects to buffer home", () => { @@ -105,13 +105,13 @@ describe("handleVisualKey — motions", () => { const r = handleVisualKey(state, "g", ev("g")); expect(r.consume).toBe(true); expect(cmds(r.actions)).toEqual(["input.select.buffer.home"]); - expect(state.pendingChar).toBeNull(); + expect(state.pending).toEqual({ kind: "none" }); }); it("g then Escape in visual cancels pending, stays visual", () => { handleVisualKey(state, "g", ev("g")); handleVisualKey(state, "escape", ev("escape")); - expect(state.pendingChar).toBeNull(); + expect(state.pending).toEqual({ kind: "none" }); // escape also exits visual mode expect(state.mode).toBe("normal"); }); From 7dbabb6fe4a449b876287be8cd5f3684a176c71d Mon Sep 17 00:00:00 2001 From: ori Date: Thu, 23 Jul 2026 18:36:39 +0300 Subject: [PATCH 16/18] docs: note dangling-operator fix in changelog, sync test line counts --- AGENTS.md | 6 +++--- CHANGELOG.md | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1f4b934..76fb962 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,12 +76,12 @@ test/ 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 (71) createVimState, toggleVimMode + state.test.ts (70) createVimState, toggleVimMode util.test.ts (31) translateKey insert.test.ts (92) handleInsertKey - normal.test.ts (620) handleNormalKey branches + normal.test.ts (655) handleNormalKey branches visual.test.ts (195) handleVisualKey branches - integration.test.ts (411) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync + 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 ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index a2aa6b9..bf74646 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version - 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 From e9c4acaf849f31320e32b3d39edcacb7038e076a Mon Sep 17 00:00:00 2001 From: ori Date: Mon, 31 Aug 2026 21:19:52 +0300 Subject: [PATCH 17/18] chore: tighten Biome lint config Enforce noExplicitAny, noConsole, and noNonNullAssertion as errors, turn on import sorting, and pin strict formatter options (semicolons, trailing commas, arrow parens). CI runs `biome ci --error-on-warnings`, so these now fail the build. --- biome.json | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/biome.json b/biome.json index 2678d06..1caea70 100644 --- a/biome.json +++ b/biome.json @@ -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" } } } From 3e9e6acb5ad72b85b2847c8a160c5cfa666dc8e9 Mon Sep 17 00:00:00 2001 From: ori Date: Mon, 31 Aug 2026 21:19:52 +0300 Subject: [PATCH 18/18] docs: sync AGENTS.md line counts after rebase index.ts 395->408 after the :w/:write merge (#60); types/state/normal drift from the pending-state consolidation. --- AGENTS.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 76fb962..1bbe44d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,16 +57,16 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (395 lines) Plugin entry: intercept registration, action application + 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 (49 lines) Action union, VimState, Mode, Operator, KeyEvent, HandlerResult, PromptAccess + 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 (79 lines) VimState lifecycle + transitions + state.ts (76 lines) VimState lifecycle + transitions insert.ts (32 lines) handleInsertKey - normal.ts (338 lines) handleNormalKey (+ file-local finishUndoableChange, isInputEmpty) + 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)