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 (638 lines) Pure vim engine: state, handlers, command tables, types
index.ts (357 lines) Plugin entry: intercept registration, action application
vim.ts (645 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 (1341 lines) Characterization tests for all key handling branches
vim.test.ts (1434 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 @@ -26,6 +26,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version

### Fixed

- Counted delete/change commands such as `3dw` now undo in one `u` press instead of one undo per repeated host command.
- Leader key now works with modifier-based configurations like the default `ctrl+x`. Previously, `parseLeaderKey` expected vim-style `C-x` notation but OpenCode's keybinds API returns `ctrl+x` format, so the leader key was silently broken for any config with modifiers.
- Leader detection supports all OpenCode modifier aliases (`control`, `alt`, `option`, `super`) and multiple leader bindings.
- Optional chaining on `api.kv.set` to avoid crashes on older OpenCode versions.
Expand Down
30 changes: 19 additions & 11 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ const plugin: TuiPluginModule = {
let leaderPending = false;
let leaderTimer: ReturnType<typeof setTimeout> | null = null;

// 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 Down Expand Up @@ -78,11 +78,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 @@ -136,26 +137,33 @@ const plugin: TuiPluginModule = {
const editor = api.renderer?.currentFocusedEditor;
const eb = editor?.editBuffer;
if (eb?.deleteRange) {
undoSnapshot = {
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
41 changes: 24 additions & 17 deletions src/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type Action =
| { type: "yankSelection" }
| { type: "clearSelection" }
| { type: "deleteRange"; start: number; end: number }
| { type: "saveUndoSnapshot" }
| { type: "undo" }
| { type: "cursorTo"; offset: number }
| { type: "selectRange"; start: number; end: number };
Expand Down Expand Up @@ -218,7 +219,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
pushN(actions, "input.delete", n);
actions.push({ type: "insertText", text: key.repeat(n) });
state.pendingChar = null;
return { consume: true, actions };
return finishUndoableChange(actions);
}

// Pending g prefix (gg, ge, etc.)
Expand Down Expand Up @@ -290,12 +291,12 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
if (state.yankRegister) actions.push({ type: "yank", text: state.yankRegister });
actions.push({ type: "cmd", cmd: "prompt.paste" });
resetPending(state);
return { consume: true, actions };
return finishUndoableChange(actions);
}

if (key === "X") {
pushN(actions, "input.backspace", consumeCount(state));
return { consume: true, actions };
return finishUndoableChange(actions);
}

if (key === "J") {
Expand All @@ -304,7 +305,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
actions.push({ type: "cmd", cmd: "input.line.end" });
actions.push({ type: "cmd", cmd: "input.delete" });
}
return { consume: true, actions };
return finishUndoableChange(actions);
}

// Operators: d, c, y
Expand All @@ -319,11 +320,13 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
state.yankRegister = text;
actions.push({ type: "yank", text });
actions.push({ type: "toast", message: `${n} line${n > 1 ? "s" : ""} yanked`, duration: 1000 });
resetPending(state);
} else {
pushN(actions, "input.delete.line", n);
if (key === "c") enterInsert(state, actions);
else resetPending(state);
return finishUndoableChange(actions);
}
state.pendingOp = null;
return { consume: true, actions };
}
state.pendingOp = key;
Expand All @@ -333,13 +336,13 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
if (key === "D") {
actions.push({ type: "cmd", cmd: "input.delete.to.line.end" });
resetPending(state);
return { consume: true, actions };
return finishUndoableChange(actions);
}

if (key === "C") {
actions.push({ type: "cmd", cmd: "input.delete.to.line.end" });
enterInsert(state, actions);
return { consume: true, actions };
return finishUndoableChange(actions);
}

// Pending operator + e (end-of-word needs special handling)
Expand All @@ -352,12 +355,12 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
state.yankRegister = text;
actions.push({ type: "yank", text });
resetPending(state);
} else {
actions.push({ type: "deleteRange", start: offset, end: target });
if (state.pendingOp === "c") enterInsert(state, actions);
else resetPending(state);
return { consume: true, actions };
}
return { consume: true, actions };
actions.push({ type: "deleteRange", start: offset, end: target });
if (state.pendingOp === "c") enterInsert(state, actions);
else resetPending(state);
return finishUndoableChange(actions);
}

// Pending operator + motion
Expand All @@ -378,14 +381,14 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
pushN(actions, "input.delete.line", n + 1);
if (state.pendingOp === "c") enterInsert(state, actions);
else resetPending(state);
return { consume: true, actions };
return finishUndoableChange(actions);
}
if (key === "k") {
pushN(actions, "input.move.up", n);
pushN(actions, "input.delete.line", n + 1);
if (state.pendingOp === "c") enterInsert(state, actions);
else resetPending(state);
return { consume: true, actions };
return finishUndoableChange(actions);
}
if (key === "G") {
consumeCount(state);
Expand All @@ -394,15 +397,15 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom
actions.push({ type: "deleteRange", start: offset, end: Math.max(0, text.length - 1) });
if (state.pendingOp === "c") enterInsert(state, actions);
else resetPending(state);
return { consume: true, actions };
return finishUndoableChange(actions);
}

const deleteCmd = DELETE_MOTION[key];
if (deleteCmd) {
pushN(actions, deleteCmd, n);
if (state.pendingOp === "c") enterInsert(state, actions);
else resetPending(state);
return { consume: true, actions };
return finishUndoableChange(actions);
}

