Developing an OpenCode TUI Sidebar Plugin

OpenCode’s TUI sidebar plugins have a compile‑time seam: what you transform versus what the host transforms. Get it right and your plugin renders; miss it and you get silence.

I learned this building a plugin to show my Venice.ai balance. OpenCode’s documentation covered hook and CLI plugins but nothing substantive for sidebar plugins. Community examples shipped raw source—no build step, just exports["./tui"] → src/tui.tsx. At first I didn’t understand why.

My instinct was to keep my normal dev flow: TypeScript for safety, a clean dist/ for publishing. It took me a while to realize OpenCode does its own transpilation at runtime. Once I understood that seam—what I transform versus what the host transforms—the rest clicked.

This post is the map I built along the way.

Two Registries

OpenCode has two plugin registries. This isn’t obvious coming from hook plugins:

Plugin kindRegistered inLoaded by
Hook pluginsopencode.jsonc"plugin"main process
TUI / sidebar pluginstui.jsonc"plugin"TUI worker

Put your sidebar plugin in opencode.jsonc and nothing loads—silently. The sidebar shows built-in sections but not yours. No error, no log.

When you run opencode plugin --global <package>, OpenCode detects the TUI target and writes to tui.jsonc. If you’ve been editing opencode.jsonc because that’s where hooks live, that’s the wrong file.

The separation exists because OpenCode’s main process loads hook plugins, while the TUI worker loads sidebar plugins—different runtime contexts with different dependency graphs.

A Sidebar Plugin

A TUI plugin default-exports an object with an id and a tui function:

/** @jsxImportSource @opentui/solid */  // Required for JSX preservation

import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"

const tui: TuiPlugin = async (api: TuiPluginApi) => {
  api.slots.register({
    order: 100,  // Higher numbers render later
    slots: {
      sidebar_content() {
        return <box><text>Hello, World!</text></box>
      },
    },
  })
}

export default { id: "jcyamo.example", tui } satisfies TuiPluginModule & { id: string }
// satisfies ensures type safety without changing runtime shape

UI is Solid.js rendering to a terminal via opentui. <box>, <text>, <span> are intrinsic elements.

The Compile-Time Seam

Two transforms sit between your source and a rendered sidebar:

  1. TypeScript → JavaScript — yours.
  2. JSX → opentui runtime calls — the host’s.

If you compile JSX yourself (default tsconfig uses "jsx": "react-jsx"), tsc bakes in import { jsx } from "@opentui/solid/jsx-runtime". That import resolves to a second copy of @opentui/solid in your node_modules, with its own reconciler. Your elements attach to a tree that doesn’t exist in the host. Plugin loads, compiles, sidebar renders blank.

This isn’t a bug—it’s doing the host’s job and binding to the wrong instance.

The invariant: @opentui/* and solid-js are host-provided peers—never bundled, never compiled into a hardcoded runtime import.

Three Patterns

From existing plugins:

I wanted the dev flow and publish semantics of B.

One caveat: with preserve, every .tsx emits .jsx, not .js. If you import a JSX module with a .js specifier ("./view.js"), OpenCode’s loader (bun) resolves .js.tsx but not .js.jsx. Import fails silently.

Fix: import JSX modules with .jsx ("./view.jsx"). TypeScript maps .jsx.tsx just like .js.ts.

The Recipe

tsconfig.json

{
  "compilerOptions": {
    "jsx": "preserve",              // NOT react-jsx
    "jsxImportSource": "@opentui/solid",
    "declaration": true,
    "noEmitOnError": true,
    "outDir": "dist",
    "rootDir": "src"
    // don't set removeComments: true — strips jsxImportSource pragma
  }
}

package.json

{
  "exports": {
    "./tui": {
      "types": "./dist/tui.d.ts",
      "import": "./dist/tui.jsx"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "del-cli dist && tsc",
    "typecheck": "tsc --noEmit"
  },
  "peerDependencies": {
    "@opencode-ai/plugin": ">=1.17.0",
    "@opentui/core": ">=0.4.0",
    "@opentui/solid": ">=0.4.0",
    "solid-js": ">=1.9.0"
  }
  // mirror in devDependencies for local typecheck
}

Check opencode --version and ~/.cache/opencode/packages/ if unsure what’s installed.

Register in tui.jsonc:

// Absolute path
{ "plugin": ["/abs/path/to/your/plugin"] }

// Name + @file: (nicer)
{ "plugin": ["@you/your-plugin@file:/abs/path/to/your/plugin"] }

I recommend the second. OpenCode hands the spec to bun, which symlinks your repo live into the plugin cache. You reference the published name locally; going live is dropping the @file: suffix.

bun link doesn’t help—OpenCode installs plugins into its own cache.

The Gotchas

Where I burned time:

  1. Wrong registry. Sidebar plugins go in tui.jsonc, not opencode.jsonc. Symptom: sidebar shows built‑in sections but not yours.
  2. react-jsx renders nothing. Compiling JSX binds the wrong instance. Use preserve. Symptom: plugin loads but sidebar renders blank.
  3. The pragma vanishes. With jsx: "preserve", tsc drops the /** @jsxImportSource … */ comment if it’s the leading comment of a type‑only import. Fix: blank line between pragma and import. Keep removeComments: false.
  4. .js import specifiers break cross-module JSX. Import ./view.jsx, not ./view.js. The plugin silently never loads—symptom identical to wrong registry or react‑jsx bugs.
  5. bun link doesn’t work. OpenCode installs plugins into its own cache. Use @file: or absolute paths.

Dev Loop

Run tsc --watch. Relaunch OpenCode to reload (plugins load at startup). Toggle sidebar with <leader>b (default Ctrl-x). With @file:, dist/ rebuilds are live immediately.

Publish with npm publish. Consumers run opencode plugin --global @you/your-plugin. If you developed with @file:, going live is deleting the @file:… suffix.

Wrapping Up

The compile-time seam isn’t just a technical detail—it’s the line between your code and the host’s runtime. Draw it in the right place, and your plugin renders. Miss it, and you get silence. Once I internalized that boundary, the rest was configuration. Now my Venice balance sits in the sidebar, refreshed on a tick, and the dev loop feels like my own. OpenCode’s TUI plugin system is powerful once you speak its language; this is the dialect I learned the hard way.