Start typing to search the documentation.

Build navigation

CLI

CLI plugins extend the terminal with commands, routes, slots, Markdown renderers, notifications, and local state.

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 this import at runtime, so local CLI plugins do not need an absolute path to an OpenCode checkout.

Context

setup receives configuration, app metadata, the current location, the OpenCode client, cached data, theme tokens, the OpenTUI renderer, and the UI APIs documented below.

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
}

Return a cleanup function for resources owned by the plugin.

setup(context) {
  const stop = context.data.on("session.execution.succeeded", () => {})
  return () => stop()
}

Client

context.client is the generated OpenCode client and can call the connected server, including a remote server.

const response = await context.client.plugin.list({
  location: context.location ?? context.data.location.default(),
})
const plugins = response.data

Events

Use data.on for one typed event or data.listen for 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()
}

Sessions

Session data exposes list, lookup, hierarchy, cost, status, synchronization, and invalidation.

const sessions = context.data.session.list()
const session = context.data.session.get(sessionID)
const rootID = context.data.session.root(sessionID)
const familyIDs = context.data.session.family(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 have list, lookup, sync, and invalidate APIs.

await context.data.session.pending.sync(sessionID)
const pending = context.data.session.pending.list(sessionID)
context.data.session.pending.invalidate(sessionID)

await context.data.session.message.sync(sessionID)
const messages = context.data.session.message.list(sessionID)
const message = context.data.session.message.get(sessionID, messageID)
context.data.session.message.invalidate(sessionID)

Permission requests can be read and refreshed for a session.

await context.data.session.permission.sync(sessionID)
const requests = context.data.session.permission.list(sessionID) ?? []
context.data.session.permission.invalidate(sessionID)

Forms can be listed, refreshed, replied to, or cancelled at a location.

import type { FormCancelInput, FormReplyInput } from "@opencode/client"

async function handleForm(reply: FormReplyInput, cancel: FormCancelInput) {
  const location = context.location
  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)
  context.data.session.form.invalidate(sessionID, location)
}

Projects and shells

Projects and saved permissions support list, lookup, sync, and invalidate operations.

await context.data.project.sync()
const projects = context.data.project.list()
const project = context.data.project.get(projectID)
context.data.project.invalidate()

await context.data.project.permission.sync(projectID)
const saved = context.data.project.permission.list(projectID) ?? []
context.data.project.permission.invalidate(projectID)

Shell data supports location-scoped list, lookup, sync, and invalidate operations.

await context.data.shell.sync(context.location)
const shells = context.data.shell.list(context.location)
const shell = context.data.shell.get(shellID)
context.data.shell.invalidate(context.location)

Location data

Location state exposes the default location and refresh controls.

const location = context.data.location.default()
await context.data.location.sync(location)
context.data.location.invalidate(location)

Version-control state exposes repository information at a location.

await context.data.location.vcs.sync(context.location)
const vcs = context.data.location.vcs.info(context.location)
const branch = vcs?.branch.current
context.data.location.vcs.invalidate(context.location)

Agents, commands, integrations, models, providers, references, skills, and MCP data share list, sync, and invalidate methods.

const location = context.location
await Promise.all([
  context.data.location.agent.sync(location),
  context.data.location.command.sync(location),
  context.data.location.integration.sync(location),
  context.data.location.model.sync(location),
  context.data.location.provider.sync(location),
  context.data.location.reference.sync(location),
  context.data.location.skill.sync(location),
  context.data.location.mcp.server.sync(location),
  context.data.location.mcp.resource.sync(location),
])

const agents = context.data.location.agent.list(location) ?? []
const commands = context.data.location.command.list(location) ?? []
const integrations = context.data.location.integration.list(location) ?? []
const models = context.data.location.model.list(location) ?? []
const providers = context.data.location.provider.list(location) ?? []
const references = context.data.location.reference.list(location) ?? []
const skills = context.data.location.skill.list(location) ?? []
const servers = context.data.location.mcp.server.list(location) ?? []
const resources = context.data.location.mcp.resource.list(location) ?? []

context.data.location.model.invalidate(location)

Attention

