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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ vimcode is a TUI plugin for [OpenCode](https://opencode.ai). Before working on i
- **`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.
- **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. In insert mode, printable leaders are consumed and inserted as text; non-printable leaders (ctrl+x, etc.) are not consumed, so they fall through to `dispatchLayers()` and trigger OpenCode's leader bindings.
- **`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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version

### Fixed

- Non-printable leader keys (e.g. `ctrl+x`) now work in insert mode instead of being swallowed ([#42](https://github.com/oribarilan/vimcode/issues/42)).
- Escape from insert mode now moves cursor left, matching vim.
- Clipboard now uses `wl-copy` on Wayland instead of `xclip`, which doesn't work there.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ vimcode reads OpenCode's leader key from your `tui.json` keybinds and handles it

In **normal and visual mode**, the leader key and the follow-up key pass straight through to OpenCode, so leader shortcuts (`<leader>c` for copy, etc.) work as expected.

In **insert mode**, vimcode intercepts the leader key to prevent the leader menu from popping up while you type. If the leader is a printable key like space, the character still gets inserted normally.
In **insert mode**, printable leaders (like space) insert their character. Non-printable leaders (like `ctrl+x`) pass through to OpenCode, so leader shortcuts work from any mode.

This allows, for example, to use the popular vim-style `space` leader, set it in your `tui.json`:

Expand Down
16 changes: 10 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,19 +312,23 @@ const plugin: TuiPluginModule = {
: handleNormalKey(state, key, ctx.event, prompt);
if (handlerMode === "normal") finishOneShotIfComplete(state, result);

// 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.
// In insert mode, intercept printable leaders (space, "a") so
// they insert their character instead of triggering the leader
// menu mid-typing. Non-printable leaders (ctrl+x, alt+m) fall
// through to dispatchLayers() so app-level shortcuts work
// without switching modes. 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 (ch) {
actions = [{ type: "insertText" as const, text: ch }];
consume = true;
}
}
}

Expand Down
Loading