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
25 changes: 18 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,27 +60,29 @@ src/
index.ts (408 lines) Plugin entry: intercept registration, action application
vim/ Pure vim engine (thin barrel re-exports the public surface):
index.ts (7 lines) Barrel — public surface only. No export *, no internals.
types.ts (50 lines) Action union, VimState, Mode, Operator, KeyEvent, HandlerResult, PromptAccess
text.ts (36 lines) Pure string algorithms: isWhitespace, charKind, endOfWord, currentLineRange
types.ts (57 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess
text.ts (77 lines) Pure string algorithms: isWhitespace, charKind, endOfWord, currentLineRange, wordRange
tables.ts (35 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal)
textobject.ts (16 lines) resolveTextObject — object char → inclusive Range seam (iw/aw; PR2 adds pairs)
util.ts (19 lines) State-agnostic primitives: translateKey, PASS, pushN
state.ts (76 lines) VimState lifecycle + transitions
insert.ts (32 lines) handleInsertKey
normal.ts (340 lines) handleNormalKey (+ file-local finishUndoableChange, isInputEmpty)
visual.ts (78 lines) handleVisualKey
normal.ts (378 lines) handleNormalKey (+ file-local finishUndoableChange, isInputEmpty)
visual.ts (104 lines) handleVisualKey
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/
support.ts (33 lines) Shared assertion helpers + ev()
fixtures.ts (17 lines) Prompt fixtures: mockPrompt, emptyPrompt
vim/ Per-module engine tests mirroring src/vim/:
text.test.ts (136) endOfWord + charKind/isWhitespace/currentLineRange units
text.test.ts (216) endOfWord, charKind/isWhitespace/currentLineRange, wordRange units
state.test.ts (70) createVimState, toggleVimMode
util.test.ts (31) translateKey
insert.test.ts (92) handleInsertKey
normal.test.ts (655) handleNormalKey branches
visual.test.ts (195) handleVisualKey branches
normal.test.ts (762) handleNormalKey branches
visual.test.ts (254) handleVisualKey branches
textobject.test.ts (23) resolveTextObject dispatch seam
integration.test.ts (418) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync
leader.test.ts (125 lines) Unit tests for leader key matching functions
```
Expand Down Expand Up @@ -135,6 +137,15 @@ To add a new motion that works with operators:
2. Add the destructive version to `DELETE_MOTION`: `{ "yourkey": "input.delete.whatever" }`
3. If the motion needs special handling with operators (like j/k which delete multiple lines), add an explicit branch in the `state.pending.kind === "operator" && key in MOTIONS` section.

### Adding a text object

Text objects (`iw`/`aw`, and the quote/bracket pairs coming next) route through one seam, so the handlers never change:

1. Add the pure range algorithm to `src/vim/text.ts` — e.g. `wordRange`, and for pairs a `pairRange`. It takes `(text, offset, around)` and returns an inclusive `Range` or `null` when there's nothing to select.
2. Add a `case` to `resolveTextObject` in `src/vim/textobject.ts` mapping the object char to that algorithm. This is the only dispatch point — the "any quote" (`q`) and "any bracket" (`b`) aliases compose over the per-delimiter cases here.
3. No handler change needed. `normal.ts` (operator + `i`/`a` → textobject pending → `deleteRange`/`yank`/insert) and `visual.ts` (`i`/`a` → textobject pending → `selectRange`) already send every object char through `resolveTextObject`.
4. Test the range algorithm in `test/vim/text.test.ts` and the dispatch in `test/vim/textobject.test.ts`; add handler branches in `normal.test.ts`/`visual.test.ts` only if the object needs new handling.

### Known limitations

- **`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`).
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version

## [Unreleased]

### Added

- Word text objects `iw` and `aw`, for use with the `d`, `c`, and `y` operators and in visual mode: `diw`, `ciw`, `daw`, `viw`, and so on ([#57](https://github.com/oribarilan/vimcode/issues/57)).

### Fixed

- After an operator, `i`/`a` now start a text object instead of switching to insert mode, so `di` and `ci` wait for the object key ([#57](https://github.com/oribarilan/vimcode/issues/57)).

## [0.17.1] — 2026-09-02

### Fixed
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,18 @@ When the input is empty, `j`/`k` scroll through prompt history instead of moving

Counts work on both operator and motion: `2dd` deletes 2 lines, `d3w` deletes 3 words.

### Text objects

`iw` (inner word) and `aw` (a word) combine with `d`, `c`, `y` and work in visual mode:

| Combo | Action |
|-------|--------|
| `diw` `ciw` `yiw` | Operate on the word under the cursor |
| `daw` `caw` `yaw` | Same, plus the word's trailing whitespace |
| `viw` `vaw` | Select the word (inner / around) |

`iw` covers the run under the cursor — word, punctuation, or whitespace. `aw` also takes the trailing whitespace, or the leading whitespace when there's none. Quote and bracket objects (`di"`, `ci(`) are coming next.

### Insert entries

| Key | Action |
Expand Down Expand Up @@ -185,7 +197,7 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b
## Known gaps

- `Ctrl+v` - block visual mode is not supported
- `ciw`, `di"`, etc. (text objects) - not yet implemented
- `di"`, `ci(`, etc. (quote and bracket text objects) - not yet implemented; word objects `iw`/`aw` now work
- 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)).

