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 kind | Registered in | Loaded by |
|---|---|---|
| Hook plugins | opencode.jsonc → "plugin" | main process |
| TUI / sidebar plugins | tui.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:
- TypeScript → JavaScript — yours.
- 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:
- A. Ship raw source. (
exports["./tui"] → src/tui.tsx, no build.) OpenCode transpiles everything. Simple, always works. No control over build process. - B.
tscwithjsx: "preserve". Types stripped, JSX left literal. You transform what you own, hand off what you don’t. Real artifacts indist/,.d.tsfor consumers. Must import JSX modules with.jsxspecifiers. - C. Bundle with
--external. Single artifact, full control. Most configuration. One missing--externalflag = silent no‑render.
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.
Local Development: @file: Symlinks
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:
- Wrong registry. Sidebar plugins go in
tui.jsonc, notopencode.jsonc. Symptom: sidebar shows built‑in sections but not yours. react-jsxrenders nothing. Compiling JSX binds the wrong instance. Usepreserve. Symptom: plugin loads but sidebar renders blank.- The pragma vanishes. With
jsx: "preserve",tscdrops the/** @jsxImportSource … */comment if it’s the leading comment of a type‑only import. Fix: blank line between pragma and import. KeepremoveComments: false. .jsimport 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.bun linkdoesn’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.