vimcode is a TUI plugin for OpenCode. Before working on it, understand the plugin system:
References (read these, don't guess):
- Official plugin docs: https://opencode.ai/docs/plugins/
- TUI plugin spec: https://github.com/sst/opencode/blob/dev/packages/opencode/specs/tui-plugins.md
- Plugin types:
@opencode-ai/plugin/tuiexportsTuiPluginModule,TuiPluginApi - A good reference TUI plugin with slots/keymap/routes: opencode-workspaces
Plugin API surface (api: TuiPluginApi):
keymap (register layers, intercepts, dispatch commands), slots (register UI into named slots), ui (toasts, dialogs), theme (colors), prompt (read/write prompt text), state (session, config), client (SDK), lifecycle (disposal), kv (persistent storage), route (custom screens).
Gotchas we hit during development:
- TUI plugins go in
tui.json, notopencode.json. The config field is"plugin". - The plugin
package.jsonneedsexports: { "./tui": "./src/index.ts" }— the loader checks./tui, not.. key:beforeis 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 akeyintercept doesn't work for cursor movement. Wrap insetTimeout(..., 0)to break out of the intercept stack.registerLayerwithactiveWhenusing SolidJS signals requiresreactiveMatcherFromSignalfrom@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 separateuseKeyboardhandler for it.registerTimedLeaderregisters a token;dispatchLayers()matches it;getPendingSequence()exposes the state. Callingctx.consume()in akeyintercept setsevent.propagationStopped, which the keymap checks after each intercept — if set, it skipsdispatchLayers()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 todispatchLayers()and trigger OpenCode's leader bindings. api.tuiConfig.keybindsgives access to OpenCode's resolved keybind config.api.tuiConfig.keybinds.get("leader")?.[0]?.keyreturns the configured leader key. Used byresolveLeader()to auto-detect the leader without requiring a separate plugin option.- SolidJS/JSX still does not work in cache-installed plugins. Last reproduced on 2026-09-08 with OpenCode 1.18.21 using an npm-source tarball. A plain
.tsentry and its TUI hook loaded, but importing a.tsxmodule with/** @jsxImportSource @opentui/solid */failed withCannot find module '@opentui/solid/jsx-dev-runtime'. The Solid transform excludes files undernode_modules; the runtime prescan therefore cannot see the JSX-generated import before Bun resolves it. OpenCode 1.18.25 has identical relevant runtime code and also pins OpenTUI 0.4.5. OpenTUI 0.5.9 retains the exclusion. Until upstream changes this path, avoid JSX andsolid-jsimports in distributed plugins. Useapi.ui.toast()for mode feedback instead of slot indicators. See #3. - Do NOT add
solid-js,@opentui/solid, or@opentui/coreas dependencies or peerDependencies. If they're inpackage.json, Bun installs them into the plugin'snode_modules/, and the local.d.tsstubs shadow the host's runtime module intercepts. The host provides these at runtime viaensureRuntimePluginSupport. Keep them only indevDependencies(via@opencode-ai/pluginwhich pulls them in for type-checking). - Test distributed plugin behavior through the package cache.
dev-tui.jsonuses"plugin": ["."], which loads from the working tree and does not reproduce cache-only module resolution failures. Use an npm-source tarball spec such asname@file:/absolute/path/package.tgzor the realgit+https://...#refinstall form, and clear only that package's cache entry before retesting.
api.renderer.currentFocusedEditor (same object as currentFocusedRenderable) exposes the underlying Textarea widget. Not part of the documented plugin API, but stable and available at runtime. The codebase currently uses plainText, cursorOffset, visualCursor, cursorStyle, insertText(), and editorView. The rest of the surface below is available but unused.
Top-level properties (read/write):
cursorOffset: number— absolute cursor position, readable and writablevisualCursor: { visualRow, visualCol, logicalRow, logicalCol, offset }— full cursor coordinates (read-only in practice)cursorStyle: { style: "block" | "line" | "underline" | "default", blinking: boolean }— set directly, no DECSCUSR escape neededplainText: string— buffer contentselectionBg: RGBA,selectionFg: RGBA— custom selection highlight colors
Top-level methods:
moveCursorLeft/Right/Up/Down()— direct cursor movementsetSelection(start, end),setSelectionInclusive(start, end),clearSelection()— selection controlgotoVisualLineEnd(),gotoLineEnd()— line boundary jumpsinsertText(text)— insert at cursor
editorView methods (lower-level):
setCursorByOffset(n)— position cursor by offsetgetNextWordBoundary(),getPrevWordBoundary()— word boundary detection (enables properevsw)getEOL(),getVisualSOL(),getVisualEOL()— line boundary infogetLineInfo(),getLogicalLineInfo()— line metadatagetCursor(),getVisualCursor(),getText()— read stategetSelectedText(),deleteSelectedText()— selection operationsmoveUpVisual(),moveDownVisual()— visual line movementsetSelection(),resetSelection(),hasSelection()— selection management
This API surface makes text objects (ciw, di"), direct cursor manipulation, and accurate line operations feasible. The current setTimeout + dispatchCommand approach can be replaced with direct widget manipulation for most operations.
src/
index.ts (408 lines) Plugin entry: intercept registration, action application
vim/ Pure vim engine (thin barrel re-exports the public surface):
index.ts (7 lines) Barrel — public surface only. No export *, no internals.
types.ts (57 lines) Action union, VimState, Mode, Operator, Pending, Range, KeyEvent, HandlerResult, PromptAccess
text.ts (210 lines) Pure string algorithms: charKind, endOfWord, currentLineRange, wordRange, bracketRange, quoteRange, anyBracketRange, anyQuoteRange
tables.ts (35 lines) Keybinding maps: MOTIONS, SELECT_MOTIONS, DELETE_MOTION (engine-internal)
textobject.ts (36 lines) resolveTextObject — object char → inclusive Range seam (iw/aw, quote/bracket pairs, iq/ib)
util.ts (19 lines) State-agnostic primitives: translateKey, PASS, pushN
state.ts (76 lines) VimState lifecycle + transitions
insert.ts (32 lines) handleInsertKey
normal.ts (378 lines) handleNormalKey (+ file-local finishUndoableChange, applyOperatorRange, isInputEmpty)
visual.ts (104 lines) handleVisualKey
leader.ts (73 lines) Leader key matching: matchesKeyLike, findMatchingLeader, leaderChar
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)
test/
support.ts (33 lines) Shared assertion helpers + ev()
fixtures.ts (17 lines) Prompt fixtures: mockPrompt, emptyPrompt
vim/ Per-module engine tests mirroring src/vim/:
text.test.ts (385) endOfWord, charKind, currentLineRange, wordRange, bracketRange, quoteRange, any* units
state.test.ts (70) createVimState, toggleVimMode
util.test.ts (31) translateKey
insert.test.ts (92) handleInsertKey
normal.test.ts (823) handleNormalKey branches
visual.test.ts (287) handleVisualKey branches
textobject.test.ts (64) resolveTextObject dispatch seam
integration.test.ts (418) Full pipeline: one-shot normal, plugin init, undo snapshots, version sync
leader.test.ts (125 lines) Unit tests for leader key matching functions
Data flow:
KeyEvent → translateKey() → handleInsertKey/handleNormalKey/handleVisualKey() → HandlerResult { consume, actions[] }
↓ ↓
mutates VimState applyActions() in index.ts
(count, pending, mode) dispatches commands via setTimeout
Handlers in src/vim/ (insert.ts, normal.ts, visual.ts) are pure — they take state + key + event, mutate state, return actions. They never touch api. The only file that calls api.keymap.dispatchCommand is index.ts. src/vim/index.ts is a strict barrel: it re-exports only the public surface (no export *, no internal helpers), and sibling modules import each other directly (./types, ./state, …) never through the barrel.
Action types:
{ type: "cmd", cmd: string }— dispatched viasetTimeout(() => api.keymap.dispatchCommand(cmd), 0){ type: "mode", mode: Mode }— updates the SolidJS signal for the indicator{ type: "toast", message: string }— shows a notification{ type: "yank", text: string }— writes text to system clipboard viawriteClipboard(){ type: "insertText", text: string }— inserts text at cursor viaeditor.insertText(){ type: "yankSelection" }— reads selected text from the focused editor, stores in yank register and clipboard{ type: "clearSelection" }— clears the textarea's selection viaeditorView.resetSelection(){ type: "cursorTo", offset: number }— setseditor.cursorOffsetdirectly{ type: "selectRange", start: number, end: number }— callseditor.setSelectionInclusive(start, end){ type: "deleteRange", start: number, end: number }— deletes text between inclusive offsets viaeditBuffer.deleteRange(). Saves a snapshot for single-step undo (see below).{ type: "undo" }— if an undo snapshot exists (from adeleteRange), restores the full buffer from it. Otherwise falls back todispatchCommand("input.undo").
- In
src/vim/normal.ts, find the right section inhandleNormalKey()(motions, operators, special keys, insert entries) - Add the key check and return appropriate actions:
if (key === "yourkey") { return { consume: true, actions: [{ type: "cmd", cmd: "input.some.command" }] } }
- Add a test in the matching
test/vim/*.test.ts(e.g.test/vim/normal.test.ts), importing helpers from../supportand fixtures from../fixtures:it("yourkey dispatches some.command", () => { const result = handleNormalKey(state, "yourkey", ev("yourkey"), mockPrompt) expect(cmds(result.actions)).toEqual(["input.some.command"]) })
- Run
bun test, thenjust devto verify in OpenCode.
Operators (d/c/y) use two tables in src/vim/tables.ts: MOTIONS maps key → standalone cursor command, DELETE_MOTION maps key → destructive command. When an operator is pending and a motion key arrives, handleNormalKey (in src/vim/normal.ts) looks up DELETE_MOTION[key] and dispatches it.
To add a new motion that works with operators:
- Add the standalone motion to
MOTIONS:{ "yourkey": "input.move.whatever" } - Add the destructive version to
DELETE_MOTION:{ "yourkey": "input.delete.whatever" } - If the motion needs special handling with operators (like j/k which delete multiple lines), add an explicit branch in the
state.pending.kind === "operator" && key in MOTIONSsection.
Text objects (iw/aw, the quote/bracket pairs, and the iq/ib aliases) route through one seam, so the handlers never change:
- Add the pure range algorithm to
src/vim/text.ts— e.g.wordRange,bracketRange,quoteRange. It takes(text, offset, around)(plus the delimiters for pairs) and returns an inclusiveRangeornullwhen there's nothing to select. - Add a
casetoresolveTextObjectinsrc/vim/textobject.tsmapping the object char to that algorithm. This is the only dispatch point — the "any quote" (q→anyQuoteRange) and "any bracket" (b→anyBracketRange) aliases compose over the per-delimiter functions, picking the tightest enclosing pair. - No handler change needed.
normal.ts(operator +i/a→ textobject pending →deleteRange/yank/insert) andvisual.ts(i/a→ textobject pending →selectRange) already send every object char throughresolveTextObject. - Test the range algorithm in
test/vim/text.test.tsand the dispatch intest/vim/textobject.test.ts; add handler branches innormal.test.ts/visual.test.tsonly if the object needs new handling.
setTimeoutdispatch — commands are deferred to avoid re-entrancy. Multi-command sequences (likeO= home + newline + up) rely on ordered setTimeout execution, which works in practice but isn't guaranteed by spec. Many of these can now be replaced with direct widget manipulation (e.g., settingcursorOffset, callinginsertText).- editBuffer undo granularity — the host editor's undo system splits multi-line deletions into per-line entries. Operations that use
deleteRange(likedG,de) work around this by saving a pre-operation snapshot and restoring from it onu. The snapshot is invalidated when any other buffer-modifying action runs (cmdorinsertText).
just dev # Launch OpenCode with the plugin (uses OPENCODE_TUI_CONFIG=dev-tui.json)
bun test # Run characterization tests
just check # Lint + tests (used in GitHub Actions)The dev-tui.json config is picked up only by just dev. Running opencode normally in this directory does not load the plugin.
Never commit, push, or create PRs unless explicitly asked. Present the changes and wait for the human to decide when to commit.
All changes go through pull requests. Direct pushes to main are blocked. CI (just check) must pass before merge. PRs are squash-merged — the PR title becomes the commit on main.
Branch naming: type/description — e.g. feat/replace-char, fix/escape-handling. Types match commit prefixes (feat, fix, refactor, chore, test, docs).
Pure functions over side effects. Handlers return data (actions), callers apply effects. This makes the core logic testable without mocking.
No classes. Use plain objects for state (VimState), plain functions for behavior. Pass state by reference, mutate it directly. Return results as data.
Single responsibility per file. The src/vim/ engine is split by concern: types.ts (data), text.ts/tables.ts (pure algorithms + keybinding maps), util.ts/state.ts (state-agnostic primitives + VimState lifecycle), and one handler per mode (insert.ts/normal.ts/visual.ts). src/index.ts owns all OpenCode API interaction. clipboard.ts owns platform I/O. Don't mix these concerns.
Barrel firewall + import discipline. src/vim/index.ts re-exports only the public surface (explicit named re-exports, no export *, no internal helpers). Sibling modules import each other directly (./types, ./state, ./text, ./tables, ./util) and must never import from the barrel ./index — barrel-import + barrel-re-export is a circular-import trap that can hand back undefined at runtime. Nothing under src/vim/ may touch the plugin api.
Comments explain why, not what. The code should read clearly without narration. Reserve comments for non-obvious decisions (like why setTimeout is needed for dispatch, or why g doesn't wait for a second keypress).
Test every handler branch. When you add a keybinding, add a test. The test should verify what actions are returned and how state changes — not what those actions do when applied.
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 each src/vim/ module focused and under 500 lines. The engine was split out of a single vim.ts once it crossed that line; the handlers already have clear internal sections, so if one grows past 500 again, split it further by concern. The barrel (index.ts) stays a pure re-export firewall — never add logic there.
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.
TypeScript strictness. strict: true in tsconfig. No any in src/vim/ or test/. The api parameter in index.ts is typed as any because the plugin types come from peer deps that may not be installed locally — that's the one acceptable use.
Cross-platform. All code must work on macOS, Linux, and Windows. No platform-specific assumptions without a runtime process.platform check and fallbacks for other platforms.
After finishing any task, check whether these need updating:
- README.md — New keybinding? Add it to the tables. Fixed a known gap? Remove it from "Known gaps".
- CHANGELOG.md — Add the change under
[Unreleased]. Follow Keep a Changelog format. - AGENTS.md — Line counts in Architecture section, known limitations, or new patterns worth documenting.