From 904e54ed6136cfb86fa888c1ab74638d1b0622ee Mon Sep 17 00:00:00 2001 From: ori Date: Mon, 8 Jun 2026 17:12:49 +0300 Subject: [PATCH] feat: persistent mode indicator via SolidJS slot Replaces the ephemeral toast with a persistent label next to the prompt. Shows NORMAL, INSERT, VISUAL, and (insert) for one-shot normal. New modeIndicator option: "status" (default), "toast", "none". Old modeToast option still works as fallback. --- AGENTS.md | 8 +++--- CHANGELOG.md | 14 +++++++++- CONTRIBUTING.md | 4 +-- README.md | 9 +++---- bunfig.toml | 2 ++ package.json | 10 ++++++-- src/{index.ts => index.tsx} | 51 ++++++++++++++++++++++++++++++++++++- src/version.ts | 2 +- src/vim.ts | 4 +-- test/preload.ts | 34 +++++++++++++++++++++++++ test/vim.test.ts | 6 ++--- tsconfig.json | 3 +++ 12 files changed, 127 insertions(+), 20 deletions(-) create mode 100644 bunfig.toml rename src/{index.ts => index.tsx} (82%) create mode 100644 test/preload.ts diff --git a/AGENTS.md b/AGENTS.md index ced19a6..3cb8d5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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) @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index ee1b238..c04ebe0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d10aba..04fd034 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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: `. 10. After CI passes, squash-merge the PR. @@ -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. diff --git a/README.md b/README.md index 3c43a61..3d3384c 100644 --- a/README.md +++ b/README.md @@ -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"] } ``` @@ -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. @@ -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. diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..786a377 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[test] +preload = ["./test/preload.ts"] diff --git a/package.json b/package.json index 8d7fde9..84421e3 100644 --- a/package.json +++ b/package.json @@ -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", @@ -16,7 +16,7 @@ "plugin" ], "exports": { - "./tui": "./src/index.ts" + "./tui": "./src/index.tsx" }, "files": [ "src/" @@ -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": "*" } } diff --git a/src/index.ts b/src/index.tsx similarity index 82% rename from src/index.ts rename to src/index.tsx index 50c1b83..bf4310d 100644 --- a/src/index.ts +++ b/src/index.tsx @@ -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 { @@ -8,6 +11,7 @@ import { handleInsertKey, handleNormalKey, handleVisualKey, + type Mode, matchesLeader, parseLeaderKey, translateKey, @@ -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(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 {label}; + }, + // 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 {label}; + }, + }, + }); + } + // Track whether the previous key was the leader, so the follow-up // key also passes through to OpenCode's leader system. let leaderPending = false; @@ -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; diff --git a/src/version.ts b/src/version.ts index c7f8994..0871abc 100644 --- a/src/version.ts +++ b/src/version.ts @@ -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. diff --git a/src/vim.ts b/src/vim.ts index 3d52f00..ad56a09 100644 --- a/src/vim.ts +++ b/src/vim.ts @@ -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 = @@ -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. diff --git a/test/preload.ts b/test/preload.ts new file mode 100644 index 0000000..befc928 --- /dev/null +++ b/test/preload.ts @@ -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) => ({ type: _type, props }), + jsxs: (_type: string, props: Record) => ({ type: _type, props }), + jsxDEV: (_type: string, props: Record) => ({ type: _type, props }), + Fragment: (props: { children?: unknown }) => props.children, +})); + +mock.module("@opentui/solid/jsx-dev-runtime", () => ({ + jsx: (_type: string, props: Record) => ({ type: _type, props }), + jsxs: (_type: string, props: Record) => ({ type: _type, props }), + jsxDEV: (_type: string, props: Record) => ({ type: _type, props }), + Fragment: (props: { children?: unknown }) => props.children, +})); + +mock.module("solid-js", () => ({ + createSignal: (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: (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, +})); diff --git a/test/vim.test.ts b/test/vim.test.ts index ad7ab0d..6947074 100644 --- a/test/vim.test.ts +++ b/test/vim.test.ts @@ -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); }); }); diff --git a/tsconfig.json b/tsconfig.json index a69f561..ea941d6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,9 @@ "module": "ESNext", "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "@opentui/solid", + "strict": true, "esModuleInterop": true, "skipLibCheck": true,