Skip to content
Merged
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
9 changes: 6 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation,

```
src/
index.ts (136 lines) Plugin entry: intercept registration, action application
vim.ts (459 lines) Pure vim engine: state, handlers, command tables, types
index.ts (148 lines) Plugin entry: intercept registration, action application
vim.ts (517 lines) Pure vim engine: state, handlers, command tables, types
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 (676 lines) Characterization tests for all key handling branches
vim.test.ts (791 lines) Characterization tests for all key handling branches
```

**Data flow:**
Expand All @@ -76,8 +76,11 @@ Handlers in `vim.ts` are pure — they take state + key + event, mutate state, r
- `{ type: "mode", mode: Mode }` — updates the SolidJS signal for the indicator
- `{ type: "toast", message: string }` — shows a notification
- `{ type: "yank", text: string }` — writes text to system clipboard via `writeClipboard()`
- `{ type: "insertText", text: string }` — inserts text at cursor via `editor.insertText()`
- `{ type: "yankSelection" }` — reads selected text from the focused editor, stores in yank register and clipboard
- `{ type: "clearSelection" }` — clears the textarea's selection via `editorView.resetSelection()`
- `{ type: "cursorTo", offset: number }` — sets `editor.cursorOffset` directly
- `{ type: "selectRange", start: number, end: number }` — calls `editor.setSelectionInclusive(start, end)`

### Adding a keybinding

Expand Down
2 changes: 1 addition & 1 deletion BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Ordered by priority within each category.

2. **Fix `gg` requiring two keypresses.** Single `g` fires `input.buffer.home` immediately. Real vim waits for a second `g`. Add pending-key state for `g` with a timeout or second-key check, similar to how `r` already works with `pendingChar`.

3. **Fix `e` behaving identically to `w`.** `editorView.getNextWordBoundary()` is available. Use it to implement proper end-of-word motion that stops at the last character of the current word rather than the first character of the next.
3. ~~**Fix `e` behaving identically to `w`.**~~ Done.

4. **Eliminate `setTimeout` command dispatch for operations that can use direct manipulation.** Multi-command sequences like `O` (home + newline + up) depend on setTimeout ordering. Replace with direct buffer manipulation (`cursorOffset` writes, `insertText()`) where possible. Keep setTimeout only for commands that genuinely need `dispatchCommand` (submit, undo/redo, history navigation).

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
### Fixed

- `yy` reads the cursor position directly from the editor widget instead of a line counter. The old counter drifted on clicks, arrow keys, and word motions, so `yy` would yank the wrong line.
- `e` moves to end of word instead of behaving like `w`. `de`, `ce`, and `ye` operate on the correct range too.

## [0.9.0] — 2026-05-29

Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The plugin checks GitHub for new versions once per day on startup. No other netw
| Key | Action |
|-----|--------|
| `h` `j` `k` `l` | Left, down, up, right |
| `w` `b` `e` | Word forward, backward, forward |
| `w` `b` `e` | Word forward, backward, end of word |
| `0` `^` | Line start |
| `$` | Line end |
| `G` | Buffer end |
Expand Down Expand Up @@ -158,7 +158,6 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b
- `ciw`, `di"`, etc. (text objects) - not yet implemented
- `gg` - single `g` goes to buffer start immediately, doesn't wait for a second keypress
- `dG`, `cG` - delete/change to buffer end not yet implemented (`yG` works)
- `e` behaves the same as `w` - the host doesn't expose a separate "end of word" command
- No persistent mode indicator - the toast fades after about a second. Cursor shape is the persistent signal, but a status bar indicator would need the host's SolidJS runtime, which external plugins can't access.

Configurable key bindings are next once the core vim coverage stabilizes.
Expand Down
12 changes: 12 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const plugin: TuiPluginModule = {
getLine: (n: number) => getInputText().split("\n")[n] ?? "",
getLineCount: () => getInputText().split("\n").length,
getCursorLine: () => api.renderer?.currentFocusedEditor?.visualCursor?.logicalRow ?? 0,
getCursorOffset: () => api.renderer?.currentFocusedEditor?.cursorOffset ?? 0,
getPlainText: () => getInputText(),
};

