diff --git a/AGENTS.md b/AGENTS.md index cc83cfe..022d8b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,11 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i **Gotchas we hit during development:** - TUI plugins go in `tui.json`, not `opencode.json`. The config field is `"plugin"`. - The plugin `package.json` needs `exports: { "./tui": "./src/index.ts" }` — the loader checks `./tui`, not `.`. -- `dispatchCommand()` from inside a `key:before` intercept doesn't work for cursor movement. Wrap in `setTimeout(..., 0)` to break out of the intercept stack. +- **`key:before` is NOT a valid intercept type.** The keymap only supports `"key"`, `"key:after"`, and `"raw"`. Passing `"key:before"` silently registers a raw terminal sequence handler that crashes on key events. +- `dispatchCommand()` from inside a `key` intercept doesn't work for cursor movement. Wrap in `setTimeout(..., 0)` to break out of the intercept stack. - `registerLayer` with `activeWhen` using SolidJS signals requires `reactiveMatcherFromSignal` from `@opentui/keymap/solid`. Plain `() => signal()` doesn't trigger re-evaluation. We chose intercepts instead of layers to avoid this. +- **Leader key is handled entirely within the keymap's `dispatchLayers()`.** There is no separate `useKeyboard` handler for it. `registerTimedLeader` registers a token; `dispatchLayers()` matches it; `getPendingSequence()` exposes the state. Calling `ctx.consume()` in a `key` intercept sets `event.propagationStopped`, which the keymap checks after each intercept — if set, it skips `dispatchLayers()` entirely. This is the correct way to suppress the leader in insert mode. +- **`api.tuiConfig.keybinds`** gives access to OpenCode's resolved keybind config. `api.tuiConfig.keybinds.get("leader")?.[0]?.key` returns the configured leader key. Used by `resolveLeader()` to auto-detect the leader without requiring a separate plugin option. - **SolidJS imports do NOT work in git-installed plugins.** The host's `ensureRuntimePluginSupport` intercepts `solid-js` and `@opentui/solid` imports, but `@opentui/solid/jsx-dev-runtime` (generated by the JSX transform) doesn't resolve from the package cache. Declaring them as peer deps also fails — Bun installs local `.d.ts` stubs that shadow the host's runtime modules. Until OpenCode fixes this, avoid JSX and `solid-js` imports in distributed plugins. Use `api.ui.toast()` for mode feedback instead of slot indicators. - **Do NOT add `solid-js`, `@opentui/solid`, or `@opentui/core` as dependencies or peerDependencies.** If they're in `package.json`, Bun installs them into the plugin's `node_modules/`, and the local `.d.ts` stubs shadow the host's runtime module intercepts. The host provides these at runtime via `ensureRuntimePluginSupport`. Keep them only in `devDependencies` (via `@opencode-ai/plugin` which pulls them in for type-checking). diff --git a/CHANGELOG.md b/CHANGELOG.md index d3a3c75..2af76bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version ## [Unreleased] +### Changed + +- Leader key is now auto-detected from OpenCode's `keybinds.leader` config. The `leader` plugin option has been removed. + +### Fixed + +- Leader key (e.g. space) no longer enters pending-sequence state when typing in question or permission prompt overlays. + ## [0.12.2] — 2026-06-08 ### Reverted diff --git a/README.md b/README.md index 9eaf811..de1c1a2 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,6 @@ 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. | | `modeIndicator` | `"toast"` \| `"none"` | `"toast"` | How to show the current mode. `"toast"` flashes a brief notification on each switch. `"none"` disables it, relying 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 diff --git a/src/index.ts b/src/index.ts index 38099f5..f7df2bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { handleNormalKey, handleVisualKey, matchesLeader, + type ParsedLeader, parseLeaderKey, translateKey, } from "./vim"; @@ -19,7 +20,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; + const leader = resolveLeader(); // Resolve modeIndicator: "toast" (default) or "none". // Backward compat: modeToast:false maps to "none", but only if @@ -55,6 +56,21 @@ 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; + } + function applyActions(actions: Action[]) { for (const action of actions) { // Any buffer-modifying action (other than our own deleteRange/undo) @@ -185,7 +201,18 @@ const plugin: TuiPluginModule = { if (sid) { const q = api.state.session.question(sid); const p = api.state.session.permission(sid); - if ((q && q.length > 0) || (p && p.length > 0)) return; + if ((q && q.length > 0) || (p && p.length > 0)) { + // 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)) { + ctx.consume(); + if (leader.char) { + api.renderer?.currentFocusedEditor?.insertText?.(leader.char); + } + } + return; + } } }