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
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ 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.tsx" }` — the loader checks `./tui`, not `.`.
- 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.
- `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.
- **SolidJS imports work in distributed plugins** as of mid-2026. The plugin uses top-level `import { createSignal } from "solid-js"` and `@jsxImportSource @opentui/solid`. This requires `"type": "module"` in package.json and `solid-js`/`@opentui/solid`/`@opentui/core` as peer dependencies. Tests need a preload (`test/preload.ts`) to mock the JSX runtime since it's only available inside the OpenCode host.
- **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).

### Editor widget API
Expand Down Expand Up @@ -54,7 +54,7 @@ This API surface makes text objects (`ciw`, `di"`), direct cursor manipulation,

```
src/
index.tsx (291 lines) Plugin entry: intercept registration, action application, slot UI
index.ts (242 lines) Plugin entry: intercept registration, action application
vim.ts (631 lines) Pure vim engine: state, handlers, command tables, types
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)
Expand Down
14 changes: 10 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version

## [Unreleased]

## [0.12.1] — 2026-06-08
## [0.12.2] — 2026-06-08

### Fixed
### Reverted

- Persistent mode indicator removed. The SolidJS slot approach doesn't work from git-installed plugins — the host's JSX runtime can't be resolved from the package cache. Back to toast-only for now ([#3](https://github.com/oribarilan/vimcode/issues/3)).

### Changed

- Plugin failed to load when installed via git URL. Peer dependencies (`solid-js`, `@opentui/solid`, `@opentui/core`) were getting installed into the plugin's `node_modules/`, and their `.d.ts` stubs shadowed the host's runtime modules. Removed peer deps — the host provides these at runtime.
- `modeIndicator` option now accepts `"toast"` (default) or `"none"`. The `"status"` value from v0.12.0 is gone until the host resolves JSX for external plugins.
- `Ctrl+O` one-shot normal now emits a proper `mode` action instead of a toast side-channel. The toast shows `(insert)` in lowercase, matching Vim convention.

## [0.12.0] — 2026-06-08

Expand Down Expand Up @@ -248,7 +253,8 @@ First release. Modal editing for the OpenCode prompt.

> `g` fires immediately as buffer-home instead of waiting for `gg`. The `yy` line tracker drifts on clicks and arrow keys. Visual mode and text objects aren't feasible without cursor position access.

[Unreleased]: https://github.com/oribarilan/vimcode/compare/v0.12.1...HEAD
[Unreleased]: https://github.com/oribarilan/vimcode/compare/v0.12.2...HEAD
[0.12.2]: https://github.com/oribarilan/vimcode/compare/v0.12.1...v0.12.2
[0.12.1]: https://github.com/oribarilan/vimcode/compare/v0.12.0...v0.12.1
[0.12.0]: https://github.com/oribarilan/vimcode/compare/v0.11.0...v0.12.0
[0.11.0]: https://github.com/oribarilan/vimcode/compare/v0.10.0...v0.11.0
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Add to your `tui.json` (or `.opencode/tui.json`):

```json
{
"plugin": ["vimcode@git+https://github.com/oribarilan/vimcode.git#v0.12.1"]
"plugin": ["vimcode@git+https://github.com/oribarilan/vimcode.git#v0.12.2"]
}
```

Expand All @@ -40,20 +40,20 @@ To pass options, use the tuple form in `tui.json`:

```json
{
"plugin": [["vimcode@git+https://github.com/oribarilan/vimcode.git#v0.12.1", { "updateCheck": false }]]
"plugin": [["vimcode@git+https://github.com/oribarilan/vimcode.git#v0.12.2", { "updateCheck": false }]]
}
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `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` | `"status"` \| `"toast"` \| `"none"` | `"status"` | How to show the current mode. `"status"` shows a persistent label next to the prompt. `"toast"` flashes a brief notification on each switch (old behavior). `"none"` disables it, relying on cursor shape alone. |
| `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

Adds normal/insert mode to OpenCode's prompt input. Escape enters normal mode, `i` goes back to insert. The current mode shows as a persistent label next to the prompt (configurable).
Adds normal/insert mode to OpenCode's prompt input. Escape enters normal mode, `i` goes back to insert. A brief toast shows the current mode on each switch (configurable).

In insert mode, typing works normally. Enter adds a newline, Ctrl+Enter submits. The file picker and autocomplete keep working: Enter picks the selected item, Escape closes the picker without leaving insert.

Expand Down Expand Up @@ -163,6 +163,7 @@ All normal-mode motions work for extending the selection: `h` `j` `k` `l` `w` `b