// api.prompt doesn't exist on the TUI plugin API. The actual text lives
Expand Down Expand Up @@ -59,6 +61,16 @@ const plugin: TuiPluginModule = {
case "clearSelection":
api.renderer?.currentFocusedEditor?.editorView?.resetSelection?.();
break;
case "cursorTo": {
const editor = api.renderer?.currentFocusedEditor;
if (editor) editor.cursorOffset = action.offset;
break;
}
case "selectRange": {
const editor = api.renderer?.currentFocusedEditor;
editor?.setSelectionInclusive?.(action.start, action.end);
break;
}
}
}
}
Expand Down
64 changes: 61 additions & 3 deletions src/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ export type Action =
| { type: "yank"; text: string }
| { type: "insertText"; text: string }
| { type: "yankSelection" }
| { type: "clearSelection" };
| { type: "clearSelection" }
| { type: "cursorTo"; offset: number }
| { type: "selectRange"; start: number; end: number };

export type HandlerResult = {
consume: boolean;
Expand Down Expand Up @@ -36,6 +38,8 @@ export type PromptAccess = {
getLine: (n: number) => string;
getLineCount: () => number;
getCursorLine: () => number;
getCursorOffset: () => number;
getPlainText: () => string;
};

export const MOTIONS: Record<string, string> = {
Expand All @@ -45,7 +49,6 @@ export const MOTIONS: Record<string, string> = {
k: "input.move.up",
w: "input.word.forward",
b: "input.word.backward",
e: "input.word.forward",
"0": "input.line.home",
"^": "input.line.home",
$: "input.line.end",
Expand All @@ -69,7 +72,6 @@ export const SELECT_MOTIONS: Record<string, string> = {
const DELETE_MOTION: Record<string, string> = {
w: "input.delete.word.forward",
b: "input.delete.word.backward",
e: "input.delete.word.forward",
$: "input.delete.to.line.end",
"0": "input.delete.to.line.start",
"^": "input.delete.to.line.start",
Expand All @@ -84,6 +86,35 @@ export function createVimState(): VimState {
return { mode: "insert", pendingOp: null, pendingChar: null, count: 0, yankRegister: "" };
}

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) {
Expand Down Expand Up @@ -246,6 +277,25 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
return { consume: true, 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);
} else {
actions.push({ type: "selectRange", start: offset, end: target });
actions.push({ type: "cmd", cmd: "input.backspace" });
if (state.pendingOp === "c") enterInsert(state, actions);
else resetPending(state);
}
return { consume: true, actions };
}

// Pending operator + motion
if (state.pendingOp && key in MOTIONS) {
const n = consumeCount(state);
Expand Down Expand Up @@ -286,6 +336,14 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
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);
Expand Down
115 changes: 115 additions & 0 deletions test/vim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import {
type Action,
createVimState,
endOfWord,
handleInsertKey,
handleNormalKey,
handleVisualKey,
Expand All @@ -14,6 +15,16 @@
return actions.filter((a): a is Extract<Action, { type: "cmd" }> => a.type === "cmd").map((a) => a.cmd);
}

function cursorTos(actions: Action[]): number[] {
return actions.filter((a): a is Extract<Action, { type: "cursorTo" }> => a.type === "cursorTo").map((a) => a.offset);
}

function selectRanges(actions: Action[]): Array<{ start: number; end: number }> {
return actions
.filter((a): a is Extract<Action, { type: "selectRange" }> => 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,
Expand All @@ -26,12 +37,16 @@
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;
Expand All @@ -41,6 +56,59 @@
state.mode = "normal";
});

// ── 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", () => {
Expand Down Expand Up @@ -158,6 +226,53 @@
});
});

// ── 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(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]);
expect(cmds(r.actions)).toContain("input.backspace");
expect(state.mode).toBe("normal");
});

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(selectRanges(r.actions)).toEqual([{ start: 0, end: 4 }]);
expect(cmds(r.actions)).toContain("input.backspace");
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", () => {
Expand Down Expand Up @@ -444,7 +559,7 @@
};
handleNormalKey(state, "2", ev("2"), prompt);
handleNormalKey(state, "y", ev("y"), prompt);
const r = handleNormalKey(state, "y", ev("y"), prompt);

Check warning on line 562 in test/vim.test.ts

View workflow job for this annotation

GitHub Actions / check

lint/correctness/noUnusedVariables

This variable r is unused.
expect(state.yankRegister).toBe("second\nthird\n");
});

Expand All @@ -455,7 +570,7 @@
getCursorLine: () => 2,
};
handleNormalKey(state, "y", ev("y"), prompt);
const r = handleNormalKey(state, "y", ev("y"), prompt);

Check warning on line 573 in test/vim.test.ts

View workflow job for this annotation

GitHub Actions / check

lint/correctness/noUnusedVariables

This variable r is unused.
expect(state.yankRegister).toBe("third\n");
});
});
Expand Down
Loading