From f3de7d5e5a6e3752c6549444626a8a07d63a7313 Mon Sep 17 00:00:00 2001 From: ori Date: Sun, 31 May 2026 16:36:04 +0300 Subject: [PATCH] fix: g waits for second keypress, gg works as two-key command g used to fire input.buffer.home on its own. Now sets pendingChar and waits. gg moves to buffer start, g+Escape or g+unknown cancels. Visual mode gg selects to buffer start. --- AGENTS.md | 5 ++-- BACKLOG.md | 2 +- CHANGELOG.md | 1 + CONTRIBUTING.md | 4 +-- README.md | 1 - justfile | 4 +-- src/vim.ts | 36 +++++++++++++++++++++----- test/vim.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++---- 8 files changed, 98 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e34302e..ea69c7e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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:** @@ -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 diff --git a/BACKLOG.md b/BACKLOG.md index d63ca40..3f69174 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index bcf4637..3a32f5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 083ccd8..2d10aba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index 0650174..a4d0b29 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/justfile b/justfile index 53e0c59..210c331 100644 --- a/justfile +++ b/justfile @@ -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: diff --git a/src/vim.ts b/src/vim.ts index c9a692d..14dea58 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -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; }; @@ -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 @@ -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 }; } @@ -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); @@ -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 }; } diff --git a/test/vim.test.ts b/test/vim.test.ts index dee09f3..d62f829 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -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); }); }); @@ -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"); }); @@ -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"); }); }); @@ -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"); }); });