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
5 changes: 2 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation,
```
src/
index.ts (148 lines) Plugin entry: intercept registration, action application
vim.ts (517 lines) Pure vim engine: state, handlers, command tables, types
vim.ts (539 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 (791 lines) Characterization tests for all key handling branches
vim.test.ts (847 lines) Characterization tests for all key handling branches
```

**Data flow:**
Expand Down Expand Up @@ -111,7 +111,6 @@ To add a new motion that works with operators:

### Known limitations

- **`g` fires immediately as `input.buffer.home`** — should wait for a second `g` (needs sequence state). Single `g` = go to top, which is wrong for vim.
- **`setTimeout` dispatch** — commands are deferred to avoid re-entrancy. Multi-command sequences (like `O` = home + newline + up) rely on ordered setTimeout execution, which works in practice but isn't guaranteed by spec. Many of these can now be replaced with direct widget manipulation (e.g., setting `cursorOffset`, calling `insertText`).

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

1. ~~**Replace `lineTracker` with direct cursor reads.**~~ Done.

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`.
2. ~~**Fix `gg` requiring two keypresses.**~~ Done.

3. ~~**Fix `e` behaving identically to `w`.**~~ Done.

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

- `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.
- `g` waits for a second keypress instead of jumping to buffer start on its own. `gg` now works as a proper two-key command.

## [0.9.0] — 2026-05-29

Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ Types match commit prefixes: `feat`, `fix`, `refactor`, `chore`, `test`, `docs`.
### Workflow

1. Create a branch: `git checkout -b feat/your-feature`
2. Make changes, run `just check` locally.
2. Make changes, run `just check` locally. It must pass with zero errors and zero warnings.
3. Push and open a PR against `main`.
4. CI runs `just check` (lint + tests). It must pass before merge.
4. CI runs `just check`. Warnings are treated as errors — the PR will be blocked until the check is fully clean.
5. PRs are squash-merged. The PR title becomes the commit message on `main`.

## Commit messages
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b

- `V`, `Ctrl+v` - only character-wise visual mode (`v`) is supported, no line-wise or block
- `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)
- 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.

Expand Down
4 changes: 2 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ install:
test:
bun test

# Check formatting and lint
# Check formatting and lint (warnings are errors)
lint:
bunx biome ci .
bunx biome ci --error-on-warnings .

# Auto-fix formatting and lint
lint-fix:
Expand Down
36 changes: 29 additions & 7 deletions src/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export type HandlerResult = {
export type VimState = {
mode: Mode;
pendingOp: Operator;
pendingChar: "r" | null;
pendingChar: "r" | "g" | null;
count: number;
yankRegister: string;
};
Expand Down Expand Up @@ -169,6 +169,19 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
return { consume: true, 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
Expand Down Expand Up @@ -356,10 +369,9 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
return { consume: true, actions };
}

// gg (buffer home)
// g prefix — wait for second keypress
if (key === "g") {
actions.push({ type: "cmd", cmd: "input.buffer.home" });
resetPending(state);
state.pendingChar = "g";
return { consume: true, actions };
}

Expand Down Expand Up @@ -429,6 +441,17 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent): Han

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);
Expand Down Expand Up @@ -466,10 +489,9 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent): Han
return { consume: true, actions };
}

// g = select to buffer home
// g prefix — wait for second keypress
if (key === "g") {
actions.push({ type: "cmd", cmd: "input.select.buffer.home" });
state.count = 0;
state.pendingChar = "g";
return { consume: true, actions };
}

Expand Down
66 changes: 61 additions & 5 deletions test/vim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,47 @@ describe("handleNormalKey — motions", () => {
expect(state.count).toBe(10);
});

it("g dispatches input.buffer.home", () => {
it("g sets pendingChar, no actions", () => {
const r = handleNormalKey(state, "g", ev("g"), mockPrompt);
expect(cmds(r.actions)).toEqual(["input.buffer.home"]);
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);
});
});

Expand Down Expand Up @@ -559,7 +597,7 @@ describe("handleNormalKey — yy uses cursor position", () => {
};
handleNormalKey(state, "2", ev("2"), prompt);
handleNormalKey(state, "y", ev("y"), prompt);
const r = handleNormalKey(state, "y", ev("y"), prompt);
handleNormalKey(state, "y", ev("y"), prompt);
expect(state.yankRegister).toBe("second\nthird\n");
});

Expand All @@ -570,7 +608,7 @@ describe("handleNormalKey — yy uses cursor position", () => {
getCursorLine: () => 2,
};
handleNormalKey(state, "y", ev("y"), prompt);
const r = handleNormalKey(state, "y", ev("y"), prompt);
handleNormalKey(state, "y", ev("y"), prompt);
expect(state.yankRegister).toBe("third\n");
});
});
Expand Down Expand Up @@ -672,9 +710,27 @@ describe("handleVisualKey — motions", () => {
expect(cmds(r.actions)).toEqual(["input.select.buffer.end"]);
});

it("g dispatches input.select.buffer.home", () => {
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");
});
});

Expand Down
Loading