Files
opencode/packages/opencode/specs/tui-plugins.md
2026-03-26 23:43:14 +01:00

12 KiB

TUI plugins

Technical reference for the current TUI plugin system.

Overview

  • TUI plugin config lives in tui.json.
  • Author package entrypoint is @opencode-ai/plugin/tui.
  • Internal plugins load inside the CLI app the same way external TUI plugins do.
  • Package plugins can be installed with opencode plugin <module>.

TUI config

Example:

{
  "$schema": "https://opencode.ai/tui.json",
  "theme": "smoke-theme",
  "plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
  "plugin_enabled": {
    "acme.demo": false
  }
}
  • plugin entries can be either a string spec or [spec, options].
  • Plugin specs can be npm specs, file:// URLs, relative paths, or absolute paths.
  • Relative path specs are resolved relative to the config file that declared them.
  • Duplicate npm plugins are deduped by package name; higher-precedence config wins.
  • Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
  • plugin_enabled is keyed by plugin id, not by plugin spec.
  • For file plugins, that id must come from the plugin module's exported id. For npm plugins, it is the exported id or the package name if id is omitted.
  • Plugins are enabled by default. plugin_enabled is only for explicit overrides, usually to disable a plugin with false.
  • plugin_enabled is merged across config layers.
  • Runtime enable/disable state is also stored in KV under plugin_enabled; that KV state overrides config on startup.

Author package shape

Package entrypoint:

  • Import types from @opencode-ai/plugin/tui.
  • @opencode-ai/plugin exports ./tui and declares optional peer deps on @opentui/core and @opentui/solid.

Minimal module shape:

/** @jsxImportSource @opentui/solid */
import type { TuiPlugin } from "@opencode-ai/plugin/tui"

const tui: TuiPlugin = async (api, options, meta) => {
  api.command.register(() => [
    {
      title: "Demo",
      value: "demo.open",
      onSelect: () => api.route.navigate("demo"),
    },
  ])

  api.route.register([
    {
      name: "demo",
      render: () => (
        <box>
          <text>demo</text>
        </box>
      ),
    },
  ])
}

export default {
  id: "acme.demo",
  tui,
}
  • Loader only reads the module default export object. Named exports are ignored.
  • TUI shape is default export { id?, tui }.
  • tui signature is (api, options, meta) => Promise<void>.
  • If package exports contains ./tui, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
  • File/path plugins must export a non-empty id.
  • npm plugins may omit id; package name is used.
  • Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
  • If a path spec points at a directory, that directory must have package.json with main.
  • There is no directory auto-discovery for TUI plugins; they must be listed in tui.json.

Package manifest and install

Package manifest is read from package.json field oc-plugin.

Example:

{
  "name": "@acme/opencode-plugin",
  "type": "module",
  "main": "./dist/index.js",
  "oc-plugin": [
    ["server", { "custom": true }],
    ["tui", { "compact": true }]
  ]
}
  • opencode plugin <module> resolves and installs the package first, then reads oc-plugin, then patches config.
  • Alias: opencode plug <module>.
  • -g / --global writes into the global config dir.
  • Local installs write into <git worktree>/.opencode when inside a git repo, otherwise <cwd>/.opencode.
  • Without --force, an already-configured npm package name is a no-op.
  • With --force, replacement matches by package name. If the existing row is [spec, options], those tuple options are kept.
  • Tuple targets in oc-plugin provide default options written into config.
  • A package can target server, tui, or both.
  • There is no uninstall, list, or update CLI command for external plugins.
  • Local file plugins are configured directly in tui.json.

When plugin entries exist in a writable .opencode dir or OPENCODE_CONFIG_DIR, OpenCode installs @opencode-ai/plugin into that dir and writes:

  • package.json
  • bun.lock
  • node_modules/
  • .gitignore

That is what makes local config-scoped plugins able to import @opencode-ai/plugin/tui.

TUI plugin API