Configurable key bindings are next once the core vim coverage stabilizes.
Expand Down
63 changes: 47 additions & 16 deletions src/vim/normal.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { consumeCount, enterInsert, resetPending } from "./state";
import { DELETE_MOTION, MOTIONS, SELECT_MOTIONS } from "./tables";
import { currentLineRange, endOfWord } from "./text";
import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types";
import { resolveTextObject } from "./textobject";
import type { Action, HandlerResult, KeyEvent, Operator, PromptAccess, VimState } from "./types";
import { PASS, pushN } from "./util";

export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prompt: PromptAccess): HandlerResult {
Expand Down Expand Up @@ -57,6 +58,19 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom

if (ev.name === "tab") return PASS;

// Pending text object: the object char after d/c/y + i/a (diw, caw, ...).
// Resolves to an inclusive range and applies the operator. Must run before
// the object char is interpreted as a motion (e.g. w).
if (state.pending.kind === "textobject") {
const { op, around } = state.pending;
const range = resolveTextObject(prompt.getPlainText(), prompt.getCursorOffset(), key, around);
if (!range) {
resetPending(state);
return { consume: true, actions: [] };
}
return applyOperatorRange(state, op, prompt.getPlainText(), range.start, range.end);
}

// Everything below is consumed
const actions: Action[] = [];

Expand Down Expand Up @@ -153,6 +167,14 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
return { consume: true, actions };
}

// Operator + i/a begins a text object (diw, caw, ...). Must precede the
// standalone i/a insert entries below — otherwise `di` falls through and
// enters insert instead of waiting for the object char (#57).
if (state.pending.kind === "operator" && (key === "i" || key === "a")) {
state.pending = { kind: "textobject", op: state.pending.op, around: key === "a" };
return { consume: true, actions };
}

if (key === "D") {
actions.push({ type: "cmd", cmd: "input.delete.to.line.end" });
resetPending(state);
Expand All @@ -171,17 +193,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
const n = consumeCount(state);
const offset = prompt.getCursorOffset();
const target = endOfWord(prompt.getPlainText(), offset, n);
if (op === "y") {
const text = prompt.getPlainText().slice(offset, target + 1);
state.yankRegister = text;
actions.push({ type: "yank", text });
resetPending(state);
return { consume: true, actions };
}
actions.push({ type: "deleteRange", start: offset, end: target });
if (op === "c") enterInsert(state, actions);
else resetPending(state);
return finishUndoableChange(actions);
return applyOperatorRange(state, op, prompt.getPlainText(), offset, target);
}

// Pending operator + motion
Expand Down Expand Up @@ -216,10 +228,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
consumeCount(state);
const offset = prompt.getCursorOffset();
const text = prompt.getPlainText();
actions.push({ type: "deleteRange", start: offset, end: Math.max(0, text.length - 1) });
if (op === "c") enterInsert(state, actions);
else resetPending(state);
return finishUndoableChange(actions);
return applyOperatorRange(state, op, text, offset, Math.max(0, text.length - 1));
}

const deleteCmd = DELETE_MOTION[key];
Expand Down Expand Up @@ -342,6 +351,28 @@ function finishUndoableChange(actions: Action[]): HandlerResult {
return { consume: true, actions: [{ type: "saveUndoSnapshot" }, ...actions] };
}

// Apply a d/c/y operator to an inclusive [start, end] buffer range: yank
// copies the slice; delete/change remove it, with change entering insert.
// Shared by operator+e, operator+G, and the text-object dispatch.
function applyOperatorRange(
state: VimState,
op: Operator | undefined,
text: string,
start: number,
end: number,
): HandlerResult {
if (op === "y") {
const yanked = text.slice(start, end + 1);
state.yankRegister = yanked;
resetPending(state);
return { consume: true, actions: [{ type: "yank", text: yanked }] };
}
const actions: Action[] = [{ type: "deleteRange", start, end }];
if (op === "c") enterInsert(state, actions);
else resetPending(state);
return finishUndoableChange(actions);
}

function isInputEmpty(prompt: PromptAccess): boolean {
return prompt.getLineCount() === 1 && prompt.getLine(0) === "";
}
47 changes: 47 additions & 0 deletions src/vim/text.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Range } from "./types";

export function endOfWord(text: string, offset: number, count = 1): number {
const len = text.length;
if (len === 0) return 0;
Expand Down Expand Up @@ -27,10 +29,55 @@ export function charKind(ch: string): "word" | "punct" | "space" {
return "punct";
}

// A word/whitespace run never spans a line break. \r and \n both count so
// runs stay within a line on Windows (CRLF) as well as Unix.
function isLineBreak(ch: string): boolean {
return ch === "\n" || ch === "\r";
}

export 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 };
}