- `V`, `Ctrl+v` - only character-wise visual mode (`v`) is supported, no line-wise or block
- `ciw`, `di"`, etc. (text objects) - not yet implemented
- No persistent mode indicator - the toast fades after about a second. A slot-based indicator needs the host's JSX runtime, which doesn't resolve reliably from git-installed plugins ([#3](https://github.com/oribarilan/vimcode/issues/3)).

Configurable key bindings are next once the core vim coverage stabilizes.

Expand Down
2 changes: 0 additions & 2 deletions bunfig.toml

This file was deleted.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vimcode",
"version": "0.12.1",
"version": "0.12.2",
"description": "Vim keybindings for the OpenCode prompt",
"author": "Ori Bar-ilan",
"license": "MIT",
Expand All @@ -16,7 +16,7 @@
"plugin"
],
"exports": {
"./tui": "./src/index.tsx"
"./tui": "./src/index.ts"
},
"files": [
"src/"
Expand Down
50 changes: 6 additions & 44 deletions src/index.tsx → src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
// @ts-nocheck
/** @jsxImportSource @opentui/solid */
import type { TuiPluginModule } from "@opencode-ai/plugin/tui";
import { createSignal } from "solid-js";
import { writeClipboard } from "./clipboard";
import { checkForUpdate } from "./version";
import {
Expand All @@ -11,7 +8,6 @@ import {
handleInsertKey,
handleNormalKey,
handleVisualKey,
type Mode,
matchesLeader,
parseLeaderKey,
translateKey,
Expand All @@ -25,49 +21,15 @@ const plugin: TuiPluginModule = {
state.mode = startMode;
const leader = options?.leader ? parseLeaderKey(options.leader) : null;

// Resolve modeIndicator: "status" (default), "toast", or "none".
// Resolve modeIndicator: "toast" (default) or "none".
// Backward compat: modeToast:false maps to "none", but only if
// modeIndicator isn't explicitly set.
const modeIndicator: "status" | "toast" | "none" =
options?.modeIndicator === "status" || options?.modeIndicator === "toast" || options?.modeIndicator === "none"
const modeIndicator: "toast" | "none" =
options?.modeIndicator === "toast" || options?.modeIndicator === "none"
? options.modeIndicator
: options?.modeToast === false
? "none"
: "status";

// Reactive signal for mode — drives the badge indicator.
const [mode, setMode] = createSignal<Mode>(startMode);

if (modeIndicator === "status") {
api.slots?.register?.({
slots: {
// biome-ignore lint/suspicious/noExplicitAny: slot context type comes from host
session_prompt_right(ctx: any) {
const m = mode();
const color =
m === "normal"
? ctx.theme.current.warning
: m === "visual"
? ctx.theme.current.primary
: ctx.theme.current.textMuted;
const label = m === "(insert)" ? m : m.toUpperCase();
return <text fg={color}>{label}</text>;
},
// biome-ignore lint/suspicious/noExplicitAny: slot context type comes from host
home_prompt_right(ctx: any) {
const m = mode();
const color =
m === "normal"
? ctx.theme.current.warning
: m === "visual"
? ctx.theme.current.primary
: ctx.theme.current.textMuted;
const label = m === "(insert)" ? m : m.toUpperCase();
return <text fg={color}>{label}</text>;
},
},
});
}
: "toast";

// Track whether the previous key was the leader, so the follow-up
// key also passes through to OpenCode's leader system.
Expand Down Expand Up @@ -105,9 +67,9 @@ const plugin: TuiPluginModule = {
setTimeout(() => api.keymap.dispatchCommand(action.cmd), 0);
break;
case "mode":
setMode(action.mode);
if (modeIndicator === "toast") {
api.ui?.toast?.({ message: action.mode.toUpperCase(), variant: "info", duration: 800 });
const label = action.mode === "(insert)" ? action.mode : action.mode.toUpperCase();
api.ui?.toast?.({ message: label, variant: "info", duration: 800 });
}
break;
case "toast":
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Keep in sync with package.json on each release.
export const VERSION = "0.12.1";
export const VERSION = "0.12.2";

// GitHub API returns fresh content immediately; raw.githubusercontent.com
// is CDN-cached for up to 5 minutes which delays update detection.
Expand Down
34 changes: 0 additions & 34 deletions test/preload.ts

This file was deleted.

3 changes: 0 additions & 3 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
"module": "ESNext",
"moduleResolution": "bundler",

"jsx": "preserve",
"jsxImportSource": "@opentui/solid",

"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
Expand Down
Loading