# TUI

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](/build/plugins), usually beside a [server
part](/build/plugins/server).

```ts title="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.

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

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

Return a cleanup function for resources owned by the plugin.

```ts
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.

```ts
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.

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

## Sessions

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

```ts
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.

```ts
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.

```ts
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.

```ts
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 shells

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

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

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

### Plugins built for OpenTUI 0.5

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](https://opentui.com/docs/releases/0.6.0) list every change.

## Solid components

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

## Markdown

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

```ts
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.

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

```ts
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.

```ts
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.

```ts
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.

```ts
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.

```ts
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.

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

### Select search

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.

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

### Select actions

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.

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

```ts
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.

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

```ts
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.

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

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

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

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

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

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

```ts
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.

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

## Publish and load

Expose the TUI plugin through `./tui`; add OpenTUI peers when the plugin renders 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.6.6",
    "@opentui/solid": ">=0.6.6",
    "solid-js": ">=1.9.0"
  }
}
```

Export `./tui` beside a [server part](/build/plugins/server) or [GUI part](/build/plugins/gui) for automatic loading.

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

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

Configure a TUI-only package in [`cli.json`](/cli/plugins) so it remains active against remote servers.

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