Skip to content
Open
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 @@ -53,12 +53,12 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation,

```
src/
index.ts (190 lines) Plugin entry: intercept registration, action application
vim.ts (549 lines) Pure vim engine: state, handlers, command tables, types
index.ts (270 lines) Plugin entry: intercept registration, action application
vim.ts (676 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 (904 lines) Characterization tests for all key handling branches
vim.test.ts (1419 lines) Characterization tests for all key handling branches
```

**Data flow:**
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version
### Added

- `Ctrl+O` in insert mode — run one normal-mode command, then return to insert. Motions, operators, counts, and `r{char}` all work.
- `.` in normal mode repeats the last command-based change. For example, `dw..` deletes three words.
- `V` selects the current line and enters visual mode.

### Fixed

- Counted delete/change commands such as `3dw` now undo in one `u` press instead of one undo per repeated host command.
- Normal/visual-mode leader keys now pass through both the configured prefix key and the following key, so OpenCode bindings like `<leader>e`, `<leader>l`, and `<leader>n` can run even when vimcode is in normal mode.

## [0.10.0] — 2026-05-31

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,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 All @@ -146,6 +147,7 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b
| `x` | Delete character |
| `u` | Undo |
| `Ctrl+r` | Redo |
| `.` | Repeat last command-based change (`dw`, `dd`, `x`, `r{char}`, etc.) |
| `p` | Paste from yank register |
| `:` | Command palette |
| `/` | Jump to message (session timeline) |
Expand All @@ -159,8 +161,9 @@ 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
- `.` does not yet repeat range-computed changes like `de` or `dG`
- 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.

Configurable key bindings are next once the core vim coverage stabilizes.
Expand Down
94 changes: 82 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ const plugin: TuiPluginModule = {
const startMode = options?.startMode === "normal" ? "normal" : "insert";
state.mode = startMode;

// Snapshot for single-step undo of deleteRange operations.
// The host editor's undo system splits multi-line deletions into
// multiple entries, so we save/restore the buffer ourselves.
let undoSnapshot: { text: string; cursor: number } | null = null;
// Snapshots for single-step undo of vim changes.
// The host editor's undo system splits repeated commands into multiple
// entries, so we save/restore the buffer ourselves.
let undoSnapshots: Array<{ text: string; cursor: number }> = [];

const prompt = {
getLine: (n: number) => getInputText().split("\n")[n] ?? "",
Expand All @@ -38,11 +38,12 @@ const plugin: TuiPluginModule = {
}

function applyActions(actions: Action[]) {
let keepUndoSnapshotForBatch = false;
for (const action of actions) {
// Any buffer-modifying action (other than our own deleteRange/undo)
// invalidates the undo snapshot.
if (action.type === "cmd" || action.type === "insertText") {
undoSnapshot = null;
if ((action.type === "cmd" || action.type === "insertText") && !keepUndoSnapshotForBatch) {
undoSnapshots = [];
}
switch (action.type) {
case "cmd":
Expand Down Expand Up @@ -83,23 +84,29 @@ const plugin: TuiPluginModule = {
const editor = api.renderer?.currentFocusedEditor;
const eb = editor?.editBuffer;
if (eb?.deleteRange) {
undoSnapshot = { text: editor.plainText ?? "", cursor: editor.cursorOffset ?? 0 };
undoSnapshots.push({ text: editor.plainText ?? "", cursor: editor.cursorOffset ?? 0 });
const text = editor.plainText ?? "";
const [sl, sc] = offsetToLineCol(text, action.start);
const [el, ec] = offsetToLineCol(text, action.end + 1);
eb.deleteRange(sl, sc, el, ec);
}
break;
}
case "saveUndoSnapshot": {
const editor = api.renderer?.currentFocusedEditor;
if (editor) undoSnapshots.push({ text: editor.plainText ?? "", cursor: editor.cursorOffset ?? 0 });
keepUndoSnapshotForBatch = true;
break;
}
case "undo": {
const undoSnapshot = undoSnapshots.pop();
if (undoSnapshot) {
const editor = api.renderer?.currentFocusedEditor;
const eb = editor?.editBuffer;
if (eb?.setText && editor) {
eb.setText(undoSnapshot.text);
editor.cursorOffset = undoSnapshot.cursor;
}
undoSnapshot = null;
} else {
setTimeout(() => api.keymap.dispatchCommand("input.undo"), 0);
}
Expand Down Expand Up @@ -174,14 +181,15 @@ const plugin: TuiPluginModule = {
}
}

const key = translateKey(ctx.event);
const event = { ...ctx.event, leader: isLeaderKey(ctx.event, getLeaderBinding(api)) };
const key = translateKey(event);
const handlerMode = state.mode;
const result =
state.mode === "insert"
? handleInsertKey(state, key, ctx.event)
? handleInsertKey(state, key, event)
: state.mode === "visual"
? handleVisualKey(state, key, ctx.event)
: handleNormalKey(state, key, ctx.event, prompt);
? handleVisualKey(state, key, event)
: handleNormalKey(state, key, event, prompt);
if (handlerMode === "normal") finishOneShotIfComplete(state, result);
if (result.consume) ctx.consume();
applyActions(result.actions);
Expand All @@ -197,4 +205,66 @@ function offsetToLineCol(text: string, offset: number): [number, number] {
return [lines.length - 1, lines[lines.length - 1].length];
}

function getLeaderBinding(api: any): unknown {
return api.tuiConfig?.keybinds?.leader ?? api.state?.config?.keybinds?.leader ?? "ctrl+x";
}

function isLeaderKey(
ev: { name: string; ctrl?: boolean; shift?: boolean; meta?: boolean; super?: boolean },
binding: unknown,
): boolean {
if (binding === false || binding === "none") return false;
if (Array.isArray(binding)) return binding.some((entry) => isLeaderKey(ev, entry));
if (typeof binding === "object" && binding !== null) {
const value = binding as {
key?: unknown;
name?: unknown;
ctrl?: boolean;
shift?: boolean;
meta?: boolean;
super?: boolean;
};
if (value.key !== undefined) return isLeaderKey(ev, value.key);
if (typeof value.name === "string") return matchesKeySpec(ev, value);
}
if (typeof binding !== "string") return false;
return binding.split(",").some((part) => matchesKeySpec(ev, parseKeySpec(part)));
}

function parseKeySpec(spec: string): {
name: string;
ctrl?: boolean;
shift?: boolean;
meta?: boolean;
super?: boolean;
} {
const parts = spec
.trim()
.toLowerCase()
.split("+")
.map((part) => part.trim())
.filter(Boolean);
const key = parts.at(-1) ?? "";
return {
name: key,
ctrl: parts.includes("ctrl"),
shift: parts.includes("shift"),
meta: parts.includes("alt") || parts.includes("meta"),
super: parts.includes("cmd") || parts.includes("super"),
};
}

function matchesKeySpec(
ev: { name: string; ctrl?: boolean; shift?: boolean; meta?: boolean; super?: boolean },
spec: { name: string; ctrl?: boolean; shift?: boolean; meta?: boolean; super?: boolean },
): boolean {
return (
ev.name.toLowerCase() === spec.name.toLowerCase() &&
Boolean(ev.ctrl) === Boolean(spec.ctrl) &&
Boolean(ev.shift) === Boolean(spec.shift) &&
Boolean(ev.meta) === Boolean(spec.meta) &&
Boolean(ev.super) === Boolean(spec.super)
);
}

export default plugin;
Loading