15a1bbcee0
Repository sits at the OpenCode config root rather than inside skills/ or
plugins/. OpenCode scans exactly those two subdirectories, and a .git
directory inside either one is picked up as a plugin and fails to load:
loading plugin id=.../plugins/.git entrypoint=.../plugins/.git/index
failed to load plugin ... AggregateError: 2 errors building .../plugins/.git/index
The watcher.ignore config option does not help: it only suppresses filesystem
update events, not plugin discovery, and the "-" disable prefix only applies to
plugins listed in opencode.json(c), not auto-discovered ones.
.gitignore admits only skills/ and plugins/ (plus itself). Personal config
(opencode.jsonc, cli.json, service.json) and node_modules stay local.
skills/opencode-plugins V2-only plugin authoring reference and 7 templates,
transcribed from the V2 docs with the URL on every file
plugins/searxng-search searxng_search tool over a self-hosted SearXNG JSON API
544 lines
16 KiB
Markdown
544 lines
16 KiB
Markdown
# TUI / CLI Plugins
|
|
|
|
**Docs: <https://opencode.ai/v2/docs/build/plugins/cli/>** — fetch this page
|
|
before writing a TUI plugin. This file is a summary of it.
|
|
**Loading: <https://opencode.ai/v2/docs/cli/plugins>**
|
|
**Template: `../templates/tui-plugin.tsx`**
|
|
|
|
`@opencode/plugin/tui` extends the **terminal frontend** with commands, routes,
|
|
slots, Markdown renderers, notifications, and local state. A TUI plugin is a
|
|
separate export from a server plugin — one package can have both.
|
|
|
|
```ts title="src/tui.ts"
|
|
import { Plugin } from "@opencode/plugin/tui"
|
|
|
|
export default Plugin.define({
|
|
id: "acme.cli",
|
|
setup(context) {
|
|
context.ui.toast.show({ message: "CLI plugin loaded", variant: "success" })
|
|
},
|
|
})
|
|
```
|
|
|
|
Import `@opencode/plugin/tui` directly. OpenCode resolves it at runtime, so a
|
|
local plugin needs no absolute path to an OpenCode checkout. `setup` may return
|
|
a cleanup function.
|
|
|
|
## Context
|
|
|
|
`setup` receives configuration, app metadata, the current location, the client,
|
|
cached data, theme tokens, the OpenTUI renderer, and the UI APIs.
|
|
|
|
```ts
|
|
setup(context) {
|
|
const compact = context.options.compact === true
|
|
const location = context.location ?? context.data.location.default()
|
|
const version = context.app.version
|
|
const channel = context.app.channel
|
|
const client = context.client
|
|
const renderer = context.renderer
|
|
const theme = context.theme
|
|
}
|
|
```
|
|
|
|
`context.client` is the generated client and works against a remote server.
|
|
|
|
```ts
|
|
const response = await context.client.plugin.list({
|
|
location: context.location ?? context.data.location.default(),
|
|
})
|
|
const plugins = response.data
|
|
```
|
|
|
|
## Events
|
|
|
|
`data.on` handles one typed event, `data.listen` handles every server event.
|
|
Both return an unsubscribe function.
|
|
|
|
```ts
|
|
const stopPermission = context.data.on("permission.asked", (event) => {
|
|
context.ui.toast.show({ message: `Permission ${event.data.id}` })
|
|
})
|
|
const stopAll = context.data.listen(({ details }) => console.log(details.type))
|
|
|
|
return () => {
|
|
stopPermission()
|
|
stopAll()
|
|
}
|
|
```
|
|
|
|
## Cached data
|
|
|
|
Reads are served from local cached data; the page also exposes `sync` and
|
|
invalidation ("synchronization, and invalidation" for sessions, "refresh
|
|
controls" for location). Do not assume `invalidate` empties the cache or that a
|
|
`list()` immediately after it returns an empty array — the page does not say
|
|
that.
|
|
|
|
Most collections expose `list`, `sync`, and `invalidate`, but the exact set varies
|
|
and **`get` is not universal**. Documented `get` / lookup: `session.get`,
|
|
`session.message.get`, and the "lookup" mentioned for pending items; `project.get`
|
|
and the lookup for saved project permissions; and `shell.get`. The
|
|
location-scoped catalogs (agents, commands, integrations, models, providers,
|
|
references, skills, MCP servers and resources) are documented with `list`,
|
|
`sync`, and `invalidate` only. Other exceptions: `session.root` / `family` /
|
|
`cost` / `status` are separate, `location.default()` has no `list`/`get`, and
|
|
version control uses `vcs.info(...)`. Check the name in the page's example.
|
|
|
|
```ts
|
|
// sessions: list, get, root, family, cost, status, sync, invalidate
|
|
const rootID = context.data.session.root(sessionID)
|
|
const cost = context.data.session.cost(sessionID)
|
|
const status = context.data.session.status(sessionID)
|
|
await context.data.session.sync(sessionID)
|
|
context.data.session.invalidate(sessionID)
|
|
|
|
// pending inbox items and messages
|
|
await context.data.session.pending.sync(sessionID)
|
|
const pending = context.data.session.pending.list(sessionID)
|
|
await context.data.session.message.sync(sessionID)
|
|
const messages = context.data.session.message.list(sessionID)
|
|
|
|
// permissions, and forms at a location
|
|
await context.data.session.permission.sync(sessionID)
|
|
const requests = context.data.session.permission.list(sessionID) ?? []
|
|
await context.data.session.form.sync(sessionID, location)
|
|
const forms = context.data.session.form.list(sessionID, location) ?? []
|
|
await context.data.session.form.reply(reply, location)
|
|
await context.data.session.form.cancel(cancel, location)
|
|
|
|
// projects, shells, location, and version control
|
|
await context.data.project.sync()
|
|
await context.data.shell.sync(context.location)
|
|
await context.data.location.vcs.sync(context.location)
|
|
const branch = context.data.location.vcs.info(context.location)?.branch.current
|
|
```
|
|
|
|
Location-scoped catalogs — agents, commands, integrations, models, providers,
|
|
references, skills, MCP servers, MCP resources — all take a `location` and
|
|
return `?? []` when empty.
|
|
|
|
## Attention
|
|
|
|
System notification, sound, or both, based on terminal focus.
|
|
|
|
```ts
|
|
const result = await context.attention.notify({
|
|
title: "OpenCode",
|
|
message: "Session done",
|
|
notification: { when: "blurred" },
|
|
sound: { name: "done", volume: 0.5, when: "always" },
|
|
})
|
|
// result.ok, result.notification, result.sound, result.skipped
|
|
```
|
|
|
|
## Theme and renderer
|
|
|
|
Use semantic theme tokens with OpenTUI elements and pass `context.renderer` to
|
|
renderer-specific helpers.
|
|
|
|
```tsx
|
|
const Status = () => <text fg={context.theme.text.base}>Ready</text>
|
|
```
|
|
|
|
Inside JSX rendered by a route, dialog, or slot, get the context with
|
|
`usePlugin`:
|
|
|
|
```tsx
|
|
import { usePlugin } from "@opencode/plugin/tui"
|
|
|
|
function Status() {
|
|
const context = usePlugin()
|
|
return <text fg={context.theme.text.base}>{context.app.version}</text>
|
|
}
|
|
```
|
|
|
|
## Storage
|
|
|
|
`store` persists JSON across restarts and syncs across TUI instances. `memory`
|
|
survives plugin reloads but dies with the TUI.
|
|
|
|
```ts
|
|
const [settings, updateSettings] = context.storage.store("settings", {
|
|
initial: { compact: false },
|
|
})
|
|
await updateSettings((draft) => {
|
|
draft.compact = true
|
|
})
|
|
|
|
const [state, updateState] = context.storage.memory("state", { initial: { count: 0 } })
|
|
```
|
|
|
|
## Commands and keymaps
|
|
|
|
```ts
|
|
context.keymap.layer(() => ({
|
|
mode: "global",
|
|
priority: 10,
|
|
commands: [
|
|
{
|
|
id: "acme.status",
|
|
title: "Show Acme status",
|
|
group: "Acme",
|
|
bind: "ctrl+g",
|
|
palette: true,
|
|
slash: { name: "acme", aliases: ["status"], arguments: true },
|
|
enabled: () => true,
|
|
suggested: true,
|
|
run: async (input) => context.ui.toast.show({ message: input ?? "Ready" }),
|
|
},
|
|
],
|
|
bindings: ["acme.status"],
|
|
}))
|
|
```
|
|
|
|
Call `layer` from `setup` — including after an `await` — or from a component.
|
|
The **layer factory must be pure**. OpenCode evaluates it once without
|
|
dependency tracking to validate the initial command shape, then reactively.
|
|
|
|
A layer is removed at the first of: the plugin is disabled/reloaded/fails
|
|
setup, or the component that created it is disposed. A layer may target one
|
|
renderable and return `false` from a command to continue keyboard dispatch.
|
|
|
|
```ts
|
|
context.keymap.layer(() => ({
|
|
target: () => panel,
|
|
commands: [{ bind: "escape", run: (_input, event) => (event ? false : undefined) }],
|
|
}))
|
|
```
|
|
|
|
Initial shape errors — a palette command with no ID — throw from `layer()`.
|
|
Binding-parse errors are handled by OpenTUI; errors from later reactive
|
|
updates follow Solid's rules.
|
|
|
|
Dispatch and inspection:
|
|
|
|
```ts
|
|
context.keymap.dispatch("acme.status", "verbose")
|
|
context.keymap.shortcuts("acme.status")
|
|
context.keymap.commands()
|
|
context.keymap.pending()
|
|
context.keymap.active()
|
|
const popMode = context.keymap.mode.push("acme-search")
|
|
popMode()
|
|
```
|
|
|
|
## Dialogs and toasts
|
|
|
|
```ts
|
|
await context.ui.dialog.alert({ title: "Acme", message: "Ready" })
|
|
|
|
const confirmed = await context.ui.dialog.confirm({
|
|
title: "Continue?",
|
|
message: "Run the Acme action?",
|
|
label: { confirm: "Run", cancel: "Cancel" },
|
|
})
|
|
|
|
const name = await context.ui.dialog.prompt({ title: "Name", placeholder: "release" })
|
|
|
|
const mode = await context.ui.dialog.select({
|
|
title: "Mode",
|
|
current: "safe",
|
|
options: [
|
|
{ title: "Safe", value: "safe", description: "Ask before changes" },
|
|
{ title: "Fast", value: "fast", category: "Advanced" },
|
|
],
|
|
})
|
|
```
|
|
|
|
Custom `search` owns matching and order. It runs for **every** query including
|
|
the empty one, and receives only enabled options.
|
|
|
|
```ts
|
|
const project = await context.ui.dialog.select({
|
|
title: "Projects",
|
|
options: [
|
|
{ title: "Alpha", value: "alpha", category: "Recent" },
|
|
{ title: "Beta", value: "beta", category: "Pinned" },
|
|
],
|
|
search: (query, options) =>
|
|
options
|
|
.filter((o) => o.title.toLowerCase().includes(query.toLowerCase()))
|
|
.toSorted((l, r) => r.title.localeCompare(l.title)),
|
|
})
|
|
```
|
|
|
|
Select **actions** appear in the footer and run from their keyboard binding, a
|
|
click, or Tab focus followed by Enter. By default they receive the selected value
|
|
and are **unavailable without a selection** — use `selection: "none"` for actions
|
|
that need none. They do not close the dialog or settle the promise.
|
|
|
|
```ts
|
|
await context.ui.dialog.select({
|
|
title: "Projects",
|
|
options: [{ title: "Alpha", value: "alpha" }],
|
|
actions: [
|
|
{ bind: "ctrl+o", title: "Inspect", onTrigger: (value) => context.ui.toast.show({ message: value }) },
|
|
{ bind: "ctrl+a", title: "Close", side: "right", selection: "none", onTrigger: () => context.ui.dialog.clear() },
|
|
],
|
|
})
|
|
```
|
|
|
|
- An action does not close the dialog or settle the promise.
|
|
- Enter or a click resolves the selected value. Escape, `clear()`, or another
|
|
dialog resolves `undefined`.
|
|
- **Do not bind `ctrl+n` or `ctrl+p`.** The dialog reserves them for navigation
|
|
and its own bindings win.
|
|
|
|
Custom JSX dialogs set their own size and close themselves:
|
|
|
|
```tsx
|
|
context.ui.dialog.set({ size: "large", centered: true })
|
|
context.ui.dialog.show(
|
|
() => (
|
|
<box>
|
|
<text>Acme</text>
|
|
</box>
|
|
),
|
|
() => console.log("closed"),
|
|
)
|
|
context.ui.dialog.clear()
|
|
```
|
|
|
|
Toasts take `title`, `message`, `variant`, and `duration`.
|
|
|
|
## Markdown
|
|
|
|
```ts
|
|
const unregister = context.markdown.registerCodeBlockRenderer("acme", (_token, render) => render.defaultRender())
|
|
return unregister
|
|
```
|
|
|
|
## Routes and tabs
|
|
|
|
```tsx
|
|
const unregister = context.ui.router.register({
|
|
name: "dashboard",
|
|
render: ({ data }) => <text>{String(data?.title ?? "Acme")}</text>,
|
|
})
|
|
context.ui.router.navigate({ type: "plugin", name: "dashboard", data: { title: "Status" } })
|
|
context.ui.router.navigate({ type: "session", sessionID })
|
|
context.ui.router.navigate({ type: "home" })
|
|
```
|
|
|
|
```ts
|
|
if (context.ui.tabs.enabled()) {
|
|
context.ui.tabs.open(backgroundSessionID) // leaves focus unchanged
|
|
context.ui.tabs.focus(sessionID) // opens if needed
|
|
const tabs = context.ui.tabs.list()
|
|
context.ui.tabs.move(backgroundSessionID, tabs.length - 1)
|
|
context.ui.tabs.close(sessionID)
|
|
}
|
|
```
|
|
|
|
## Model
|
|
|
|
```ts
|
|
const selected = context.ui.model.current()
|
|
const variants = context.ui.model.variant.list()
|
|
context.ui.model.variant.set("high")
|
|
context.ui.model.variant.set(undefined) // back to the model default
|
|
```
|
|
|
|
`current()` and `variant.list()` are reactive when read in a Solid computation.
|
|
`variant.set` returns `false` when no model is selected or the variant does not
|
|
exist. A common use is replacing the built-in variant cycle:
|
|
|
|
```ts
|
|
context.keymap.layer(() => ({
|
|
commands: [
|
|
{
|
|
id: "acme.variant.cycle",
|
|
title: "Cycle variants",
|
|
bind: "tab",
|
|
run() {
|
|
const variants = context.ui.model.variant.list()
|
|
if (variants.length === 0) return
|
|
const index = variants.indexOf(context.ui.model.current()?.variant ?? "")
|
|
context.ui.model.variant.set(variants[(index + 1) % variants.length])
|
|
},
|
|
},
|
|
],
|
|
}))
|
|
```
|
|
|
|
## Slots
|
|
|
|
Insert or replace JSX at `app`, `home.footer`, `home.footer.status`,
|
|
`prompt.footer`, `prompt.footer.status`, `prompt.footer.file`,
|
|
`session.composer.top`, `sidebar.content`, or `sidebar.footer`.
|
|
|
|
Placements: `prepend`, `append`, `before`, `after`, `replace`.
|
|
|
|
```tsx
|
|
return context.ui.slot({
|
|
append: "sidebar.content",
|
|
render: ({ sessionID }) => <text>{context.data.session.get(sessionID)?.title}</text>,
|
|
})
|
|
|
|
context.ui.slot({ append: "home.footer.status", render: () => <text>SYNCED</text> })
|
|
```
|
|
|
|
Use `home.footer.status` to add to the built-in footer row **without replacing
|
|
it**; status contributions appear after health indicators and before the version.
|
|
|
|
### Session panels
|
|
|
|
Register a contribution to `session.panel`, then open it from a command. The
|
|
host owns sizing, focus, and full-screen presentation; the plugin owns content.
|
|
|
|
```tsx
|
|
import { Show } from "solid-js"
|
|
|
|
context.ui.slot({
|
|
append: "session.panel",
|
|
render: (panel) => (
|
|
<Show when={panel.name === "acme.review"}>
|
|
<ReviewPanel panel={panel} />
|
|
</Show>
|
|
),
|
|
})
|
|
|
|
context.ui.slot({
|
|
append: "app",
|
|
render: () => {
|
|
context.keymap.layer(() => ({
|
|
mode: "global",
|
|
commands: [
|
|
{
|
|
id: "acme.review",
|
|
title: "Open review",
|
|
slash: { name: "review" },
|
|
run: () => context.ui.panel.open("acme.review"),
|
|
},
|
|
],
|
|
}))
|
|
return null
|
|
},
|
|
})
|
|
```
|
|
|
|
- Opening outside a session returns `false`.
|
|
- This is an ordinary slot: all five placements and the existing replacement
|
|
ordering rules apply. `append` is what lets independently selectable
|
|
contributions coexist; `replace` still takes over the slot.
|
|
- Names are **shared selection values, not registered claims**. Use a
|
|
plugin-prefixed name. Opening a name with no matching renderer leaves the slot
|
|
empty.
|
|
- Changing presentation preserves mounted contributions. Closing the panel
|
|
disposes them; disabling a plugin removes its contributions through normal
|
|
slot cleanup.
|
|
- Its keyboard layers and input modes are active only while the panel owns input.
|
|
- The slot receives reactive `name`, `sessionID`, `width`, `presentation`, and
|
|
`focused`, plus `focus`, `close`, and `toggleFullscreen`. The host keeps narrow
|
|
terminals full-screen, so `toggleFullscreen` does nothing until there is room.
|
|
|
|
```tsx
|
|
import type { PanelInput } from "@opencode/plugin/tui/context"
|
|
import { usePlugin } from "@opencode/plugin/tui"
|
|
|
|
function ReviewPanel(props: { panel: PanelInput }) {
|
|
const context = usePlugin()
|
|
context.keymap.layer(() => ({
|
|
commands: [{ id: "acme.review.fullscreen", bind: "f", run: props.panel.toggleFullscreen }],
|
|
}))
|
|
return <text>Reviewing {props.panel.sessionID}</text>
|
|
}
|
|
```
|
|
|
|
```ts
|
|
context.ui.panel.open("acme.review", { presentation: "fullscreen" })
|
|
const current = context.ui.panel.current()
|
|
context.ui.panel.close()
|
|
```
|
|
|
|
## Formatting
|
|
|
|
```ts
|
|
const displayPath = context.ui.format.path(context.location?.directory ?? "/home/me/project")
|
|
```
|
|
|
|
Abbreviates the home directory.
|
|
|
|
## Publish and load
|
|
|
|
Expose the TUI plugin through `./tui` and add OpenTUI peers when rendering JSX.
|
|
|
|
```json title="package.json"
|
|
{
|
|
"name": "opencode-acme-plugin",
|
|
"type": "module",
|
|
"exports": {
|
|
".": "./src/index.ts",
|
|
"./tui": "./src/tui.tsx"
|
|
},
|
|
"dependencies": { "@opencode/plugin": "latest" },
|
|
"peerDependencies": {
|
|
"@opentui/core": ">=0.5.8",
|
|
"@opentui/solid": ">=0.5.8",
|
|
"solid-js": ">=1.9.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
Exporting `./tui` beside the main plugin makes the CLI load it automatically.
|
|
The docs say you **do not need** to add the same package to `cli.json` — the CLI
|
|
gets the active plugin list from the connected OpenCode server, so this also
|
|
works when the server is remote.
|
|
|
|
Use `cli.json` only for **CLI-only** plugins that run locally in the terminal and
|
|
must stay active against a remote server:
|
|
|
|
```json title="cli.json"
|
|
{
|
|
"$schema": "https://opencode.ai/v2/cli.json",
|
|
"plugins": [
|
|
"opencode.example",
|
|
"opencode.example@1.0.0",
|
|
"@example/opencode-tui",
|
|
"@example/opencode-tui@1.0.0",
|
|
"./plugins/status",
|
|
"../plugins/status",
|
|
"/home/user/plugins/status",
|
|
"file:///home/user/plugins/status"
|
|
]
|
|
}
|
|
```
|
|
|
|
Entries are processed in order. Prefix an ID or wildcard with `-` to disable
|
|
matching plugins. The `cli/plugins` page shows `["*", "-opencode.notifications",
|
|
"-team.*"]` as its example; the explicit meaning of `*` and `.*` is spelled out
|
|
only on the `plugins` config page, so treat the wildcard semantics here as
|
|
inferred from that example.
|
|
|
|
```json title="cli.json"
|
|
{ "plugins": ["*", "-opencode.notifications", "-team.*"] }
|
|
```
|
|
|
|
Options use the object form:
|
|
|
|
```json title="cli.json"
|
|
{
|
|
"$schema": "https://opencode.ai/v2/cli.json",
|
|
"plugins": [{ "package": "@example/opencode-tui", "options": { "compact": true } }]
|
|
}
|
|
```
|
|
|
|
`cli.json` lives at `~/.config/opencode/cli.json`, or
|
|
`$XDG_CONFIG_HOME/opencode/cli.json`. **There is no project-local CLI settings
|
|
file** — a `cli.json` next to a project `opencode.json(c)` is not read.
|
|
(Source: <https://opencode.ai/v2/docs/cli/config>)
|
|
|
|
Server plugins belong in `opencode.json(c)`.
|
|
|
|
OpenCode also discovers plugins under the global config directory and project
|
|
`.opencode` directories, each using the published-package layout with server and
|
|
TUI entrypoints together:
|
|
|
|
```text title="Plugin discovery paths"
|
|
<global-config>/plugins/status/index.ts
|
|
<global-config>/plugins/status/tui.ts
|
|
<project>/.opencode/plugins/status/index.ts
|
|
<project>/.opencode/plugins/status/tui.ts
|
|
```
|