Files
xiaoshuai 15a1bbcee0 Version OpenCode skills and plugins
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
2026-10-08 13:12:10 +08:00

16 KiB

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.

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.

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.

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.

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.

// 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.

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.

const Status = () => <text fg={context.theme.text.base}>Ready</text>

Inside JSX rendered by a route, dialog, or slot, get the context with usePlugin:

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.

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

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.

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:

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

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.

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.

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:

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

const unregister = context.markdown.registerCodeBlockRenderer("acme", (_token, render) => render.defaultRender())
return unregister

Routes and tabs

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" })
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

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:

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.

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.

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.
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>
}
context.ui.panel.open("acme.review", { presentation: "fullscreen" })
const current = context.ui.panel.current()
context.ui.panel.close()

Formatting

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.

{
  "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:

{
  "$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.

{ "plugins": ["*", "-opencode.notifications", "-team.*"] }

Options use the object form:

{
  "$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:

<global-config>/plugins/status/index.ts
<global-config>/plugins/status/tui.ts
<project>/.opencode/plugins/status/index.ts
<project>/.opencode/plugins/status/tui.ts