From a35e1b14471a6aea939f4dbf0e66fdbad60454e1 Mon Sep 17 00:00:00 2001 From: ori Date: Mon, 8 Jun 2026 00:20:34 +0300 Subject: [PATCH 1/4] impl and tests --- AGENTS.md | 6 +-- CHANGELOG.md | 1 + README.md | 1 + src/index.ts | 4 +- src/vim.ts | 55 ++++++++++++++++++++++++++- test/vim.test.ts | 99 ++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 161 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4ca0be8..47a6e0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 (202 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:** diff --git a/CHANGELOG.md b/CHANGELOG.md index 7989d98..3420209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 — if your OpenCode leader key doubles as a character you type (like space), this stops it from firing as leader while you're in insert mode ([#21](https://github.com/oribarilan/vimcode/issues/21)). ## [0.10.0] — 2026-05-31 diff --git a/README.md b/README.md index 5b2c7a1..de99748 100644 --- a/README.md +++ b/README.md @@ -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` | — | Match this to your `"leader"` keybind in `tui.json` (e.g. `"space"`). In insert mode, vimcode types the character normally instead of opening the leader menu. Modifier prefixes work: `"C-x"`, `"S-a"`, `"M-x"`. | ## What it does diff --git a/src/index.ts b/src/index.ts index 60ce161..6afc71d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { handleInsertKey, handleNormalKey, handleVisualKey, + parseLeaderKey, translateKey, } from "./vim"; @@ -17,6 +18,7 @@ const plugin: TuiPluginModule = { const state = createVimState(); const startMode = options?.startMode === "normal" ? "normal" : "insert"; state.mode = startMode; + const leader = options?.leader ? parseLeaderKey(options.leader) : null; // Snapshot for single-step undo of deleteRange operations. // The host editor's undo system splits multi-line deletions into @@ -178,7 +180,7 @@ const plugin: TuiPluginModule = { 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); diff --git a/src/vim.ts b/src/vim.ts index c245386..4e0b4c5 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -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; @@ -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 }; +} + +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" }] }; @@ -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; } diff --git a/test/vim.test.ts b/test/vim.test.ts index 0623602..ad7ab0d 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -8,6 +8,7 @@ import { handleNormalKey, handleVisualKey, type PromptAccess, + parseLeaderKey, translateKey, type VimState, } from "../src/vim"; @@ -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", () => { @@ -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); From 5ebf6522ee5556c1a718a52a3762cfd216e2ad52 Mon Sep 17 00:00:00 2001 From: ori Date: Mon, 8 Jun 2026 00:31:37 +0300 Subject: [PATCH 2/4] adding normal mode support now --- src/index.ts | 27 +++++++++++++++++++++++++++ src/vim.ts | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 6afc71d..b8da806 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import { handleInsertKey, handleNormalKey, handleVisualKey, + matchesLeader, parseLeaderKey, translateKey, } from "./vim"; @@ -20,6 +21,11 @@ const plugin: TuiPluginModule = { 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 | 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. @@ -133,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); @@ -177,6 +186,24 @@ 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" diff --git a/src/vim.ts b/src/vim.ts index 4e0b4c5..3d52f00 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -162,7 +162,7 @@ export function parseLeaderKey(raw: string): ParsedLeader | null { return { name, ctrl, shift, meta, char }; } -function matchesLeader(ev: KeyEvent, leader: ParsedLeader): boolean { +export function matchesLeader(ev: KeyEvent, leader: ParsedLeader): boolean { return ( ev.name === leader.name && (ev.ctrl ?? false) === leader.ctrl && From 1f5f66f8ffb8d83173866fa62e58f095fa6f41bf Mon Sep 17 00:00:00 2001 From: ori Date: Mon, 8 Jun 2026 00:32:43 +0300 Subject: [PATCH 3/4] readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index de99748..a3b9125 100644 --- a/README.md +++ b/README.md @@ -49,7 +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` | — | Match this to your `"leader"` keybind in `tui.json` (e.g. `"space"`). In insert mode, vimcode types the character normally instead of opening the leader menu. Modifier prefixes work: `"C-x"`, `"S-a"`, `"M-x"`. | +| `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 From 16bb2e286c1643fe4b56d654dc9d1f5f52b1bc5a Mon Sep 17 00:00:00 2001 From: ori Date: Mon, 8 Jun 2026 00:33:54 +0300 Subject: [PATCH 4/4] changelog --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 47a6e0a..ced19a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (202 lines) Plugin entry: intercept registration, action application + 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3420209..654c8ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +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 — if your OpenCode leader key doubles as a character you type (like space), this stops it from firing as leader while you're in insert mode ([#21](https://github.com/oribarilan/vimcode/issues/21)). +- `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