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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
31 changes: 29 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
handleNormalKey,
handleVisualKey,
matchesLeader,
type ParsedLeader,
parseLeaderKey,
translateKey,
} from "./vim";
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
}
}

Expand Down
Loading