Top-level API groups exposed to tui(api, options, meta):

  • api.app.version
  • api.command.register(cb) / api.command.trigger(value)
  • api.route.register(routes) / api.route.navigate(name, params?) / api.route.current
  • api.ui.Dialog, DialogAlert, DialogConfirm, DialogPrompt, DialogSelect, ui.toast, ui.dialog
  • api.keybind.match, print, create
  • api.tuiConfig
  • api.kv.get, set, ready
  • api.state
  • api.theme.current, selected, has, set, install, mode, ready
  • api.client, api.scopedClient(workspaceID?), api.workspace.current(), api.workspace.set(workspaceID?)
  • api.event.on(type, handler)
  • api.renderer
  • api.slots.register(plugin)
  • api.plugins.list(), activate(id), deactivate(id)
  • api.lifecycle.signal, api.lifecycle.onDispose(fn)

Commands

api.command.register returns an unregister function. Command rows support:

  • title, value
  • description, category
  • keybind
  • suggested, hidden, enabled
  • slash: { name, aliases? }
  • onSelect

Command behavior:

  • Registrations are reactive.
  • Later registrations win for duplicate value and for keybind handling.
  • Hidden commands are removed from the command dialog and slash list, but still respond to keybinds and command.trigger(value) if enabled !== false.

Routes

  • Reserved route names: home and session.
  • Any other name is treated as a plugin route.
  • api.route.current returns one of:
    • { name: "home" }
    • { name: "session", params: { sessionID, initialPrompt? } }
    • { name: string, params?: Record<string, unknown> }
  • api.route.navigate("session", params) only uses params.sessionID. It cannot set initialPrompt.
  • If multiple plugins register the same route name, the last registered route wins.
  • Unknown plugin routes render a fallback screen with a go home action.

Dialogs and toast

  • ui.Dialog is the base dialog wrapper.
  • ui.DialogAlert, ui.DialogConfirm, ui.DialogPrompt, ui.DialogSelect are built-in dialog components.
  • ui.toast(...) shows a toast.
  • ui.dialog exposes the host dialog stack:
    • replace(render, onClose?)
    • clear()
    • setSize("medium" | "large" | "xlarge")
    • readonly size, depth, open

Keybinds

  • api.keybind.match(key, evt) and print(key) use the host keybind parser/printer.
  • api.keybind.create(defaults, overrides?) builds a plugin-local keybind set.
  • Only missing, blank, or non-string overrides are ignored. Key syntax is not validated.
  • Returned keybind set exposes all, get(name), match(name, evt), print(name).

KV, state, client, events

  • api.kv is the shared app KV store backed by state/kv.json. It is not plugin-namespaced.
  • api.kv exposes ready.
  • api.tuiConfig and api.state are live host objects/getters, not frozen snapshots.
  • api.state exposes synced TUI state:
    • ready
    • config
    • provider
    • path.{state,config,worktree,directory}
    • vcs?.branch
    • workspace.list() / workspace.get(workspaceID)
    • session.count()
    • session.diff(sessionID)
    • session.todo(sessionID)
    • session.messages(sessionID)
    • session.status(sessionID)
    • session.permission(sessionID)
    • session.question(sessionID)
    • part(messageID)
    • lsp()
    • mcp()
  • api.client always reflects the current runtime client.
  • api.scopedClient(workspaceID?) creates or reuses a client bound to a workspace.
  • api.workspace.set(...) rebinds the active workspace; api.client follows that rebind.
  • api.event.on(type, handler) subscribes to the TUI event stream and returns an unsubscribe function.
  • api.renderer exposes the raw CliRenderer.

Theme

  • api.theme.current exposes the resolved current theme tokens.
  • api.theme.selected is the selected theme name.
  • api.theme.has(name) checks for an installed theme.
  • api.theme.set(name) switches theme and returns boolean.
  • api.theme.mode() returns "dark" | "light".
  • api.theme.install(jsonPath) installs a theme JSON file.
  • api.theme.ready reports theme readiness.

