diff --git a/AGENTS.md b/AGENTS.md index 022d8b8..0b5dacc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,12 +57,14 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation, ``` src/ - index.ts (242 lines) Plugin entry: intercept registration, action application - vim.ts (631 lines) Pure vim engine: state, handlers, command tables, types + index.ts (342 lines) Plugin entry: intercept registration, action application + vim.ts (608 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 (1271 lines) Characterization tests for all key handling branches + vim.test.ts (1268 lines) Characterization tests for all key handling branches + leader.test.ts (125 lines) Unit tests for leader key matching functions ``` **Data flow:** diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a5bcea..5003bd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ### Fixed +- 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. - Escape/Enter no longer intercepted when vim is disabled. - Toggling vim off resets mode and clears pending state. diff --git a/dev-tui.json b/dev-tui.json index 0d8a074..7d7ecbb 100644 --- a/dev-tui.json +++ b/dev-tui.json @@ -1,3 +1,6 @@ { - "plugin": ["."] + "plugin": ["."], + "keybinds": { + "leader": "ctrl+x" + } } diff --git a/justfile b/justfile index 210c331..793b4f4 100644 --- a/justfile +++ b/justfile @@ -21,8 +21,10 @@ check: just lint just test -# Launch OpenCode with the vimcode plugin active +# Launch OpenCode with the vimcode plugin active. +# Unset OPENCODE_CONFIG_DIR so dev-tui.json keybinds aren't +# overridden by the global dotfiles config (which is level 4). dev: - OPENCODE_TUI_CONFIG=dev-tui.json opencode + OPENCODE_TUI_CONFIG=dev-tui.json OPENCODE_CONFIG_DIR= opencode diff --git a/src/index.ts b/src/index.ts index b3e3791..ddbd5f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import type { TuiPluginModule } from "@opencode-ai/plugin/tui"; import { writeClipboard } from "./clipboard"; +import { findMatchingLeader, type KeyLike, leaderChar } from "./leader"; import { checkForUpdate } from "./version"; import { type Action, @@ -8,9 +9,6 @@ import { handleInsertKey, handleNormalKey, handleVisualKey, - matchesLeader, - type ParsedLeader, - parseLeaderKey, toggleVimMode, translateKey, } from "./vim"; @@ -21,7 +19,7 @@ const plugin: TuiPluginModule = { const state = createVimState(); const startMode = options?.startMode === "normal" ? "normal" : "insert"; state.mode = startMode; - const leader = resolveLeader(); + const leaderKeys = resolveLeaderKeys(); // Resolve modeIndicator: "toast" (default) or "none". // Backward compat: modeToast:false maps to "none", but only if @@ -64,19 +62,19 @@ const plugin: TuiPluginModule = { return api.renderer?.currentFocusedEditor?.plainText ?? ""; } - // Read the leader key from OpenCode's resolved keybinds config. - function resolveLeader(): ParsedLeader | null { - const binding = api.tuiConfig?.keybinds?.get?.("leader")?.[0]; - const key = binding?.key; - if (typeof key === "string") return parseLeaderKey(key); - if (key && typeof key === "object" && typeof key.name === "string") { - let raw = key.name; - if (key.ctrl) raw = `C-${raw}`; - if (key.shift) raw = `S-${raw}`; - if (key.meta) raw = `M-${raw}`; - return parseLeaderKey(raw); - } - return null; + // Read all configured leader keys from OpenCode's keybinds config. + function resolveLeaderKeys(): KeyLike[] { + const bindings = api.tuiConfig?.keybinds?.get?.("leader") ?? []; + return bindings + .map((b: { key?: unknown }) => b.key) + .filter( + (k: unknown): k is KeyLike => + !!k && + k !== "none" && + k !== "false" && + (typeof k === "string" || + (typeof k === "object" && typeof (k as Record).name === "string")), + ); } function applyActions(actions: Action[]) { @@ -255,11 +253,11 @@ const plugin: TuiPluginModule = { // Consume the leader key so dispatchLayers() doesn't // match it as a leader token, which would enter pending- // sequence state instead of typing a space. - if (leader && matchesLeader(ctx.event, leader)) { + const matched = findMatchingLeader(ctx.event, leaderKeys); + if (matched) { ctx.consume(); - if (leader.char) { - api.renderer?.currentFocusedEditor?.insertText?.(leader.char); - } + const ch = leaderChar(matched); + if (ch) api.renderer?.currentFocusedEditor?.insertText?.(ch); } return; } @@ -290,13 +288,13 @@ const plugin: TuiPluginModule = { // 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 (leaderKeys.length > 0 && state.mode !== "insert") { if (leaderPending) { leaderPending = false; if (leaderTimer) clearTimeout(leaderTimer); return; } - if (matchesLeader(ctx.event, leader)) { + if (findMatchingLeader(ctx.event, leaderKeys)) { leaderPending = true; leaderTimer = setTimeout(() => { leaderPending = false; @@ -308,13 +306,30 @@ const plugin: TuiPluginModule = { const handlerMode = state.mode; const result = state.mode === "insert" - ? handleInsertKey(state, key, ctx.event, leader) + ? handleInsertKey(state, key, ctx.event) : state.mode === "visual" ? handleVisualKey(state, key, ctx.event) : handleNormalKey(state, key, ctx.event, prompt); if (handlerMode === "normal") finishOneShotIfComplete(state, result); - if (result.consume) ctx.consume(); - applyActions(result.actions); + + // In insert mode, if the handler didn't consume the key, check + // if it's a leader key. Swallow it to prevent the leader menu + // from popping up while typing. This runs after handleInsertKey + // so explicit handlers (escape, return, tab, ctrl+o) take priority. + // Don't mutate `result` — it may be the shared PASS constant. + let consume = result.consume; + let actions = result.actions; + if (handlerMode === "insert" && !consume && leaderKeys.length > 0) { + const matched = findMatchingLeader(ctx.event, leaderKeys); + if (matched) { + const ch = leaderChar(matched); + actions = ch ? [{ type: "insertText" as const, text: ch }] : []; + consume = true; + } + } + + if (consume) ctx.consume(); + applyActions(actions); }, { priority: 10_000 }, ); diff --git a/src/leader.ts b/src/leader.ts new file mode 100644 index 0000000..a299337 --- /dev/null +++ b/src/leader.ts @@ -0,0 +1,73 @@ +import type { KeyEvent } from "./vim"; + +export type KeyStrokeInput = { name: string; ctrl?: boolean; shift?: boolean; meta?: boolean; super?: boolean }; +export type KeyLike = string | KeyStrokeInput; + +const MODS = ["ctrl", "shift", "meta", "super"] as const; +type Mod = (typeof MODS)[number]; + +const MOD_ALIASES: Record = { + ctrl: "ctrl", + control: "ctrl", + shift: "shift", + meta: "meta", + alt: "meta", + option: "meta", + super: "super", +}; + +function parseString(s: string): { key: string; mods: Record } { + const mods: Record = { ctrl: false, shift: false, meta: false, super: false }; + if (s === "+") return { key: "+", mods }; + const parts = s.split("+").filter(Boolean); + const last = parts.pop(); + if (!last) return { key: s.toLowerCase(), mods }; + const key = last.toLowerCase(); + for (const p of parts) { + const mod = MOD_ALIASES[p.toLowerCase()]; + if (mod) mods[mod] = true; + } + return { key, mods }; +} + +function matchParsed(ev: KeyEvent, key: string, mods: Record): boolean { + if (ev.name.toLowerCase() !== key) return false; + return MODS.every((m) => (ev[m] ?? false) === mods[m]); +} + +export function matchesKeyLike(ev: KeyEvent, keyLike: KeyLike): boolean { + if (typeof keyLike === "string") { + const { key, mods } = parseString(keyLike); + return matchParsed(ev, key, mods); + } + const mods: Record = { ctrl: false, shift: false, meta: false, super: false }; + for (const m of MODS) if (keyLike[m]) mods[m] = true; + return matchParsed(ev, keyLike.name.toLowerCase(), mods); +} + +export function findMatchingLeader(ev: KeyEvent, keys: KeyLike[]): KeyLike | null { + return keys.find((k) => matchesKeyLike(ev, k)) ?? null; +} + +export function leaderChar(keyLike: KeyLike): string | null { + let key: string; + let hasShift: boolean; + let hasOtherMod: boolean; + + if (typeof keyLike === "string") { + const parsed = parseString(keyLike); + key = parsed.key; + hasShift = parsed.mods.shift; + hasOtherMod = parsed.mods.ctrl || parsed.mods.meta || parsed.mods.super; + } else { + key = keyLike.name.toLowerCase(); + hasShift = keyLike.shift ?? false; + hasOtherMod = !!(keyLike.ctrl || keyLike.meta || keyLike.super); + } + + if (hasOtherMod) return null; + if (key === "space") return " "; + if (key === "tab") return "\t"; + if (key.length !== 1) return null; + return hasShift ? key.toUpperCase() : key; +} diff --git a/src/vim.ts b/src/vim.ts index 7c25082..8543dc0 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -38,14 +38,6 @@ 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; @@ -168,45 +160,7 @@ export function translateKey(ev: KeyEvent): string { return key; } -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 { +export function handleInsertKey(state: VimState, _key: string, ev: KeyEvent): HandlerResult { if (ev.name === "escape") { state.mode = "normal"; return { consume: true, actions: [{ type: "mode", mode: "normal" }] }; @@ -225,13 +179,6 @@ export function handleInsertKey( state.oneShotNormal = true; return { consume: true, actions: [{ type: "mode", mode: "(insert)" }] }; } - // 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/leader.test.ts b/test/leader.test.ts new file mode 100644 index 0000000..483653a --- /dev/null +++ b/test/leader.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "bun:test"; +import { findMatchingLeader, leaderChar, matchesKeyLike } from "../src/leader"; + +const ev = (name: string, opts?: { shift?: boolean; ctrl?: boolean; meta?: boolean; super?: boolean }) => ({ + name, + shift: opts?.shift ?? false, + ctrl: opts?.ctrl ?? false, + meta: opts?.meta ?? false, + super: opts?.super ?? false, +}); + +describe("matchesKeyLike", () => { + // String format + it("ctrl+x matches event with ctrl and name x", () => { + expect(matchesKeyLike(ev("x", { ctrl: true }), "ctrl+x")).toBe(true); + }); + + it("control+x matches (alias)", () => { + expect(matchesKeyLike(ev("x", { ctrl: true }), "control+x")).toBe(true); + }); + + it("alt+m matches event with meta", () => { + expect(matchesKeyLike(ev("m", { meta: true }), "alt+m")).toBe(true); + }); + + it("option+m matches event with meta (alias)", () => { + expect(matchesKeyLike(ev("m", { meta: true }), "option+m")).toBe(true); + }); + + it("ctrl+shift+d matches event with ctrl+shift", () => { + expect(matchesKeyLike(ev("d", { ctrl: true, shift: true }), "ctrl+shift+d")).toBe(true); + }); + + it("ctrl+x does not match when shift is also true", () => { + expect(matchesKeyLike(ev("x", { ctrl: true, shift: true }), "ctrl+x")).toBe(false); + }); + + it("space matches event with name space", () => { + expect(matchesKeyLike(ev("space"), "space")).toBe(true); + }); + + it("+ as literal key matches event with name +", () => { + expect(matchesKeyLike(ev("+"), "+")).toBe(true); + }); + + it("Ctrl+X matches (case insensitive)", () => { + expect(matchesKeyLike(ev("x", { ctrl: true }), "Ctrl+X")).toBe(true); + }); + + it("simple key a matches event with name a", () => { + expect(matchesKeyLike(ev("a"), "a")).toBe(true); + }); + + // Object format + it("object { name: x, ctrl: true } matches event", () => { + expect(matchesKeyLike(ev("x", { ctrl: true }), { name: "x", ctrl: true })).toBe(true); + }); + + it("object { name: x } does not match event with ctrl", () => { + expect(matchesKeyLike(ev("x", { ctrl: true }), { name: "x" })).toBe(false); + }); + + it("object without modifiers matches plain event", () => { + expect(matchesKeyLike(ev("a"), { name: "a" })).toBe(true); + }); +}); + +describe("findMatchingLeader", () => { + it("returns first matching key from array", () => { + const keys = ["ctrl+x", "space", "a"] as const; + expect(findMatchingLeader(ev("space"), [...keys])).toBe("space"); + }); + + it("returns null for empty array", () => { + expect(findMatchingLeader(ev("a"), [])).toBeNull(); + }); + + it("returns null when nothing matches", () => { + expect(findMatchingLeader(ev("z"), ["a", "b", "c"])).toBeNull(); + }); +}); + +describe("leaderChar", () => { + // String format + it("space → ' '", () => { + expect(leaderChar("space")).toBe(" "); + }); + + it("tab → '\\t'", () => { + expect(leaderChar("tab")).toBe("\t"); + }); + + it("ctrl+x → null", () => { + expect(leaderChar("ctrl+x")).toBeNull(); + }); + + it("shift+a → 'A'", () => { + expect(leaderChar("shift+a")).toBe("A"); + }); + + it("a → 'a'", () => { + expect(leaderChar("a")).toBe("a"); + }); + + it("escape → null (multi-char name)", () => { + expect(leaderChar("escape")).toBeNull(); + }); + + // Object format + it("{ name: space } → ' '", () => { + expect(leaderChar({ name: "space" })).toBe(" "); + }); + + it("{ name: x, ctrl: true } → null", () => { + expect(leaderChar({ name: "x", ctrl: true })).toBeNull(); + }); + + it("{ name: a, shift: true } → 'A'", () => { + expect(leaderChar({ name: "a", shift: true })).toBe("A"); + }); + + it("{ name: a } → 'a'", () => { + expect(leaderChar({ name: "a" })).toBe("a"); + }); +}); diff --git a/test/vim.test.ts b/test/vim.test.ts index b8bf1fd..44b1926 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -8,7 +8,6 @@ import { handleNormalKey, handleVisualKey, type PromptAccess, - parseLeaderKey, toggleVimMode, translateKey, type VimState, @@ -209,46 +208,6 @@ 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", () => { @@ -286,64 +245,6 @@ 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);