Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,27 +65,27 @@ src/
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 (57 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess
text.ts (210 lines) Pure string algorithms: charKind, endOfWord, currentLineRange, wordRange, bracketRange, quoteRange, anyBracketRange, anyQuoteRange
text.ts (237 lines) Pure string algorithms: charKind, endOfWord, wordMotion (B/W/E), currentLineRange, wordRange, bracketRange, quoteRange, anyBracketRange, anyQuoteRange
tables.ts (35 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal)
textobject.ts (36 lines) resolveTextObject — object char → inclusive Range seam (iw/aw, quote/bracket pairs, iq/ib)
util.ts (19 lines) State-agnostic primitives: translateKey, PASS, pushN
state.ts (76 lines) VimState lifecycle + transitions
insert.ts (32 lines) handleInsertKey
normal.ts (378 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty)
visual.ts (104 lines) handleVisualKey
normal.ts (411 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty)
visual.ts (112 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/
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 (385) endOfWord, charKind, currentLineRange, wordRange, bracketRange, quoteRange, any* units
text.test.ts (545) endOfWord, wordMotion, charKind, currentLineRange, wordRange, bracketRange, quoteRange, any* units
state.test.ts (70) createVimState, toggleVimMode
util.test.ts (31) translateKey
insert.test.ts (92) handleInsertKey
normal.test.ts (823) handleNormalKey branches
visual.test.ts (287) handleVisualKey branches
normal.test.ts (961) handleNormalKey branches
visual.test.ts (381) handleVisualKey branches
textobject.test.ts (64) resolveTextObject dispatch seam
integration.test.ts (662) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync, prompt overlay tracking
editor.test.ts (118) Host-coordinate boundaries and opaque selection-color forwarding
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version

## [Unreleased]

### Added

- Added `B`, `W`, and `E` WORD motions with counts, visual selections, and delete/change/yank operators, keeping punctuation and URLs together.

## [0.19.2] — 2026-10-14

### Added
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ The plugin checks GitHub for new versions once per day on startup. No other netw
|-----|--------|
| `h` `j` `k` `l` | Left, down, up, right |
| `w` `b` `e` | Word forward, backward, end of word |
| `W` `B` `E` | WORD forward, backward, end of WORD |
| `0` `^` | Line start |
| `$` | Line end |
| `gg` | Buffer start |
Expand All @@ -202,6 +203,9 @@ When the input is empty, `j`/`k` scroll through prompt history instead of moving
| `dw` `cw` `yw` | To next word |
| `db` `cb` `yb` | To previous word |
| `de` `ce` `ye` | To end of word |
| `dW` `cW` `yW` | To next WORD |
| `dB` `cB` `yB` | To previous WORD |
| `dE` `cE` `yE` | To end of WORD |
| `d$` `c$` `y$` | To end of line |
| `d0` `c0` `y0` | To start of line |
| `d^` `c^` `y^` | To start of line |
Expand Down Expand Up @@ -253,7 +257,7 @@ Press `v` in normal mode to enter character-wise visual mode. Press `V` to selec
| `V` | Select current line |
| `Escape` `v` | Exit visual mode |

All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b` `e` `0` `$` `G`, with counts.
All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b` `e` `W` `B` `E` `0` `$` `G`, with counts.

### Other

Expand Down
43 changes: 38 additions & 5 deletions src/vim/normal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { consumeCount, enterInsert, resetPending } from "./state";
import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables";
import { currentLineRange, endOfWord } from "./text";
import { currentLineRange, endOfWord, wordMotion } from "./text";
import { resolveTextObject } from "./textobject";
import type { Action, HandlerResult, KeyEvent, Operator, PromptAccess, VimState } from "./types";
import { PASS, pushN } from "./util";
Expand Down Expand Up @@ -187,13 +187,39 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
return finishUndoableChange(actions);
}

// Pending operator + e (end-of-word needs special handling)
if (state.pending.kind === "operator" && key === "e") {
// Pending operator + pure text motion
if (state.pending.kind === "operator" && (key === "e" || key === "W" || key === "B" || key === "E")) {
const op = state.pending.op;
const n = consumeCount(state);
const text = prompt.getPlainText();
const offset = prompt.getCursorOffset();
const target = endOfWord(prompt.getPlainText(), offset, n);
return applyOperatorRange(state, op, prompt.getPlainText(), offset, target);
if (key === "e") {
const target = endOfWord(text, offset, n);
return applyOperatorRange(state, op, text, offset, target);
}

if (key === "W") {
if (offset >= text.length) {
resetPending(state);
return { consume: true, actions: [] };
}
const target = wordMotion(text, offset, key, n);
return applyOperatorRange(state, op, text, offset, target - 1);
}

if (key === "B") {
if (offset === 0) {
resetPending(state);
return { consume: true, actions: [] };
}
const target = wordMotion(text, offset, key, n);
return applyOperatorRange(state, op, text, target, offset - 1);
}

if (key === "E") {
const target = wordMotion(text, offset, key, n);
return applyOperatorRange(state, op, text, Math.min(offset, target), target);
}
}

// Pending operator + motion
Expand Down Expand Up @@ -252,6 +278,13 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
return { consume: true, actions };
}

if (key === "W" || key === "B" || key === "E") {
const n = consumeCount(state);
const target = wordMotion(prompt.getPlainText(), prompt.getCursorOffset(), key, n);
actions.push({ type: "cursorTo", offset: target });
return { consume: true, actions };
}

// Standalone motions
if (key in MOTIONS) {
const n = consumeCount(state);
Expand Down
27 changes: 27 additions & 0 deletions src/vim/text.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
import type { Range } from "./types";

export function wordMotion(text: string, offset: number, motion: "W" | "B" | "E", count = 1): number {
const len = text.length;
if (len === 0) return 0;
let pos = offset;
for (let step = 0; step < count; step++) {
switch (motion) {
case "W":
while (pos < len && !isWhitespace(text[pos])) pos++;
while (pos < len && isWhitespace(text[pos])) pos++;
break;
case "B":
if (pos > 0) pos--;
while (pos > 0 && isWhitespace(text[pos])) pos--;
while (pos > 0 && !isWhitespace(text[pos - 1])) pos--;
break;
case "E":
if (pos < len - 1 && !isWhitespace(text[pos])) pos++;
while (pos < len && isWhitespace(text[pos])) pos++;
if (pos >= len) return len - 1;
while (pos + 1 < len && !isWhitespace(text[pos + 1])) pos++;
break;
// `endOfWord` could be integrated as case "e" here
}
}
return pos;
}

export function endOfWord(text: string, offset: number, count = 1): number {
const len = text.length;
if (len === 0) return 0;
Expand Down
10 changes: 9 additions & 1 deletion src/vim/visual.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { consumeCount, enterInsert, enterNormal, exitVisual } from "./state";
import { SELECT_MOTIONS } from "./tables";
import { endOfWord } from "./text";
import { endOfWord, wordMotion } from "./text";
import { resolveTextObject } from "./textobject";
import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types";
import { PASS, pushN } from "./util";
Expand Down Expand Up @@ -81,6 +81,14 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom
return { consume: true, actions };
}

if (key === "W" || key === "B" || key === "E") {
const n = consumeCount(state);
const target = wordMotion(prompt.getPlainText(), prompt.getCursorOffset(), key, 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));
Expand Down
138 changes: 138 additions & 0 deletions test/vim/normal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,144 @@ describe("handleNormalKey — e motion", () => {
});
});

