Technical reference for the current TUI plugin system.
tui.json.@kilocode/plugin/tui.server or tui, never both.package.json["oc-themes"] without a ./tui entrypoint.Example:
{
"$schema": "https://opencode.ai/tui.json",
"theme": "smoke-theme",
"plugin": ["@acme/[email protected]", ["./plugins/demo.tsx", { "label": "demo" }]],
"plugin_enabled": {
"acme.demo": false
}
}
plugin entries can be either a string spec or [spec, options].file:// URLs, relative paths, or absolute paths.tui.json must be a TUI module (default export { id?, tui }) and must not export server.plugin_enabled is keyed by plugin id, not by plugin spec.id. For npm plugins, it is the exported id or the package name if id is omitted.plugin_enabled is only for explicit overrides, usually to disable a plugin with false.plugin_enabled is merged across config layers.plugin_enabled; that KV state overrides config on startup.Package entrypoint:
@kilocode/plugin/tui.@kilocode/plugin exports ./tui and declares optional peer deps on @opentui/core and @opentui/solid.Minimal module shape:
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginModule } from "@kilocode/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
default export { id?, tui }; including server is rejected.server and tui.tui signature is (api, options, meta) => Promise<void>.exports contains ./tui, the loader resolves that entrypoint.exports exists, loader only resolves ./tui or ./server; it never falls back to exports["."].package.json main as a fallback entry.package.json main is only used for server plugin entrypoint resolution../tui entrypoint and no valid oc-themes, it is skipped with a warning (not a load failure)../tui entrypoint but has valid oc-themes, runtime creates a no-op module record and still loads it for theme sync and plugin state.exports (./server and ./tui) so each target resolves to a target-only module.id.id; package name is used.package.json main.package.json main../plugin can resolve to ./plugin/index.ts (or index.js) when package.json is missing../plugin -> ./plugin/index.* fallback applies to both server and TUI v1 loading.tui.json.Install target detection is inferred from package.json entrypoints and theme metadata:
server target when exports["./server"] exists or main is set.tui target when exports["./tui"] exists.tui target when oc-themes exists and resolves to a non-empty set of valid package-relative theme paths.oc-themes rules:
oc-themes is an array of relative paths.file:// paths are rejected.oc-themes causes manifest read failure for install.Example:
{
"name": "@acme/opencode-plugin",
"type": "module",
"main": "./dist/server.js",
"exports": {
"./server": {
"import": "./dist/server.js",
"config": { "custom": true }
},
"./tui": {
"import": "./dist/tui.js",
"config": { "compact": true }
}
},
"engines": {
"opencode": "^1.0.0"
}
}
npm plugins can declare a version compatibility range in package.json using the standard engines field:
{
"engines": {
"opencode": "^1.0.0"
}
}
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 detected 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.
npm plugin package installs are executed with --ignore-scripts, so package install / postinstall lifecycle scripts are not run.
exports["./server"].config and exports["./tui"].config can provide default plugin options written on first install.
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.
Explicit npm specs with a version suffix (for example [email protected]) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions.
Bare npm specs (pkg) are treated as latest and can refresh when the cached version is stale.
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 KILO_CONFIG_DIR, OpenCode installs @kilocode/plugin into that dir and writes:
package.jsonbun.locknode_modules/.gitignoreThat is what makes local config-scoped plugins able to import @kilocode/plugin/tui.
Top-level API groups exposed to tui(api, options, meta):
api.app.versionapi.command.register(cb) / api.command.trigger(value) / api.command.show()api.route.register(routes) / api.route.navigate(name, params?) / api.route.currentapi.ui.Dialog, DialogAlert, DialogConfirm, DialogPrompt, DialogSelect, Slot, Prompt, ui.toast, ui.dialogapi.keybind.match, print, createapi.tuiConfigapi.kv.get, set, readyapi.stateapi.theme.current, selected, has, set, install, mode, readyapi.client, api.scopedClient(workspaceID?), api.workspace.current(), api.workspace.set(workspaceID?)api.event.on(type, handler)api.rendererapi.slots.register(plugin)api.plugins.list(), activate(id), deactivate(id), add(spec), install(spec, options?)api.lifecycle.signal, api.lifecycle.onDispose(fn)api.command.register returns an unregister function. Command rows support:
title, valuedescription, categorykeybindsuggested, hidden, enabledslash: { name, aliases? }onSelectCommand behavior:
value and for keybind handling.command.trigger(value) if enabled !== false.api.command.show() opens the host command dialog directly.home and session.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.go home action.ui.Dialog is the base dialog wrapper.ui.DialogAlert, ui.DialogConfirm, ui.DialogPrompt, ui.DialogSelect are built-in dialog components.ui.Slot renders host or plugin-defined slots by name from plugin JSX.ui.Prompt renders the same prompt component used by the host app and accepts sessionID, workspaceID, ref, and right for the prompt meta row's right side.ui.toast(...) shows a toast.ui.dialog exposes the host dialog stack:
replace(render, onClose?)clear()setSize("medium" | "large" | "xlarge")size, depth, openapi.keybind.match(key, evt) and print(key) use the host keybind parser/printer.api.keybind.create(defaults, overrides?) builds a plugin-local keybind set.all, get(name), match(name, evt), print(name).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:
readyconfigproviderpath.{state,config,worktree,directory}vcs?.branchworkspace.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.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:
api.theme.install(...) and oc-themes auto-sync share the same installer path.tui-theme:<dest>.updated.updated, host skips rewrite when tracked mtime/size is unchanged.updated, host can still persist theme metadata when destination already exists..opencode/themes area near the plugin config source.themes dir.Current host slot names:
apphome_logohome_prompt with props { workspace_id?, ref? }home_prompt_right with props { workspace_id? }session_prompt with props { session_id, visible?, disabled?, on_submit?, ref? }session_prompt_right with props { session_id }home_bottomhome_footersidebar_title with props { session_id, title, share_url? }sidebar_content with props { session_id }sidebar_footer with props { session_id }Slot notes:
theme.api.slots.register(plugin) returns the host-assigned slot plugin id.api.slots.register(plugin) does not return an unregister function.pluginId, pluginId:1, pluginId:2, and so on.id is not allowed.home_logo, home_prompt, and session_prompt with replace, home_footer, sidebar_title, and sidebar_footer with single_winner, and app, home_prompt_right, session_prompt_right, home_bottom, and sidebar_content with the slot library default mode.api.slots.register(...) and render them from plugin UI with ui.Slot.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.add(spec) can load theme-only packages (oc-themes with no ./tui) as runtime entries.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.enabled=true and active=false.api.lifecycle.signal is aborted before cleanup runs.api.lifecycle.onDispose(fn) registers cleanup and returns an unregister function.meta passed to tui(api, options, meta) contains:
state: first | updated | sameid, source, spec, targetrequested, versionmodifiedfirst_time, last_time, time_changed, load_count, fingerprintMetadata is persisted by plugin id.
target|modified.target|requested|version.state: "same".tuiConfig.plugin.--pure / KILO_PURE skips external TUI plugins only../tui entrypoint and valid oc-themes are loaded as synthetic no-op TUI plugin modules.api.plugins.list() and plugin manager rows like other external plugins../tui entrypoint and no valid oc-themes are skipped with warning.oc-themes runs before plugin tui(...) execution and only on metadata state first or updated.true when the spec is already loaded.lifecycle.onDispose(...) handlersinternal:home-tipsinternal:sidebar-contextinternal:sidebar-mcpinternal:sidebar-lspinternal:sidebar-todointernal:sidebar-filesinternal:sidebar-footerinternal:plugin-managerSidebar 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.
plugin_manager.none.active.plugins.install with title Install plugin.shift+i opens the install prompt.tab toggles local/global.api.state.path.directory is available; current guard message is Paths are still syncing. Try again in a moment..api.plugins.install(spec, { global }).tui target (tui=false), manager reports that and does not expect a runtime load.tui target detection includes exports["./tui"] and valid oc-themes.tui=true, manager then calls api.plugins.add(spec)..opencode/plugins/tui-smoke.tsx.opencode/plugins/tui-vim.tsx.opencode/tui.json.opencode/plugins/smoke-theme.json