Skip to content

Latest commit

 

History

History
205 lines (156 loc) · 18.2 KB

File metadata and controls

205 lines (156 loc) · 18.2 KB

AGENTS.md

OpenCode Plugin Development

vimcode is a TUI plugin for OpenCode. Before working on it, understand the plugin system:

References (read these, don't guess):

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, not opencode.json. The config field is "plugin".
  • The plugin package.json needs exports: { "./tui": "./src/index.ts" } — the loader checks ./tui, not ..
  • key:before is 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 a key 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.
  • Leader key is handled entirely within the keymap's dispatchLayers(). There is no separate useKeyboard handler for it. registerTimedLeader registers a token; dispatchLayers() matches it; getPendingSequence() exposes the state. Calling ctx.consume() in a key intercept sets event.propagationStopped, which the keymap checks after each intercept — if set, it skips dispatchLayers() 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 to dispatchLayers() and trigger OpenCode's leader bindings.
  • api.tuiConfig.keybinds gives access to OpenCode's resolved keybind config. api.tuiConfig.keybinds.get("leader")?.[0]?.key returns the configured leader key. Used by resolveLeader() 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 .ts entry and its TUI hook loaded, but importing a .tsx module with /** @jsxImportSource @opentui/solid */ failed with Cannot find module '@opentui/solid/jsx-dev-runtime'. The Solid transform excludes files under node_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 and solid-js imports in distributed plugins. Use api.ui.toast() for mode feedback instead of slot indicators. See #3.
  • 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).
  • Test distributed plugin behavior through the package cache. dev-tui.json uses "plugin": ["."], which loads from the working tree and does not reproduce cache-only module resolution failures. Use an npm-source tarball spec such as name@file:/absolute/path/package.tgz or the real git+https://...#ref install form, and clear only that package's cache entry before retesting.

Editor widget API

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 writable
  • visualCursor: { 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 needed
  • plainText: string — buffer content
  • selectionBg: RGBA, selectionFg: RGBA — custom selection highlight colors

Top-level methods:

  • moveCursorLeft/Right/Up/Down() — direct cursor movement
  • setSelection(start, end), setSelectionInclusive(start, end), clearSelection() — selection control
  • gotoVisualLineEnd(), gotoLineEnd() — line boundary jumps
  • insertText(text) — insert at cursor

editorView methods (lower-level):

  • setCursorByOffset(n) — position cursor by offset
  • getNextWordBoundary(), getPrevWordBoundary() — word boundary detection (enables proper e vs w)
  • getEOL(), getVisualSOL(), getVisualEOL() — line boundary info
  • getLineInfo(), getLogicalLineInfo() — line metadata
  • getCursor(), getVisualCursor(), getText() — read state
  • getSelectedText(), deleteSelectedText() — selection operations
  • moveUpVisual(), moveDownVisual() — visual line movement
  • setSelection(), 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.

Architecture

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 via setTimeout(() => 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 via writeClipboard()
  • { type: "insertText", text: string } — inserts text at cursor via editor.insertText()
  • { type: "yankSelection" } — reads selected text from the focused editor, stores in yank register and clipboard
  • { type: "clearSelection" } — clears the textarea's selection via editorView.resetSelection()
  • { type: "cursorTo", offset: number } — sets editor.cursorOffset directly
  • { type: "selectRange", start: number, end: number } — calls editor.setSelectionInclusive(start, end)
  • { type: "deleteRange", start: number, end: number } — deletes text between inclusive offsets via editBuffer.deleteRange(). Saves a snapshot for single-step undo (see below).
  • { type: "undo" } — if an undo snapshot exists (from a deleteRange), restores the full buffer from it. Otherwise falls back to dispatchCommand("input.undo").

Adding a keybinding

  1. In src/vim/normal.ts, find the right section in handleNormalKey() (motions, operators, special keys, insert entries)
  2. Add the key check and return appropriate actions:
    if (key === "yourkey") {
      return { consume: true, actions: [{ type: "cmd", cmd: "input.some.command" }] }
    }
  3. Add a test in the matching test/vim/*.test.ts (e.g. test/vim/normal.test.ts), importing helpers from ../support and fixtures from ../fixtures:
    it("yourkey dispatches some.command", () => {
      const result = handleNormalKey(state, "yourkey", ev("yourkey"), mockPrompt)
      expect(cmds(result.actions)).toEqual(["input.some.command"])
    })
  4. Run bun test, then just dev to verify in OpenCode.

Adding an operator+motion combo

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:

  1. Add the standalone motion to MOTIONS: { "yourkey": "input.move.whatever" }
  2. Add the destructive version to DELETE_MOTION: { "yourkey": "input.delete.whatever" }
  3. 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 MOTIONS section.

Adding a text object

Text objects (iw/aw, the quote/bracket pairs, and the iq/ib aliases) route through one seam, so the handlers never change:

  1. 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 inclusive Range or null when there's nothing to select.
  2. Add a case to resolveTextObject in src/vim/textobject.ts mapping the object char to that algorithm. This is the only dispatch point — the "any quote" (qanyQuoteRange) and "any bracket" (banyBracketRange) aliases compose over the per-delimiter functions, picking the tightest enclosing pair.
  3. No handler change needed. normal.ts (operator + i/a → textobject pending → deleteRange/yank/insert) and visual.ts (i/a → textobject pending → selectRange) already send every object char through resolveTextObject.
  4. Test the range algorithm in test/vim/text.test.ts and the dispatch in test/vim/textobject.test.ts; add handler branches in normal.test.ts/visual.test.ts only if the object needs new handling.

Known limitations

  • setTimeout dispatch — commands are deferred to avoid re-entrancy. Multi-command sequences (like O = 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., setting cursorOffset, calling insertText).
  • editBuffer undo granularity — the host editor's undo system splits multi-line deletions into per-line entries. Operations that use deleteRange (like dG, de) work around this by saving a pre-operation snapshot and restoring from it on u. The snapshot is invalidated when any other buffer-modifying action runs (cmd or insertText).

Development

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.

Git Workflow

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).

Code Conventions

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.

Task Completion Checklist

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.