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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)).

Expand Down
22 changes: 22 additions & 0 deletions src/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions test/vim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 Down Expand Up @@ -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 ──────────────────────────────
Expand Down
Loading