Attention requests can show a system notification, play a configured sound, or do 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" },
})
console.log(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>
const renderer = context.renderer

Solid components

Use usePlugin to access the current context inside JSX rendered by a route, dialog, or slot.

import { usePlugin } from "@opencode/plugin/tui"

function Status() {
  const context = usePlugin()
  return <text fg={context.theme.text.base}>{context.app.version}</text>
}

Markdown

Register a fenced-code renderer by language; the returned function unregisters it.

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

Commands and keymaps

Register palette, slash, and keyboard commands in a reactive keymap layer.

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

A layer may target one OpenTUI renderable and can return false from a command to continue keyboard dispatch.

context.keymap.layer(() => ({
  target: () => panel,
  commands: [{ bind: "escape", run: (_input, event) => (event ? false : undefined) }],
}))

Dispatch commands, inspect shortcuts and command state, or push a temporary input mode.

context.keymap.dispatch("acme.status", "verbose")
const shortcuts = context.keymap.shortcuts("acme.status")
const commands = context.keymap.commands()
const pending = context.keymap.pending()
const active = context.keymap.active()
const currentMode = context.keymap.mode.current()
const popMode = context.keymap.mode.push("acme-search")
popMode()

Storage

Durable storage persists JSON across restarts and synchronizes across TUI instances.

const [settings, updateSettings] = context.storage.store("settings", {
  initial: { compact: false },
})
await updateSettings((draft) => {
  draft.compact = true
})

Memory storage survives plugin reloads but is discarded when the TUI exits.

const [state, updateState] = context.storage.memory("state", {
  initial: { count: 0 },
})
updateState((draft) => {
  draft.count++
})

Dialogs and toasts

Use promise-based dialogs for alerts, confirmations, text input, and selection.

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", disabled: false, category: "Advanced" },
  ],
})

Custom JSX dialogs can set their 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 support title, message, variant, and duration.

context.ui.toast.show({
  title: "Acme",
  message: "Saved",
  variant: "success",
  duration: 3000,
})

Routes and tabs

Register a JSX route, inspect the current route, and navigate to home, a session, or the plugin page.

const unregister = context.ui.router.register({
  name: "dashboard",
  render: ({ data }) => <text>{String(data?.title ?? "Acme")}</text>,
})
const current = context.ui.router.current()
context.ui.router.navigate({ type: "plugin", name: "dashboard", data: { title: "Status" } })
context.ui.router.navigate({ type: "session", sessionID })
context.ui.router.navigate({ type: "home" })
return unregister

Tabs can be listed, opened, focused, moved, and closed when session tabs are enabled. open leaves focus unchanged; focus opens the tab when needed.

if (context.ui.tabs.enabled()) {
  context.ui.tabs.open(backgroundSessionID)
  context.ui.tabs.focus(sessionID)
  const tabs = context.ui.tabs.list()
  context.ui.tabs.move(backgroundSessionID, tabs.length - 1)
  context.ui.tabs.close(sessionID)
  context.ui.tabs.close()
}

Model

Read the prompt’s selected model and change its variant. current() and variant.list() are reactive when read in a Solid computation. A variant of undefined is the model default.

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)

variant.set returns false when no model is selected or the model has no such variant. Bind it to a command to replace the built-in variant cycle, for example to skip the model default:

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

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.

return context.ui.slot({
  append: "sidebar.content",
  render: ({ sessionID }) => <text>{context.data.session.get(sessionID)?.title}</text>,
})

Use prepend, append, before, after, or replace for placement.

context.ui.slot({ prepend: "home.footer", render: () => <text>Before footer content</text> })
context.ui.slot({ append: "home.footer", render: () => <text>After footer content</text> })
context.ui.slot({ before: "home.footer", render: () => <text>Before footer slot</text> })
context.ui.slot({ after: "home.footer", render: () => <text>After footer slot</text> })
context.ui.slot({ replace: "home.footer", render: () => <text>New footer</text> })

Use home.footer.status to add content to the built-in footer row without replacing it. Status contributions appear after OpenCode’s health indicators and before the version.

context.ui.slot({ append: "home.footer.status", render: () => <text>SYNCED</text> })

Session panels

Register a contribution to session.panel, then open the panel from a command. The host owns sizing, focus, and full-screen presentation; the plugin owns its contents. The selected name is passed to every contribution as panel.name, and each contribution decides whether to render.

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.
  • Use append for independently selectable contributions so they can coexist. replace still takes over the slot.
  • Names are shared selection values, not registered claims. Use a plugin-prefixed name such as acme.review to avoid collisions. Opening a name with no matching renderer leaves the slot empty.
  • Changing presentation preserves the 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 properties, plus focus, close, and toggleFullscreen actions. The host keeps narrow terminals full-screen; toggleFullscreen has no effect until there is enough room for a side panel.

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

You can request full-screen presentation initially, inspect your active panel, or close it without affecting another plugin’s panel.

context.ui.panel.open("acme.review", { presentation: "fullscreen" })
const current = context.ui.panel.current()
context.ui.panel.close()

Formatting

Format filesystem paths for display, including home-directory abbreviation.

const displayPath = context.ui.format.path(context.location?.directory ?? "/home/me/project")

Publish and load

Expose the CLI plugin through ./tui; add OpenTUI peers when the plugin renders JSX.

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

Export ./tui beside the main plugin for automatic loading.

src/index.ts
import { Plugin } from "@opencode/plugin"

export default Plugin.define({
  id: "acme.server",
  setup() {},
})

Configure a CLI-only package in cli.json so it remains active against remote servers.

cli.json
{
  "plugins": ["opencode-acme-plugin"]
}