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 @@ -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.ts" }` — the loader checks `./tui`, not `.`.
- The plugin `package.json` needs `exports: { "./tui": "./src/index.tsx" }` — 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.
- **No external runtime imports in distributed plugins.** OpenCode's Bun runtime module plugin (`onResolve` hooks for `solid-js`, `@opentui/solid`, etc.) doesn't intercept imports from files loaded from `~/.cache/opencode/packages/`. Any import from `solid-js` or `@opentui/solid` fails with `Cannot find module`. Use only the `api` parameter and local modules. Mode feedback uses `api.ui.toast()` instead of a slot indicator. This limitation affects all git/npm-installed TUI plugins, not just vimcode.
- **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.

### Editor widget API

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

```
src/
index.ts (229 lines) Plugin entry: intercept registration, action application
index.tsx (291 lines) Plugin entry: intercept registration, action application, slot UI
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 Expand Up @@ -148,6 +148,8 @@ Branch naming: `type/description` — e.g. `feat/replace-char`, `fix/escape-hand

**Prefer discriminated unions.** The `Action` type uses `{ type: "cmd" } | { type: "mode" } | ...` so consumers can exhaustively switch on `action.type`. Add new action types when handlers need new kinds of side effects.

**Every mode transition emits a `mode` action.** The `Mode` type is the single source of truth for all displayable modes — including transient states like `"(insert)"` (one-shot normal). Never represent a mode as a separate boolean flag with a toast side-channel. If something changes what mode the user is in, it goes through the `Mode` type and a `{ type: "mode" }` action.

**Keep `vim.ts` under 500 lines.** If it grows past that, split by concern (motions, operators, insert entries). The handlers are already structured with clear sections — those become natural file boundaries.

**Shifted key translation** happens in `translateKey()` before the handler sees the key. Handlers work with normalized keys (`$` not `shift+4`, `G` not `shift+g`). Add new shift mappings in `translateKey`, not in handlers.
Expand Down
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Version

## [Unreleased]

## [0.12.0] — 2026-06-08

### Added