resetPending(state);
Expand Down Expand Up @@ -437,7 +440,7 @@ export function handleNormalKey(state: VimState, key: string, ev: KeyEvent, prom

if (key === "x") {
pushN(actions, "input.delete", consumeCount(state));
return { consume: true, actions };
return finishUndoableChange(actions);
}

if (key === "r") {
Expand Down Expand Up @@ -596,6 +599,10 @@ function resetPending(state: VimState) {
state.count = 0;
}

function finishUndoableChange(actions: Action[]): HandlerResult {
return { consume: true, actions: [{ type: "saveUndoSnapshot" }, ...actions] };
}

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);
Expand Down
93 changes: 93 additions & 0 deletions test/vim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ function deleteRanges(actions: Action[]): Array<{ start: number; end: number }>
.map((a) => ({ start: a.start, end: a.end }));
}

function saveUndoSnapshots(actions: Action[]): Action[] {
return actions.filter((a) => a.type === "saveUndoSnapshot");
}

function selectRanges(actions: Action[]): Array<{ start: number; end: number }> {
return actions
.filter((a): a is Extract<Action, { type: "selectRange" }> => a.type === "selectRange")
Expand Down Expand Up @@ -412,6 +416,9 @@ describe("handleNormalKey — e motion", () => {
const r = handleNormalKey(state, "e", ev("e"), ePrompt);
expect(deleteRanges(r.actions)).toEqual([{ start: 0, end: 4 }]);
expect(state.mode).toBe("normal");
// The deleteRange goes through finishUndoableChange, so the snapshot
// comes from a single source (the saveUndoSnapshot action), not index.ts.
expect(saveUndoSnapshots(r.actions)).toHaveLength(1);
});

it("ce deletes from cursor to end of word and enters insert", () => {
Expand Down Expand Up @@ -452,6 +459,18 @@ describe("handleNormalKey — operators", () => {
expect(cmds(r.actions)).toEqual(["input.delete.word.forward"]);
});

it("3dw saves one undo snapshot around the repeated deletes", () => {
Comment thread
hamidi-dev marked this conversation as resolved.
handleNormalKey(state, "3", ev("3"), mockPrompt);
handleNormalKey(state, "d", ev("d"), mockPrompt);
const r = handleNormalKey(state, "w", ev("w"), mockPrompt);
expect(saveUndoSnapshots(r.actions)).toHaveLength(1);
expect(cmds(r.actions)).toEqual([
"input.delete.word.forward",
"input.delete.word.forward",
"input.delete.word.forward",
]);
});

it("d$ dispatches input.delete.to.line.end", () => {
handleNormalKey(state, "d", ev("d"), mockPrompt);
const r = handleNormalKey(state, "$", ev("4", { shift: true }), mockPrompt);
Expand Down Expand Up @@ -541,6 +560,9 @@ describe("handleNormalKey — dG and cG", () => {
const r = handleNormalKey(state, "G", ev("g", { shift: true }), midPrompt);
expect(deleteRanges(r.actions)).toEqual([{ start: 12, end: 33 }]);
expect(state.mode).toBe("normal");
// Single snapshot source: the saveUndoSnapshot action, not a second
// push inside the deleteRange handler.
expect(saveUndoSnapshots(r.actions)).toHaveLength(1);
});

it("cG deletes from cursor to buffer end, enters insert", () => {
Expand Down Expand Up @@ -1338,4 +1360,75 @@ describe("undo snapshot — deleteRange + u", () => {
await new Promise((r) => setTimeout(r, 20));
expect(dispatched).toContain("input.undo");
});

it("u after 3dw restores the full buffer via editBuffer.setText", async () => {
const original = "hello world second line third line";
const { press, calls, dispatched, getCursor } = await setup(original, 0);

press("3");
press("d");
press("w");

calls.length = 0;
press("u");

expect(calls).toContainEqual({ method: "setText", args: [original] });
expect(getCursor()).toBe(0);
expect(dispatched).not.toContain("input.undo");
});

it("u after 3dw then dd unwinds the snapshot stack one step per press", async () => {
const original = "hello world second line third line";
const { press, calls, dispatched } = await setup(original, 0);

// Two stacked undoable changes → two snapshots on the stack.
press("3");
press("d");
press("w");
press("d");
press("d");

// First u pops the dd snapshot, second pops the 3dw snapshot — each a
// local restore via setText, never the host's input.undo.
calls.length = 0;
dispatched.length = 0;
press("u");
expect(calls.some((c) => c.method === "setText")).toBe(true);
expect(dispatched).not.toContain("input.undo");

calls.length = 0;
press("u");
expect(calls.some((c) => c.method === "setText")).toBe(true);
expect(dispatched).not.toContain("input.undo");

// Stack is now empty — a third u falls through to host undo.
calls.length = 0;
press("u");
expect(calls.every((c) => c.method !== "setText")).toBe(true);
await new Promise((r) => setTimeout(r, 20));
expect(dispatched).toContain("input.undo");
});

it("u after 3dw then an insert-mode edit falls back to host input.undo", async () => {
const { press, calls, dispatched } = await setup("hello world second line third line", 0);

press("3");
press("d");
press("w");

// Enter insert and modify the buffer. The insert edit emits an
// insertText action, which clears the vim snapshot stack.
press("i");
press("tab");
press("escape");

calls.length = 0;
dispatched.length = 0;
press("u");

expect(calls.every((c) => c.method !== "setText")).toBe(true);
// input.undo is dispatched via setTimeout
await new Promise((r) => setTimeout(r, 20));
expect(dispatched).toContain("input.undo");
});
});
Loading