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
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion dev-tui.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
{
"plugin": ["."]
"plugin": ["."],
"keybinds": {
"leader": "ctrl+x"
}
}
6 changes: 4 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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


67 changes: 41 additions & 26 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -8,9 +9,6 @@ import {
handleInsertKey,
handleNormalKey,
handleVisualKey,
matchesLeader,
type ParsedLeader,
parseLeaderKey,
toggleVimMode,
translateKey,
} from "./vim";
Expand All @@ -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
Expand Down Expand Up @@ -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<string, unknown>).name === "string")),
);
}

function applyActions(actions: Action[]) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand All @@ -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 },
);
Expand Down
73 changes: 73 additions & 0 deletions src/leader.ts
Original file line number Diff line number Diff line change
@@ -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<string, Mod> = {
ctrl: "ctrl",
control: "ctrl",
shift: "shift",
meta: "meta",
alt: "meta",
option: "meta",
super: "super",
};

function parseString(s: string): { key: string; mods: Record<Mod, boolean> } {
const mods: Record<Mod, boolean> = { 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<Mod, boolean>): 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<Mod, boolean> = { 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;
}
55 changes: 1 addition & 54 deletions src/vim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" }] };
Expand All @@ -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;
}

Expand Down
Loading
Loading