- Persistent mode indicator next to the prompt ([#3](https://github.com/oribarilan/vimcode/issues/3)). Shows NORMAL, INSERT, VISUAL, or (insert) for one-shot normal. Replaces the old toast, which disappeared after a second.
- `modeIndicator` option: `"status"` (default, persistent label), `"toast"` (old behavior), or `"none"` (disabled). The old `modeToast` option still works as a fallback.

### Changed

- Plugin entry point is now `.tsx` (was `.ts`) to support SolidJS slot rendering.

## [0.11.0] — 2026-06-08

### Added
Expand Down Expand Up @@ -231,7 +242,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.11.0...HEAD
[Unreleased]: https://github.com/oribarilan/vimcode/compare/v0.12.0...HEAD
[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
[0.10.0]: https://github.com/oribarilan/vimcode/compare/v0.9.0...v0.10.0
[0.9.0]: https://github.com/oribarilan/vimcode/compare/v0.8.0...v0.9.0
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Releases are manual.
4. Update link references at the bottom of CHANGELOG.md.
5. Bump version in `package.json` (`npm version X.Y.Z --no-git-tag-version`).
6. Bump `VERSION` in `src/version.ts` to match.
7. Update the version tag in `README.md`'s install snippet.
7. Update **all** version tags in `README.md` — the install snippet and the config example both reference a specific version.
8. Run `just check`.
9. Open a PR with the release changes. Title: `Release vX.Y.Z: <one-line summary>`.
10. After CI passes, squash-merge the PR.
Expand All @@ -86,4 +86,4 @@ Bare names (like `"vimcode"`) trigger npm resolution, which won't work since the

## Architecture

`src/vim.ts` owns all key handling (pure functions). `src/index.ts` owns all OpenCode API interaction. See `AGENTS.md` for the full architecture guide.
`src/vim.ts` owns all key handling (pure functions). `src/index.tsx` owns all OpenCode API interaction. See `AGENTS.md` for the full architecture guide.
9 changes: 4 additions & 5 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.11.0"]
"plugin": ["vimcode@git+https://github.com/oribarilan/vimcode.git#v0.12.0"]
}
```

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.9.0", { "updateCheck": false }]]
"plugin": [["vimcode@git+https://github.com/oribarilan/vimcode.git#v0.12.0", { "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. |
| `modeToast` | `boolean` | `true` | Show a brief toast ("NORMAL" / "INSERT" / "VISUAL") on mode switches. Set to `false` to rely on cursor shape alone. |
| `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. |
| `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. A brief toast shows the current mode on each switch (configurable).
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).

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,7 +163,6 @@ 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. Cursor shape is the persistent signal, but a status bar indicator would need the host's SolidJS runtime, which external plugins can't access.

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

Expand Down
2 changes: 2 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[test]
preload = ["./test/preload.ts"]
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "vimcode",
"version": "0.11.0",
"version": "0.12.0",
"description": "Vim keybindings for the OpenCode prompt",
"author": "Ori Bar-ilan",
"license": "MIT",
Expand All @@ -16,7 +16,7 @@
"plugin"
],
"exports": {
"./tui": "./src/index.ts"
"./tui": "./src/index.tsx"
},
"files": [
"src/"
Expand All @@ -31,5 +31,11 @@
"@biomejs/biome": "2.4.15",
"@opencode-ai/plugin": "^1.15.4",
"tsx": "^4.22.1"
},
"peerDependencies": {
"@opencode-ai/plugin": "*",
"@opentui/core": "*",
"@opentui/solid": "*",
"solid-js": "*"
}
}
51 changes: 50 additions & 1 deletion src/index.ts → src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
// @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 @@ -8,6 +11,7 @@ import {
handleInsertKey,
handleNormalKey,
handleVisualKey,
type Mode,
matchesLeader,
parseLeaderKey,
translateKey,
Expand All @@ -21,6 +25,50 @@ const plugin: TuiPluginModule = {
state.mode = startMode;
const leader = options?.leader ? parseLeaderKey(options.leader) : null;

// Resolve modeIndicator: "status" (default), "toast", 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"
? 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>;
},
},
});
}

// Track whether the previous key was the leader, so the follow-up
// key also passes through to OpenCode's leader system.
let leaderPending = false;
Expand Down Expand Up @@ -57,7 +105,8 @@ const plugin: TuiPluginModule = {
setTimeout(() => api.keymap.dispatchCommand(action.cmd), 0);
break;
case "mode":
if (options?.modeToast !== false) {
setMode(action.mode);
if (modeIndicator === "toast") {
api.ui?.toast?.({ message: action.mode.toUpperCase(), variant: "info", duration: 800 });
}
break;
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.11.0";
export const VERSION = "0.12.0";

// GitHub API returns fresh content immediately; raw.githubusercontent.com
// is CDN-cached for up to 5 minutes which delays update detection.
Expand Down
4 changes: 2 additions & 2 deletions src/vim.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type Mode = "normal" | "insert" | "visual";
export type Mode = "normal" | "insert" | "visual" | "(insert)";
export type Operator = "d" | "c" | "y" | null;

export type Action =
Expand Down Expand Up @@ -193,7 +193,7 @@ export function handleInsertKey(
if (ev.name === "o" && ev.ctrl) {
state.mode = "normal";
state.oneShotNormal = true;
return { consume: true, actions: [{ type: "toast", message: "(insert)", duration: 800 }] };
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.
Expand Down
34 changes: 34 additions & 0 deletions test/preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Preload: mock the @opentui/solid JSX runtime for tests.
// The real runtime is injected by the OpenCode host at runtime.
import { mock } from "bun:test";

mock.module("@opentui/solid/jsx-runtime", () => ({
jsx: (_type: string, props: Record<string, unknown>) => ({ type: _type, props }),
jsxs: (_type: string, props: Record<string, unknown>) => ({ type: _type, props }),
jsxDEV: (_type: string, props: Record<string, unknown>) => ({ type: _type, props }),
Fragment: (props: { children?: unknown }) => props.children,
}));

mock.module("@opentui/solid/jsx-dev-runtime", () => ({
jsx: (_type: string, props: Record<string, unknown>) => ({ type: _type, props }),
jsxs: (_type: string, props: Record<string, unknown>) => ({ type: _type, props }),
jsxDEV: (_type: string, props: Record<string, unknown>) => ({ type: _type, props }),
Fragment: (props: { children?: unknown }) => props.children,
}));

mock.module("solid-js", () => ({
createSignal: <T>(init: T) => {
let value = init;
const getter = () => value;
const setter = (v: T | ((prev: T) => T)) => {
value = typeof v === "function" ? (v as (prev: T) => T)(value) : v;
};
return [getter, setter] as const;
},
createMemo: <T>(fn: () => T) => fn,
createEffect: () => {},
onCleanup: () => {},
onMount: (fn: () => void) => fn(),
Show: (props: { when: unknown; children: unknown }) => props.children,
For: (props: { each: unknown[]; children: unknown }) => props.children,
}));
6 changes: 3 additions & 3 deletions test/vim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,12 +279,12 @@ describe("handleInsertKey", () => {
expect(r.consume).toBe(true);
expect(state.mode).toBe("normal");
expect(state.oneShotNormal).toBe(true);
expect(r.actions).toContainEqual({ type: "toast", message: "(insert)", duration: 800 });
expect(r.actions).toContainEqual({ type: "mode", mode: "(insert)" });
});

it("ctrl+o does not emit a mode action", () => {
it("ctrl+o emits (insert) mode action", () => {
const r = handleInsertKey(state, "o", ev("o", { ctrl: true }));
expect(r.actions.some((a) => a.type === "mode")).toBe(false);
expect(r.actions.some((a) => a.type === "mode" && a.mode === "(insert)")).toBe(true);
});
});

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

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

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