Skip to content
Build docs

TUI

Contents

TUI plugins extend the terminal UI with commands, routes, slots, Markdown renderers, notifications, and local state. A TUI plugin is the ./tui part of a plugin package, usually beside a server part.

src/tui.tsx
import { Plugin } from "@opencode/plugin/tui"

export default Plugin.define({
  id: "acme.cli",
  setup(context) {
    context.ui.toast.show({ message: "TUI plugin loaded", variant: "success" })
  },
})

Import @opencode/plugin/tui directly. OpenCode resolves this import at runtime, so local TUI plugins do not need an absolute path to an OpenCode checkout.

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

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

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

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

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

Use data.on for one typed event or data.listen for every server event; both return an unsubscribe function.

TypeScript
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()
}

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

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

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

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

TypeScript
import type { SessionFormCancelInput, SessionFormReplyInput } from "@opencode/client"

async function handleForm(reply: SessionFormReplyInput, cancel: SessionFormCancelInput) {
  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 saved permissions support list, lookup, sync, and invalidate operations.

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

TypeScript
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 state exposes the default location and refresh controls.

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

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

TypeScript
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 requests can show a system notification, play a configured sound, or do both based on terminal focus.

TypeScript
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)

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>
const renderer = context.renderer

An OpenTUI resource that the renderer draws needs context.renderer.nativeScene as its owner. For example, <markdown> and <code> need a SyntaxStyle. Destroy the resource when the renderer is idle, after the last frame that draws it.

TSX
import { SyntaxStyle } from "@opentui/core"

const style = SyntaxStyle.fromStyles({ default: { fg: context.theme.text.base } }, context.renderer.nativeScene)
context.ui.slot({ append: "home.footer", render: () => <markdown content="Ready" syntaxStyle={style} /> })
return () => context.renderer.idle().then(() => style.destroy())

To read the layout style of a renderable, call the method with the Yoga Node getter’s name, such as box.getPadding(Yoga.Edge.Bottom) or box.getMaxWidth(). Host renderables are not part of the plugin API. Their structure and styles can change in any release.

OpenCode runs plugins with its own OpenTUI. Since OpenTUI 0.6:

  • SyntaxStyle.create(), SyntaxStyle.fromStyles(), SyntaxStyle.fromTheme(), TextBuffer.create(), and EditBuffer.create() take an owner as their last argument. OptimizedBuffer.create() takes it in the owner option. Pass context.renderer.nativeScene. OpenTUI 0.5 ignores the extra argument, so the plugin still runs on older OpenCode releases.
  • generateSyntax() from @opencode/theme/tui takes the owner as its second argument: generateSyntax(context.theme, context.renderer.nativeScene). Older @opencode/theme versions build the style without an owner, so update the package too.
  • getLayoutNode() is removed. Replace getLayoutNode().getMaxWidth() and the other style getters with the renderable method of the same name, on OpenTUI 0.6.5 or later. Replace getComputedLayout() and the other computed position and size getters with getLayout(), and markDirty() with invalidateIntrinsicSize(). Computed padding, margin, and border have no replacement, and getPadding(edge) reads only the padding set for that edge. To restore a style that a getter reports as unset (Unit.Undefined), assign undefined to the property, on OpenTUI 0.6.6 or later.

The OpenTUI 0.6.0 release notes list every change.

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

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

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

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

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

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

TypeScript
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. Each layer is removed at the first of these:

  • The plugin is disabled, reloaded, or fails setup.
  • The component or reactive computation that created it, such as a slot, route, or dialog, is disposed.

The layer factory must be pure. OpenCode evaluates it once without dependency tracking to validate the initial command shape, then reactively. Initial command-shape errors, such as a palette command without an ID, throw from layer(). OpenTUI handles binding-parse errors. Errors from later reactive updates follow Solid’s error handling.

TypeScript
async setup(context) {
  const settings = await loadSettings()
  context.keymap.layer(() => ({
    mode: "global",
    commands: [{ id: "acme.sync", title: "Sync Acme", palette: true, run: () => sync(settings) }],
  }))
}

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

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

TypeScript
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()

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

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

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

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

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

Pass search to own matching and result order. It runs for every query, including the empty one, and receives only enabled options. Results are listed in the returned order, with each category shown inline.

TypeScript
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((option) => option.title.toLowerCase().includes(query.toLowerCase()))
      .toSorted((left, right) => right.title.localeCompare(left.title)),
})

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 no selection.

TypeScript
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 clicking an option resolves the selected value.
  • Escape, clear(), or showing another dialog resolves undefined.
  • Do not bind ctrl+n or ctrl+p. The dialog reserves them for navigation, and its own bindings take precedence.

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

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

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

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

TypeScript
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()
}

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.

TypeScript
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:

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

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

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

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

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.

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

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

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

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

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

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

Expose the TUI 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.6.6",
    "@opentui/solid": ">=0.6.6",
    "solid-js": ">=1.9.0"
  }
}

Export ./tui beside a server part or GUI part for automatic loading.

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

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

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

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