mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
preliminary internal docs
This commit is contained in:
331
packages/opencode/specs/tui-plugins.md
Normal file
331
packages/opencode/specs/tui-plugins.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# 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 plug <module>`.
|
||||
|
||||
## 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.
|
||||
- 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 } 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:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@acme/opencode-plugin",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"oc-plugin": [
|
||||
["server", { "custom": true }],
|
||||
["tui", { "compact": true }]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `opencode plug <module>` resolves and installs the package first, then reads `oc-plugin`, then patches config.
|
||||
- Alias: `opencode plugin <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")`
|
||||
- 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`
|
||||
Reference in New Issue
Block a user