Theme install behavior:

  • Relative theme paths are resolved from the plugin root.
  • Theme name is the JSON basename.
  • Install is skipped if that theme name already exists.
  • Local plugins persist installed themes under the local .opencode/themes area near the plugin config source.
  • Global plugins persist installed themes under the global themes dir.
  • Invalid or unreadable theme files are ignored.

Slots

Current host slot names:

  • app
  • home_logo
  • home_bottom
  • sidebar_title with props { session_id, title, share_url? }
  • sidebar_content with props { session_id }
  • sidebar_footer with props { session_id }

Slot notes:

  • Slot context currently exposes only theme.
  • api.slots.register(plugin) returns the host-assigned slot plugin id.
  • api.slots.register(plugin) does not return an unregister function.
  • Returned ids are pluginId, pluginId:1, pluginId:2, and so on.
  • Plugin-provided id is not allowed.
  • The current host renders home_logo with replace, sidebar_title and sidebar_footer with single_winner, and app, home_bottom, and sidebar_content with the slot library default mode.
  • Plugins cannot define new slot names in this branch.

Plugin control and lifecycle

  • api.plugins.list() returns { id, source, spec, target, enabled, active }[].
  • enabled is the persisted desired state. active means the plugin is currently initialized.
  • api.plugins.activate(id) sets enabled=true, persists it into KV, and initializes the plugin.
  • api.plugins.deactivate(id) sets enabled=false, persists it into KV, and disposes the plugin scope.
  • If activation fails, the plugin can remain enabled=true and active=false.
  • api.lifecycle.signal is aborted before cleanup runs.
  • api.lifecycle.onDispose(fn) registers cleanup and returns an unregister function.

Plugin metadata

meta passed to tui(api, options, meta) contains:

  • state: first | updated | same
  • id, source, spec, target
  • npm-only fields when available: requested, version
  • file-only field when available: modified
  • first_time, last_time, time_changed, load_count, fingerprint

Metadata is persisted by plugin id.

  • File plugin fingerprint is target|modified.
  • npm plugin fingerprint is target|requested|version.
  • Internal plugins get synthetic metadata with state: "same".

Runtime behavior

  • Internal TUI plugins load first.
  • External TUI plugins load from tuiConfig.plugin.
  • --pure / OPENCODE_PURE skips external TUI plugins only.
  • External plugin resolution and import are parallel.
  • External plugin activation is sequential to keep command, route, and side-effect order deterministic.
  • File plugins that fail initially are retried once after waiting for config dependency installation.
  • Plugin init failure rolls back that plugin's tracked registrations and loading continues.
  • TUI runtime tracks and disposes:
    • command registrations
    • route registrations
    • event subscriptions
    • slot registrations
    • explicit lifecycle.onDispose(...) handlers
  • Cleanup runs in reverse order.
  • Cleanup is awaited.
  • Total cleanup budget per plugin is 5 seconds; timeout/error is logged and shutdown continues.

Built-in plugins

  • internal:home-tips
  • internal:sidebar-context
  • internal:sidebar-mcp
  • internal:sidebar-lsp
  • internal:sidebar-todo
  • internal:sidebar-files
  • internal:sidebar-footer
  • internal:plugin-manager

Sidebar content order is currently: context 100, mcp 200, lsp 300, todo 400, files 500.

The plugin manager is exposed as a command with title Plugins and value plugins.list.

  • Keybind name is plugin_manager.
  • Default keybind is none.
  • It lists both internal and external plugins.
  • It toggles based on active.
  • Its own row is disabled only inside the manager dialog.

Current in-repo examples

  • Local smoke plugin: .opencode/plugins/tui-smoke.tsx
  • Local smoke config: .opencode/tui.json
  • Local smoke theme: .opencode/plugins/smoke-theme.json