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 @@ -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 (229 lines) Plugin entry: intercept registration, action application
vim.ts (631 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 (1271 lines) Characterization tests for all key handling branches
```

**Data flow:**
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ 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.
- `leader` plugin option — lets you use space (or any key) as leader without breaking insert mode. Spaces type normally while editing, and leader sequences like `space l` still work in normal mode ([#21](https://github.com/oribarilan/vimcode/issues/21)).

## [0.10.0] — 2026-05-31

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ To pass options, use the tuple form in `tui.json`:
| `updateCheck` | `boolean` | `true` | On startup, check GitHub for new versions (at most once per day). This is the only network request vimcode makes. Set to `false` to disable. |
| `modeToast` | `boolean` | `true` | Show a brief toast ("NORMAL" / "INSERT" / "VISUAL") on mode switches. Set to `false` to rely on cursor shape alone. |
| `startMode` | `"insert"` \| `"normal"` | `"insert"` | Which mode to start in when OpenCode launches. |
| `leader` | `string` | — | Set this to match your `"leader"` keybind in `tui.json`. If you use space as leader (common in vim), this keeps spaces working while you type and lets leader sequences like `space l` fire in normal mode. Supports modifiers too: `"C-x"`, `"S-a"`, `"M-x"`. |

## What it does

Expand Down
31 changes: 30 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
handleInsertKey,
handleNormalKey,
handleVisualKey,
matchesLeader,
parseLeaderKey,
translateKey,
} from "./vim";

Expand All @@ -17,6 +19,12 @@ const plugin: TuiPluginModule = {
const state = createVimState();
const startMode = options?.startMode === "normal" ? "normal" : "insert";
state.mode = startMode;
const leader = options?.leader ? parseLeaderKey(options.leader) : null;

// Track whether the previous key was the leader, so the follow-up
// key also passes through to OpenCode's leader system.
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
Expand Down Expand Up @@ -131,6 +139,9 @@ const plugin: TuiPluginModule = {
// in terminals that don't support DECSCUSR (e.g. macOS Terminal.app).
const cursorInterval = setInterval(syncCursorStyle, 100);
api.lifecycle?.onDispose?.(() => clearInterval(cursorInterval));
api.lifecycle?.onDispose?.(() => {
if (leaderTimer) clearTimeout(leaderTimer);
});

if (options?.updateCheck !== false) {
checkForUpdate((opts) => api.ui?.toast?.(opts), api.kv);
Expand Down Expand Up @@ -175,10 +186,28 @@ const plugin: TuiPluginModule = {
}

const key = translateKey(ctx.event);

// In normal/visual mode, let the leader key and its follow-up
// pass through so OpenCode's leader bindings work.
if (leader && state.mode !== "insert") {
if (leaderPending) {
leaderPending = false;
if (leaderTimer) clearTimeout(leaderTimer);
return;
}
if (matchesLeader(ctx.event, leader)) {
leaderPending = true;
leaderTimer = setTimeout(() => {
leaderPending = false;
}, 2000);
return;
}
}

const handlerMode = state.mode;
const result =
state.mode === "insert"
? handleInsertKey(state, key, ctx.event)
? handleInsertKey(state, key, ctx.event, leader)
: state.mode === "visual"
? handleVisualKey(state, key, ctx.event)
: handleNormalKey(state, key, ctx.event, prompt);
Expand Down
55 changes: 54 additions & 1 deletion src/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ export type KeyEvent = {
eventType?: string;
};

export type ParsedLeader = {
name: string;
ctrl: boolean;
shift: boolean;
meta: boolean;
char: string | null;
};

export type PromptAccess = {
getLine: (n: number) => string;
getLineCount: () => number;
Expand Down Expand Up @@ -130,7 +138,45 @@ export function translateKey(ev: KeyEvent): string {
return key;
}

export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent): HandlerResult {
export function parseLeaderKey(raw: string): ParsedLeader | null {
if (!raw) return null;
let ctrl = false;
let shift = false;
let meta = false;
let remaining = raw;
while (remaining.length > 2 && remaining[1] === "-") {
const mod = remaining[0].toUpperCase();
if (mod === "C") ctrl = true;
else if (mod === "S") shift = true;
else if (mod === "M") meta = true;
else break;
remaining = remaining.slice(2);
}
const name = remaining.toLowerCase();
let char: string | null = null;
if (!ctrl && !meta) {
if (name === "space") char = " ";
else if (name === "tab") char = "\t";
else if (name.length === 1 && /[a-z0-9]/.test(name)) char = shift ? name.toUpperCase() : name;
}
return { name, ctrl, shift, meta, char };
}

export function matchesLeader(ev: KeyEvent, leader: ParsedLeader): boolean {
return (
ev.name === leader.name &&
(ev.ctrl ?? false) === leader.ctrl &&
(ev.shift ?? false) === leader.shift &&
(ev.meta ?? false) === leader.meta
);
}

export function handleInsertKey(
state: VimState,
_key: string,
ev: KeyEvent,
leader?: ParsedLeader | null,
): HandlerResult {
if (ev.name === "escape") {
state.mode = "normal";
return { consume: true, actions: [{ type: "mode", mode: "normal" }] };
Expand All @@ -149,6 +195,13 @@ export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent): Ha
state.oneShotNormal = true;
return { consume: true, actions: [{ type: "toast", message: "(insert)", duration: 800 }] };
}
// Swallow the leader key so OpenCode doesn't open the leader menu
// while typing. Insert the literal character if it's printable.
if (leader && matchesLeader(ev, leader)) {
return leader.char
? { consume: true, actions: [{ type: "insertText", text: leader.char }] }
: { consume: true, actions: [] };
}
return PASS;
}

Expand Down
99 changes: 99 additions & 0 deletions test/vim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
handleNormalKey,
handleVisualKey,
type PromptAccess,
parseLeaderKey,
translateKey,
type VimState,
} from "../src/vim";
Expand Down Expand Up @@ -138,6 +139,46 @@ describe("translateKey", () => {
});
});

// ── parseLeaderKey ─────────────────────────────────────────

describe("parseLeaderKey", () => {
it("parses 'space' with printable char", () => {
expect(parseLeaderKey("space")).toEqual({ name: "space", ctrl: false, shift: false, meta: false, char: " " });
});

it("parses single letter", () => {
expect(parseLeaderKey("a")).toEqual({ name: "a", ctrl: false, shift: false, meta: false, char: "a" });
});

it("parses 'C-x' (ctrl modifier)", () => {
expect(parseLeaderKey("C-x")).toEqual({ name: "x", ctrl: true, shift: false, meta: false, char: null });
});

it("parses 'S-a' (shift modifier, char uppercased)", () => {
expect(parseLeaderKey("S-a")).toEqual({ name: "a", ctrl: false, shift: true, meta: false, char: "A" });
});

it("parses 'M-x' (meta modifier)", () => {
expect(parseLeaderKey("M-x")).toEqual({ name: "x", ctrl: false, shift: false, meta: true, char: null });
});

it("parses compound modifiers 'C-S-a'", () => {
expect(parseLeaderKey("C-S-a")).toEqual({ name: "a", ctrl: true, shift: true, meta: false, char: null });
});

it("parses 'tab' with printable char", () => {
expect(parseLeaderKey("tab")).toEqual({ name: "tab", ctrl: false, shift: false, meta: false, char: "\t" });
});

it("returns null for empty string", () => {
expect(parseLeaderKey("")).toBeNull();
});

it("multi-char key name without modifiers has no printable char", () => {
expect(parseLeaderKey("escape")).toEqual({ name: "escape", ctrl: false, shift: false, meta: false, char: null });
});
});

// ── handleInsertKey ─────────────────────────────────────────

describe("handleInsertKey", () => {
Expand Down Expand Up @@ -175,6 +216,64 @@ describe("handleInsertKey", () => {
expect(r.consume).toBe(false);
});

it("leader key → consume and insert its character", () => {
const leader = parseLeaderKey("space");
const r = handleInsertKey(state, "space", ev("space"), leader);
expect(r.consume).toBe(true);
expect(r.actions).toContainEqual({ type: "insertText", text: " " });
});

it("non-leader key still passes through with leader set", () => {
const leader = parseLeaderKey("space");
const r = handleInsertKey(state, "a", ev("a"), leader);
expect(r.consume).toBe(false);
});

it("non-printable leader consumed without insertText", () => {
const leader = parseLeaderKey("C-x");
const r = handleInsertKey(state, "x", ev("x", { ctrl: true }), leader);
expect(r.consume).toBe(true);
expect(r.actions).toEqual([]);
});

it("explicit handlers take priority over leader (escape)", () => {
const leader = parseLeaderKey("escape");
const r = handleInsertKey(state, "escape", ev("escape"), leader);
expect(r.consume).toBe(true);
expect(state.mode).toBe("normal");
});

it("no leader parameter → backward compatible passthrough", () => {
const r = handleInsertKey(state, "space", ev("space"));
expect(r.consume).toBe(false);
});

it("null leader → backward compatible passthrough", () => {
const r = handleInsertKey(state, "space", ev("space"), null);
expect(r.consume).toBe(false);
});

it("tab handler wins over tab-as-leader", () => {
const leader = parseLeaderKey("tab");
const r = handleInsertKey(state, "tab", ev("tab"), leader);
expect(r.consume).toBe(true);
expect(r.actions).toContainEqual({ type: "insertText", text: "\t" });
});

it("return handler wins over return-as-leader", () => {
const leader = parseLeaderKey("return");
const r = handleInsertKey(state, "return", ev("return"), leader);
expect(r.consume).toBe(true);
expect(cmds(r.actions)).toContain("input.newline");
});

it("shift leader matches shift key event", () => {
const leader = parseLeaderKey("S-a");
const r = handleInsertKey(state, "A", ev("a", { shift: true }), leader);
expect(r.consume).toBe(true);
expect(r.actions).toContainEqual({ type: "insertText", text: "A" });
});

it("ctrl+o enters normal mode with oneShotNormal flag", () => {
const r = handleInsertKey(state, "o", ev("o", { ctrl: true }));
expect(r.consume).toBe(true);
Expand Down
Loading