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 imports do NOT work in git-installed plugins. The host's
ensureRuntimePluginSupportinterceptssolid-jsand@opentui/solidimports, 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.tsstubs that shadow the host's runtime modules. Until OpenCode fixes this, avoid JSX andsolid-jsimports in distributed plugins. Useapi.ui.toast()for mode feedback instead of slot indicators. - 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).
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 (357 lines) Plugin entry: intercept registration, action application
vim.ts (645 lines) Pure vim engine: state, handlers, command tables, types
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/
vim.test.ts (1434 lines) Characterization tests for all key handling branches
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, pendingOp, pendingChar, mode) dispatches commands via setTimeout
Handlers in vim.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.
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
vim.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
test/vim.test.ts: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: MOTIONS maps key → standalone cursor command, DELETE_MOTION maps key → destructive command. When pendingOp is set and a motion key arrives, handleNormalKey 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
pendingOp && key in MOTIONSsection.
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. vim.ts owns all key handling logic and state transitions. index.ts owns all OpenCode API interaction. clipboard.ts owns platform I/O. Don't mix these concerns.
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 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.
TypeScript strictness. strict: true in tsconfig. No any in vim.ts 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.