# TUI / CLI Plugins **Docs: ** — fetch this page before writing a TUI plugin. This file is a summary of it. **Loading: ** **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 = () => Ready ``` 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 {context.app.version} } ``` ## 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( () => ( Acme ), () => 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 }) => {String(data?.title ?? "Acme")}, }) 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 }) => {context.data.session.get(sessionID)?.title}, }) context.ui.slot({ append: "home.footer.status", render: () => SYNCED }) ``` 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) => ( ), }) 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 Reviewing {props.panel.sessionID} } ``` ```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: ) 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" /plugins/status/index.ts /plugins/status/tui.ts /.opencode/plugins/status/index.ts /.opencode/plugins/status/tui.ts ```