// Inclusive [start, end] offsets for the `iw`/`aw` text object under the
// cursor. A "word" is a run of one charKind (word/punct/space); runs never
// cross a newline. `around` extends past a word/punct run to its trailing
// whitespace (or leading, when there is none), and past a whitespace run to
// the following word. Returns null when there is nothing to select (empty
// text, or the cursor sits on a newline).
export function wordRange(text: string, offset: number, around: boolean): Range | null {
const len = text.length;
if (len === 0) return null;
const pos = Math.min(Math.max(offset, 0), len - 1);
if (isLineBreak(text[pos])) return null;

const kind = charKind(text[pos]);
let start = pos;
while (start > 0 && !isLineBreak(text[start - 1]) && charKind(text[start - 1]) === kind) start--;
let end = pos;
while (end < len - 1 && !isLineBreak(text[end + 1]) && charKind(text[end + 1]) === kind) end++;

if (!around) return { start, end };

if (kind === "space") {
if (end < len - 1 && !isLineBreak(text[end + 1])) {
const nextKind = charKind(text[end + 1]);
let te = end + 1;
while (te < len - 1 && !isLineBreak(text[te + 1]) && charKind(text[te + 1]) === nextKind) te++;
return { start, end: te };
}
return { start, end };
}

let trailing = end;
while (trailing < len - 1 && !isLineBreak(text[trailing + 1]) && charKind(text[trailing + 1]) === "space") trailing++;
if (trailing > end) return { start, end: trailing };

let leading = start;
while (leading > 0 && !isLineBreak(text[leading - 1]) && charKind(text[leading - 1]) === "space") leading--;
return { start: leading, end };
}
16 changes: 16 additions & 0 deletions src/vim/textobject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { wordRange } from "./text";
import type { Range } from "./types";

// The single dispatch seam for text objects. Maps an object char (the key
// after `i`/`a`) to its inclusive [start, end] range under the cursor, or
// null when the char is not a text object or there is nothing to select.
// PR2 extends this with quote and bracket pairs (" ' ` ( ) { } [ ] < > q b)
// without touching the normal/visual handlers.
export function resolveTextObject(text: string, offset: number, objectChar: string, around: boolean): Range | null {
switch (objectChar) {
case "w":
return wordRange(text, offset, around);
default:
return null;
}
}
9 changes: 8 additions & 1 deletion src/vim/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
export type Mode = "normal" | "insert" | "visual" | "(insert)";
export type Operator = "d" | "c" | "y";

export type Pending = { kind: "none" } | { kind: "operator"; op: Operator } | { kind: "goto" } | { kind: "replace" };
export type Range = { start: number; end: number };

export type Pending =
| { kind: "none" }
| { kind: "operator"; op: Operator }
| { kind: "goto" }
| { kind: "replace" }
| { kind: "textobject"; op?: Operator; around: boolean };

export type Action =
| { type: "cmd"; cmd: string }
Expand Down
22 changes: 22 additions & 0 deletions src/vim/visual.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { consumeCount, enterInsert, enterNormal, exitVisual } from "./state";
import { SELECT_MOTIONS } from "./tables";
import { endOfWord } from "./text";
import { resolveTextObject } from "./textobject";
import type { Action, HandlerResult, KeyEvent, PromptAccess, VimState } from "./types";
import { PASS, pushN } from "./util";

Expand All @@ -25,6 +26,21 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom
// Unknown g-combo or escape — fall through to normal visual handling
}

// Pending text object: object char after i/a (viw, vaw, ...). Selects the
// resolved range. Must run before the object char is treated as a selection
// motion (e.g. w, b).
if (state.pending.kind === "textobject") {
const around = state.pending.around;
state.pending = { kind: "none" };
const range = resolveTextObject(prompt.getPlainText(), prompt.getCursorOffset(), key, around);
if (range) {
state.visualAnchor = range.start;
actions.push({ type: "selectRange", start: range.start, end: range.end });
actions.push({ type: "cursorTo", offset: range.end });
}
return { consume: true, actions };
}

// Count accumulation
if (/[1-9]/.test(key) || (key === "0" && state.count > 0)) {
state.count = state.count * 10 + parseInt(key, 10);
Expand Down Expand Up @@ -71,6 +87,12 @@ export function handleVisualKey(state: VimState, key: string, ev: KeyEvent, prom
return { consume: true, actions };
}

// i/a begin a text object (viw, vaw, ...); wait for the object char.
if (key === "i" || key === "a") {
state.pending = { kind: "textobject", around: key === "a" };
return { consume: true, actions };
}

// g prefix — wait for second keypress
if (key === "g") {
state.pending = { kind: "goto" };
Expand Down
Loading
Loading