// ── handleNormalKey — W motion ─────────────────────────────

describe("handleNormalKey — W motion", () => {
const wPrompt: PromptAccess = {
getLine: (n) => ["hello.world next.word", "second line"][n] ?? "",
getLineCount: () => 2,
getCursorLine: () => 0,
getCursorOffset: () => 0,
getPlainText: () => "hello.world next.word\nsecond line",
};

it("W returns cursorTo at start of next WORD", () => {
const r = handleNormalKey(state, "W", ev("W"), wPrompt);
expect(r.consume).toBe(true);
expect(cursorTos(r.actions)).toEqual([12]);
});

it("2W returns cursorTo at start of second next WORD", () => {
handleNormalKey(state, "2", ev("2"), wPrompt);
const r = handleNormalKey(state, "W", ev("W"), wPrompt);
expect(cursorTos(r.actions)).toEqual([22]);
});

it("dW deletes from cursor to start of next WORD", () => {
handleNormalKey(state, "d", ev("d"), wPrompt);
const r = handleNormalKey(state, "W", ev("W"), wPrompt);
expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 11 }]);
expect(state.mode).toBe("normal");
expect(saveUndoSnapshots(r.actions)).toHaveLength(1);
});

it("cW deletes from cursor to start of next WORD and enters insert", () => {
handleNormalKey(state, "c", ev("c"), wPrompt);
const r = handleNormalKey(state, "W", ev("W"), wPrompt);
expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 11 }]);
expect(state.mode).toBe("insert");
});

