From 621e3292298159c8afd0d92ff58960d5b97b6646 Mon Sep 17 00:00:00 2001 From: Paul Luis Roehl Date: Sun, 18 Oct 2026 11:13:25 +0200 Subject: [PATCH 1/3] feat: add B W and E WORD motions --- src/vim/normal.ts | 43 ++++++++++++++++++++++++++++++++++++++----- src/vim/text.ts | 27 +++++++++++++++++++++++++++ src/vim/visual.ts | 10 +++++++++- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/vim/normal.ts b/src/vim/normal.ts index 8d21c48..5c4249c 100644 --- a/src/vim/normal.ts +++ b/src/vim/normal.ts @@ -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"; @@ -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 @@ -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); diff --git a/src/vim/text.ts b/src/vim/text.ts index 56ee4eb..3db06bf 100644 --- a/src/vim/text.ts +++ b/src/vim/text.ts @@ -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; diff --git a/src/vim/visual.ts b/src/vim/visual.ts index 4ab479d..3b33e4a 100644 --- a/src/vim/visual.ts +++ b/src/vim/visual.ts @@ -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"; @@ -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)); From 4bae59457874ff397034d64e904d26ca21af2a4f Mon Sep 17 00:00:00 2001 From: Paul Luis Roehl Date: Sun, 18 Oct 2026 11:13:25 +0200 Subject: [PATCH 2/3] test: cover WORD motions in existing test style --- test/vim/normal.test.ts | 138 ++++++++++++++++++++++++++++++++++ test/vim/text.test.ts | 160 ++++++++++++++++++++++++++++++++++++++++ test/vim/visual.test.ts | 94 +++++++++++++++++++++++ 3 files changed, 392 insertions(+) diff --git a/test/vim/normal.test.ts b/test/vim/normal.test.ts index d32c74b..b268284 100644 --- a/test/vim/normal.test.ts +++ b/test/vim/normal.test.ts @@ -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", () => { diff --git a/test/vim/text.test.ts b/test/vim/text.test.ts index 72b0fa9..a48bbdf 100644 --- a/test/vim/text.test.ts +++ b/test/vim/text.test.ts @@ -8,6 +8,7 @@ import { currentLineRange, isWhitespace, quoteRange, + wordMotion, wordRange, } from "../../src/vim/text"; @@ -64,6 +65,165 @@ describe("endOfWord", () => { }); }); +// ── wordMotion — W ───────────────────────────────────────── + +describe("wordMotion — W", () => { + it("from start of WORD, moves to start of next WORD", () => { + expect(wordMotion("hello world", 0, "W")).toBe(6); + }); + + it("from middle of WORD, moves to start of next WORD", () => { + expect(wordMotion("hello world", 2, "W")).toBe(6); + }); + + it("from end of WORD, moves to start of next WORD", () => { + expect(wordMotion("hello world", 4, "W")).toBe(6); + }); + + it("from whitespace, skips to start of next WORD", () => { + expect(wordMotion("hello world", 5, "W")).toBe(6); + }); + + it("does not stop at punctuation boundary", () => { + expect(wordMotion("hello.world next", 0, "W")).toBe(12); + }); + + it("from punctuation, moves to start of next WORD", () => { + expect(wordMotion("hello...world next", 5, "W")).toBe(14); + }); + + it("from end of punctuation, moves to start of next WORD", () => { + expect(wordMotion("a.b next", 1, "W")).toBe(4); + }); + + it("at end of text, stays put", () => { + expect(wordMotion("hello", 5, "W")).toBe(5); + }); + + it("handles count > 1", () => { + expect(wordMotion("one two three", 0, "W", 2)).toBe(8); + }); + + it("handles multiple whitespace", () => { + expect(wordMotion("hello world", 0, "W")).toBe(8); + expect(wordMotion("hello world", 4, "W")).toBe(8); + }); + + it("handles newlines as whitespace", () => { + expect(wordMotion("hello\nworld", 4, "W")).toBe(6); + }); + + it("clamps at end of text", () => { + expect(wordMotion("hi", 0, "W", 5)).toBe(2); + }); +}); + +// ── wordMotion — B ───────────────────────────────────────── + +describe("wordMotion — B", () => { + it("from start of WORD, moves to start of previous WORD", () => { + expect(wordMotion("hello world", 6, "B")).toBe(0); + }); + + it("from middle of WORD, moves to first char", () => { + expect(wordMotion("hello world", 8, "B")).toBe(6); + }); + + it("from end of WORD, moves to first char", () => { + expect(wordMotion("hello world", 10, "B")).toBe(6); + }); + + it("from whitespace, skips to start of previous WORD", () => { + expect(wordMotion("hello world", 5, "B")).toBe(0); + }); + + it("does not stop at punctuation boundary", () => { + expect(wordMotion("hello.world", 10, "B")).toBe(0); + }); + + it("from punctuation, moves to start of WORD", () => { + expect(wordMotion("hello...world", 5, "B")).toBe(0); + }); + + it("from end of punctuation, moves to start of WORD", () => { + expect(wordMotion("a.b", 1, "B")).toBe(0); + }); + + it("at start of text, stays put", () => { + expect(wordMotion("hello", 0, "B")).toBe(0); + }); + + it("handles count > 1", () => { + expect(wordMotion("one two three", 8, "B", 2)).toBe(0); + }); + + it("handles multiple whitespace", () => { + expect(wordMotion("hello world", 10, "B")).toBe(8); + expect(wordMotion("hello world", 8, "B")).toBe(0); + }); + + it("handles newlines as whitespace", () => { + expect(wordMotion("hello\nworld", 6, "B")).toBe(0); + }); + + it("clamps at start of text", () => { + expect(wordMotion("hi", 1, "B", 5)).toBe(0); + }); +}); + +// ── wordMotion — E ───────────────────────────────────────── + +describe("wordMotion — E", () => { + it("from start of WORD, moves to last char", () => { + expect(wordMotion("hello world", 0, "E")).toBe(4); + }); + + it("from middle of WORD, moves to last char", () => { + expect(wordMotion("hello world", 2, "E")).toBe(4); + }); + + it("from end of WORD, moves to end of next WORD", () => { + expect(wordMotion("hello world", 4, "E")).toBe(10); + }); + + it("from whitespace, skips to end of next WORD", () => { + expect(wordMotion("hello world", 5, "E")).toBe(10); + }); + + it("does not stop at punctuation boundary", () => { + expect(wordMotion("hello.world", 0, "E")).toBe(10); + }); + + it("from punctuation, moves to end of WORD", () => { + expect(wordMotion("hello...world", 5, "E")).toBe(12); + }); + + it("from end of punctuation, moves to end of WORD", () => { + expect(wordMotion("a.b", 1, "E")).toBe(2); + }); + + it("at end of text, stays put", () => { + expect(wordMotion("hello", 4, "E")).toBe(4); + }); + + it("handles count > 1", () => { + expect(wordMotion("one two three", 0, "E", 2)).toBe(6); + }); + + it("handles multiple whitespace", () => { + expect(wordMotion("hello world", 0, "E")).toBe(4); + expect(wordMotion("hello world", 4, "E")).toBe(12); + }); + + it("handles newlines as whitespace", () => { + expect(wordMotion("hello\nworld", 4, "E")).toBe(10); + }); + + it("clamps at end of text", () => { + expect(wordMotion("hi", 0, "E", 5)).toBe(1); + }); +}); + // ── isWhitespace ─────────────────────────────────────────── describe("isWhitespace", () => { diff --git a/test/vim/visual.test.ts b/test/vim/visual.test.ts index 1dd820d..4798701 100644 --- a/test/vim/visual.test.ts +++ b/test/vim/visual.test.ts @@ -13,6 +13,11 @@ beforeEach(() => { // ── handleVisualKey — motions ────────────────────────────── describe("handleVisualKey — motions", () => { + const wordPrompt: PromptAccess = { + ...mockPrompt, + getPlainText: () => "hello.world next.word last.word", + }; + beforeEach(() => { state.mode = "visual"; }); @@ -93,6 +98,95 @@ describe("handleVisualKey — motions", () => { expect(cursorTos(r2.actions)).toEqual([10]); }); + it("W selects from visual anchor to start of next WORD", () => { + state.visualAnchor = 0; + const r = handleVisualKey(state, "W", ev("W"), wordPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 12 }]); + }); + + it("2W selects from visual anchor to start of second next WORD", () => { + state.visualAnchor = 0; + handleVisualKey(state, "2", ev("2"), wordPrompt); + const r = handleVisualKey(state, "W", ev("W"), wordPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 22 }]); + }); + + it("W pressed twice extends selection to successive WORD starts", () => { + state.visualAnchor = 0; + let cursorPos = 0; + const prompt: PromptAccess = { ...wordPrompt, getCursorOffset: () => cursorPos }; + + const r1 = handleVisualKey(state, "W", ev("W"), prompt); + expect(selectRanges(r1.actions)).toEqual([{ start: 0, end: 12 }]); + expect(cursorTos(r1.actions)).toEqual([12]); + + cursorPos = cursorTos(r1.actions)[0]; + + const r2 = handleVisualKey(state, "W", ev("W"), prompt); + expect(selectRanges(r2.actions)).toEqual([{ start: 0, end: 22 }]); + expect(cursorTos(r2.actions)).toEqual([22]); + }); + + it("B selects from visual anchor to start of WORD", () => { + state.visualAnchor = 20; + const prompt: PromptAccess = { ...wordPrompt, getCursorOffset: () => 20 }; + const r = handleVisualKey(state, "B", ev("B"), prompt); + expect(selectRanges(r.actions)).toEqual([{ start: 20, end: 12 }]); + }); + + it("2B selects from visual anchor to start of previous WORD", () => { + state.visualAnchor = 20; + const prompt: PromptAccess = { ...wordPrompt, getCursorOffset: () => 20 }; + handleVisualKey(state, "2", ev("2"), prompt); + const r = handleVisualKey(state, "B", ev("B"), prompt); + expect(selectRanges(r.actions)).toEqual([{ start: 20, end: 0 }]); + }); + + it("B pressed twice extends selection to successive WORD starts", () => { + state.visualAnchor = 20; + let cursorPos = 20; + const prompt: PromptAccess = { ...wordPrompt, getCursorOffset: () => cursorPos }; + + const r1 = handleVisualKey(state, "B", ev("B"), prompt); + expect(selectRanges(r1.actions)).toEqual([{ start: 20, end: 12 }]); + expect(cursorTos(r1.actions)).toEqual([12]); + + cursorPos = cursorTos(r1.actions)[0]; + + const r2 = handleVisualKey(state, "B", ev("B"), prompt); + expect(selectRanges(r2.actions)).toEqual([{ start: 20, end: 0 }]); + expect(cursorTos(r2.actions)).toEqual([0]); + }); + + it("E selects from visual anchor to end of WORD", () => { + state.visualAnchor = 0; + const r = handleVisualKey(state, "E", ev("E"), wordPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 10 }]); + }); + + it("2E selects from visual anchor to end of 2nd WORD", () => { + state.visualAnchor = 0; + handleVisualKey(state, "2", ev("2"), wordPrompt); + const r = handleVisualKey(state, "E", ev("E"), wordPrompt); + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 20 }]); + }); + + it("E pressed twice extends selection to successive WORD ends", () => { + state.visualAnchor = 0; + let cursorPos = 0; + const prompt: PromptAccess = { ...wordPrompt, getCursorOffset: () => cursorPos }; + + const r1 = handleVisualKey(state, "E", ev("E"), prompt); + expect(selectRanges(r1.actions)).toEqual([{ start: 0, end: 10 }]); + expect(cursorTos(r1.actions)).toEqual([10]); + + cursorPos = cursorTos(r1.actions)[0]; + + const r2 = handleVisualKey(state, "E", ev("E"), prompt); + expect(selectRanges(r2.actions)).toEqual([{ start: 0, end: 20 }]); + expect(cursorTos(r2.actions)).toEqual([20]); + }); + it("g sets pending goto, no actions", () => { const r = handleVisualKey(state, "g", ev("g")); expect(r.consume).toBe(true); From 6c5f8dac00b7cb528ad86266cf538d26d9e72bad Mon Sep 17 00:00:00 2001 From: Paul Luis Roehl Date: Sun, 18 Oct 2026 11:13:25 +0200 Subject: [PATCH 3/3] chore: document WORD motions and update architecture counts --- AGENTS.md | 12 ++++++------ CHANGELOG.md | 4 ++++ README.md | 6 +++++- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 05679bd..536fe63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,14 +65,14 @@ 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) @@ -80,12 +80,12 @@ 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index ec6db6d..990cecd 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] +### 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 diff --git a/README.md b/README.md index ca8917c..08839a8 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 | @@ -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