diff --git a/AGENTS.md b/AGENTS.md index e589b3f..dcb1cdf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,13 +57,13 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (345 lines) Plugin entry: intercept registration, action application - vim.ts (608 lines) Pure vim engine: state, handlers, command tables, types + index.ts (342 lines) Plugin entry: intercept registration, action application + vim.ts (630 lines) Pure vim engine: state, handlers, command tables, types 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/ - vim.test.ts (1268 lines) Characterization tests for all key handling branches + vim.test.ts (1311 lines) Characterization tests for all key handling branches leader.test.ts (125 lines) Unit tests for leader key matching functions ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index b313b05..4be8c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version - `/vim` toggle command to disable/enable vim mode. Persisted across restarts. - Startup toast when vim is disabled so users know why keybindings aren't active. +- `V` selects the current line and enters visual mode. ### Fixed diff --git a/README.md b/README.md index 27b77fe..017ded5 100644 --- a/README.md +++ b/README.md @@ -147,13 +147,14 @@ Counts work on both operator and motion: `2dd` deletes 2 lines, `d3w` deletes 3 ### Visual mode -Press `v` in normal mode to enter character-wise visual mode. Motions extend the selection, operators act on it: +Press `v` in normal mode to enter character-wise visual mode. Press `V` to select the current line. Motions extend the selection, operators act on it: | Key | Action | |-----|--------| | `d` `x` | Delete selection | | `c` | Delete selection, enter insert mode | | `y` | Yank (copy) selection | +| `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. @@ -182,7 +183,7 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b ## Known gaps -- `V`, `Ctrl+v` - only character-wise visual mode (`v`) is supported, no line-wise or block +- `Ctrl+v` - block visual mode is not supported - `ciw`, `di"`, etc. (text objects) - not yet implemented - No persistent mode indicator - the toast fades after about a second. A slot-based indicator needs the host's JSX runtime, which doesn't resolve reliably from git-installed plugins ([#3](https://github.com/oribarilan/vimcode/issues/3)). diff --git a/src/vim.ts b/src/vim.ts index 8543dc0..125bab4 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -444,6 +444,20 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom } // Visual mode entry + if (key === "V") { + const range = currentLineRange(prompt.getPlainText(), prompt.getCursorOffset()); + state.mode = "visual"; + state.oneShotNormal = false; + resetPending(state); + return { + consume: true, + actions: [ + { type: "selectRange", start: range.start, end: range.end }, + { type: "mode", mode: "visual" }, + ], + }; + } + if (key === "v") { state.mode = "visual"; state.oneShotNormal = false; @@ -574,6 +588,14 @@ function resetPending(state: VimState) { state.count = 0; } +function currentLineRange(text: string, offset: number): { start: number; end: number } { + if (text.length === 0) return { start: 0, end: 0 }; + const safeOffset = Math.min(Math.max(offset, 0), text.length - 1); + const start = text.lastIndexOf("\n", safeOffset - 1) + 1; + const newline = text.indexOf("\n", safeOffset); + return { start, end: newline === -1 ? text.length - 1 : newline }; +} + function consumeCount(state: VimState): number { const n = state.count || 1; state.count = 0; diff --git a/test/vim.test.ts b/test/vim.test.ts index 44b1926..c85d93d 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -27,6 +27,12 @@ function deleteRanges(actions: Action[]): Array<{ start: number; end: number }> .map((a) => ({ start: a.start, end: a.end })); } +function selectRanges(actions: Action[]): Array<{ start: number; end: number }> { + return actions + .filter((a): a is Extract => 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, @@ -795,6 +801,43 @@ describe("handleNormalKey — visual mode entry", () => { expect(state.pendingOp).toBeNull(); expect(state.mode).toBe("visual"); }); + + it("V selects the current line and enters visual mode", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 1, + getCursorOffset: () => 8, + getPlainText: () => "first\nsecond\nthird", + }; + + const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); + + expect(r.consume).toBe(true); + expect(state.mode).toBe("visual"); + expect(selectRanges(r.actions)).toEqual([{ start: 6, end: 12 }]); + expect(r.actions).toContainEqual({ type: "mode", mode: "visual" }); + }); + + it("V selects the first line including its newline", () => { + const r = handleNormalKey(state, "V", ev("v", { shift: true }), mockPrompt); + + expect(selectRanges(r.actions)).toEqual([{ start: 0, end: 11 }]); + }); + + it("V selects the last line without requiring a trailing newline", () => { + const prompt: PromptAccess = { + getLine: (n) => ["first", "second", "third"][n] ?? "", + getLineCount: () => 3, + getCursorLine: () => 2, + getCursorOffset: () => 15, + getPlainText: () => "first\nsecond\nthird", + }; + + const r = handleNormalKey(state, "V", ev("v", { shift: true }), prompt); + + expect(selectRanges(r.actions)).toEqual([{ start: 13, end: 17 }]); + }); }); // ── handleVisualKey — motions ──────────────────────────────