390 lines
16 KiB
Markdown
390 lines
16 KiB
Markdown
# 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 from CLI or TUI.
|
|
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
|
|
- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
|
|
|
|
## TUI config
|
|
|
|
Example:
|
|
|
|
```json
|
|
{
|
|
"$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.
|
|
- A file module listed in `tui.json` must be a TUI module (`default export { id?, tui }`) and must not export `server`.
|
|
- 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:
|
|
|
|
```tsx
|
|
/** @jsxImportSource @opentui/solid */
|
|
import type { TuiPlugin, TuiPluginModule } 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>
|
|
),
|
|
},
|
|
])
|
|
}
|
|
|
|
const plugin: TuiPluginModule & { id: string } = {
|
|
id: "acme.demo",
|
|
tui,
|
|
}
|
|
|
|
export default plugin
|
|
```
|
|
|
|
- Loader only reads the module default export object. Named exports are ignored.
|
|
- TUI shape is `default export { id?, tui }`; including `server` is rejected.
|
|
- A single module cannot export both `server` and `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.
|
|
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
|
|
- 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:
|
|
|
|
```json
|
|
{
|
|
"name": "@acme/opencode-plugin",
|
|
"type": "module",
|
|
"main": "./dist/index.js",
|
|
"engines": {
|
|
"opencode": "^1.0.0"
|
|
},
|
|
"oc-plugin": [
|
|
["server", { "custom": true }],
|
|
["tui", { "compact": true }]
|
|
]
|
|
}
|
|
```
|
|
|
|
### Version compatibility
|
|
|
|
npm plugins can declare a version compatibility range in `package.json` using the standard `engines` field:
|
|
|
|
```json
|
|
{
|
|
"engines": {
|
|
"opencode": "^1.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
- The value is a semver range checked against the running OpenCode version.
|
|
- If the range is not satisfied, the plugin is skipped with a warning and a session error.
|
|
- If `engines.opencode` is absent, no check is performed (backward compatible).
|
|
- File plugins are never checked; only npm package plugins are validated.
|
|
|
|
- Install flow is shared by CLI and TUI in `src/plugin/install.ts`.
|
|
- Shared helpers are `installPlugin`, `readPluginManifest`, and `patchPluginConfig`.
|
|
- `opencode plugin <module>` and TUI install both run install → manifest read → config patch.
|
|
- Alias: `opencode plug <module>`.
|
|
- `-g` / `--global` writes into the global config dir.
|
|
- Local installs resolve target dir inside `patchPluginConfig`.
|
|
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
|
|
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
|
|
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
|
|
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
|
|
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
|
|
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
|
|
- 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.
|
|
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
|
|
- 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`, `Prompt`, `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)`, `add(spec)`, `install(spec, options?)`
|
|
- `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.Prompt` renders the same prompt component used by the host app.
|
|
- `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_prompt` with props `{ workspace_id? }`
|
|
- `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` and `home_prompt` 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.
|
|
- `api.plugins.add(spec)` trims the input and returns `false` for an empty string.
|
|
- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
|
|
- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
|
|
- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
|
|
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
|
|
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
|
|
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
|
|
- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
|
|
- 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.
|
|
- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
|
|
- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
|
|
- Runtime install and runtime add are separate operations.
|
|
- 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.
|
|
- It also exposes command `plugins.install` with title `Install plugin`.
|
|
- Inside the Plugins dialog, key `shift+i` opens the install prompt.
|
|
- Install prompt asks for npm package name.
|
|
- Scope defaults to local, and `tab` toggles local/global.
|
|
- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
|
|
- Manager install uses `api.plugins.install(spec, { global })`.
|
|
- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
|
|
- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
|
|
- If runtime add fails, TUI shows a warning and restart remains the fallback.
|
|
|
|
## 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`
|