it("yW yanks from cursor to start of next WORD", () => {
handleNormalKey(state, "y", ev("y"), wPrompt);
const r = handleNormalKey(state, "W", ev("W"), wPrompt);
expect(state.yankRegister).toBe("hello.world ");
expect(r.actions.some((a) => a.type === "yank" && a.text === "hello.world ")).toBe(true);
});
});

// ── handleNormalKey — B motion ─────────────────────────────

describe("handleNormalKey — B motion", () => {
const bPrompt: PromptAccess = {
getLine: (n) => ["hello.world next.word", "second line"][n] ?? "",
getLineCount: () => 2,
getCursorLine: () => 0,
getCursorOffset: () => 16,
getPlainText: () => "hello.world next.word\nsecond line",
};

it("B returns cursorTo at start of current WORD", () => {
const r = handleNormalKey(state, "B", ev("B"), bPrompt);
expect(r.consume).toBe(true);
expect(cursorTos(r.actions)).toEqual([12]);
});

it("2B returns cursorTo at start of previous WORD", () => {
handleNormalKey(state, "2", ev("2"), bPrompt);
const r = handleNormalKey(state, "B", ev("B"), bPrompt);
expect(cursorTos(r.actions)).toEqual([0]);
});

it("dB deletes from start of WORD to cursor", () => {
handleNormalKey(state, "d", ev("d"), bPrompt);
const r = handleNormalKey(state, "B", ev("B"), bPrompt);
expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 15 }]);
expect(state.mode).toBe("normal");
expect(saveUndoSnapshots(r.actions)).toHaveLength(1);
});

it("cB deletes from start of WORD to cursor and enters insert", () => {
handleNormalKey(state, "c", ev("c"), bPrompt);
const r = handleNormalKey(state, "B", ev("B"), bPrompt);
expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 15 }]);
expect(state.mode).toBe("insert");
});

it("yB yanks from start of WORD to cursor", () => {
handleNormalKey(state, "y", ev("y"), bPrompt);
const r = handleNormalKey(state, "B", ev("B"), bPrompt);
expect(state.yankRegister).toBe("next");
expect(r.actions.some((a) => a.type === "yank" && a.text === "next")).toBe(true);
});
});

// ── handleNormalKey — E motion ─────────────────────────────

describe("handleNormalKey — E motion", () => {
const ePrompt: PromptAccess = {
getLine: (n) => ["hello.world next.word", "second line"][n] ?? "",
getLineCount: () => 2,
getCursorLine: () => 0,
getCursorOffset: () => 0,
getPlainText: () => "hello.world next.word\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([10]);
});

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([20]);
});

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: 10 }]);
expect(state.mode).toBe("normal");
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: 10 }]);
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.world");
expect(r.actions.some((a) => a.type === "yank" && a.text === "hello.world")).toBe(true);
});
});

// ── handleNormalKey — text objects ─────────────────────────

describe("handleNormalKey — text objects", () => {
Expand Down
Loading