GUI
A GUI plugin adds panels, commands, settings pages, and native integrations to OpenCode Desktop with the GUI plugin SDK,
@opencode/plugin/gui. It is the ./gui-experimental part of a plugin package, alone or beside a
server part and a TUI part.
The ./gui-experimental file default-exports Plugin.define, like the server and TUI parts:
import { Command, Plugin } from "@opencode/plugin/gui"
import { Acme } from "./rpc"
export default Plugin.define({
i18n: { en: { "command.open": "Open Acme" } },
setup(ctx) {
ctx.add(Command, () => ({
id: "open",
title: ctx.t("command.open"),
run: () => void ctx.client.rpc(Acme).open({}),
}))
},
main(ctx) {
ctx.log.write("info", "acme started")
},
})A GUI plugin can run in two processes:
setupruns in every desktop app window. It contributes panels, commands, settings pages, slots, server sources, link handlers, and styles. See The window.mainruns once, in the Electron main process. It provides typedIpccontracts, native menu items,WebContentsViewembeds, and Node and Electron integrations. See The main process.
Package
A plugin package can ship server, TUI, and GUI code from one npm package. Add one ./gui-experimental export beside
./tui, and OpenCode Desktop loads it for each plugin on its own background server.
{
"name": "opencode-acme-plugin",
"type": "module",
"exports": {
".": "./src/index.ts",
"./tui": "./src/tui.tsx",
"./gui-experimental": "./dist/gui-experimental.cjs"
},
"dependencies": {
"@opencode/plugin": "latest"
},
"devDependencies": {
"solid-js": "^1.9.0",
"vite": "^7.0.0",
"vite-plugin-solid": "^2.11.0"
}
}Users install it like any other plugin; there is no separate GUI install step.
{
"plugins": ["opencode-acme-plugin"],
}The GUI declares no id: it takes the plugin’s, the server part’s id or, for a package without a server part, the
package name (a local directory without a name uses the directory name). Read it as ctx.id.
One file, two processes
Each process evaluates the whole ./gui-experimental file and reads its default export. Windows run setup, and the
desktop app’s main process runs main.
import { shell } from "electron"
import { Plugin } from "@opencode/plugin/gui"
export default Plugin.define({
setup(ctx) {
// Window code: Solid components, ctx.layout, ctx.add(Command, …).
},
main(ctx) {
// Main code: electron, Node built-ins, ctx.windows.
ctx.windows.on("open", () => shell.beep())
},
})Because both processes evaluate the same file, each one stands in inert placeholders for the other process’s modules:
- In a window,
electronand Node built-ins are inert placeholders. - In the main process, Solid and the window-only modules are inert placeholders.
A placeholder accepts any property read, call, or construction at module scope, such as Solid’s hoisted templates or a
bundler’s hoisted require calls. Keep each process’s code inside its own setup: code that runs in the wrong process
gets placeholders instead of working values.
Plugin.define and the contract helpers come from one SDK in both processes. In the main process,
@opencode/plugin/gui resolves to the main SDK, which exports the same Plugin, Store, Ipc, Contract, and
Registry. One definition serves both processes. Its window-only exports, such as Command, bindPlugin, and
createKeyed, are undefined in the main process, so use them only inside setup.
Build the bundle
./gui-experimental must be one prebuilt CommonJS file. Build it with the Vite preset from @opencode/plugin/gui/vite:
import { defineConfig } from "vite"
import { gui } from "@opencode/plugin/gui/vite"
export default defineConfig({ plugins: [gui()] })vite build
The preset does the following:
- Compiles Solid JSX with
vite-plugin-solid. - Emits one CommonJS file and inlines every dynamic
import(), because the app cannot load a second chunk. - Keeps the app’s shared modules, Electron, and Node built-ins external, and bundles every other dependency.
- Fails the build on any other
effect/*,solid-js/*, or@opencode/*module. The app does not provide it, and a bundled copy would not share the app’s instance.
effect/Schema is not a module the OpenCode app provides to plugin GUIs: effect, @opencode/plugin/gui, …
Import from the shared entry instead, for example import { Schema } from "effect".
Preset options
Pass options to gui() to change the entry or the output file.
export default defineConfig({ plugins: [gui({ entry: "src/desktop.tsx", outFile: "dist/desktop.cjs" })] })entryis the file that default-exportsPlugin.define. It defaults tosrc/gui.tsx.outFileis the bundle that the./gui-experimentalexport points to. It defaults todist/gui-experimental.cjs.
Reference
function gui(options?: GuiOptions): Plugin[]
interface GuiOptions {
entry?: string
outFile?: string
}
Dependencies
Add the build tools as devDependencies. The app provides @opencode/plugin/gui at runtime, so the bundle only needs its
types from the @opencode/plugin dependency.
bun add -d vite vite-plugin-solid solid-js
For the Electron and Node types in main, also add electron and @types/node:
bun add -d electron @types/node
Shared modules
Each process provides these modules to the bundle. Requiring a module that neither process provides fails the plugin’s GUI.
import { Schema } from "effect" // Both processes
import { createSignal } from "solid-js" // Windows; an inert placeholder in the main process
import { readFile } from "node:fs/promises" // The main process; an inert placeholder in windows
- Both processes:
effect,@opencode/plugin/gui,@opencode/plugin/gui/main,@opencode/plugin/rpc,@opencode/schema/rpc,@opencode/client, and@opencode/client/effect. - Windows only:
solid-js,solid-js/web,solid-js/store,@opencode/client/solid, and@opencode/plugin/gui/{file,review,browser}. The main process gets inert placeholders. - Main process only:
electronand Node built-ins such asnode:fs. Windows get inert placeholders.
Where GUI entries load
OpenCode Desktop loads GUI entries from its own background server, which already loads the plugins from your config, so
they never add a new source of code. Configure the plugin in the global opencode.json(c) to make its GUI available in
every window.
The desktop app’s main process lists the plugins and reads their bundles from its own server, with a credential that only it holds, and sends windows the same set. A window cannot supply main-process code.
A plugin whose server part fails to load has no GUI.
Reload and updates
Load the plugin from its local directory and rebuild on every change:
vite build --watch
- A local directory whose
package.jsonhas anameandexportsresolves./gui-experimentalthrough them. Without them, it loadsgui-experimental.cjs(or.js) at its root. The entry must be.cjsor.js; any other file is ignored. - Each rebuild reloads the plugin’s GUI in every window and in the main process. The server entry does not restart.
- An installed package’s GUI changes only with its version.
- Changing the plugin’s
optionsinopencode.json(c)reloads the plugin. opencode plugin updatereplaces a package’s GUI once the update finishes.- Settings > Extensions > Plugins lists the plugin with a switch that turns its GUI off. To
remove it, remove it from
opencode.json(c)or runopencode plugin remove <package>.
Define the GUI plugin
Plugin.define declares the systems the plugin runs on, its stored window state, and its copy. The host reads these
declarations before either setup runs.
import { Schema } from "effect"
import { Plugin, Store } from "@opencode/plugin/gui"
export const definition = Plugin.define({
os: ["macos", "linux"],
stores: {
prefs: Store.global(Schema.Struct({ enabled: Schema.Boolean }), { enabled: true }),
},
i18n: {
en: { title: "Acme", "command.open": "Open Acme" },
de: { title: "Acme", "command.open": "Acme öffnen" },
},
})- The host gives the plugin its id,
ctx.id: the server part’sid, or the package name for a package without a server part (the directory name for a local directory without aname). It prefixes every id the plugin creates: commands (<id>.<command>), panel keys (<id>:<tab>), stored keys (plugin.<id>.<key>), and the ids of the contracts and Ipcs it provides. oslimits the plugin to some operating systems. Omit it to run on every OS. A plugin whoseosleaves out this system does not run, and Settings > Extensions > Plugins keeps its row with “Not available on this system.”storesdeclares stored state for windows. Read it asctx.stores.<key>.i18nholds copy by locale. Write every locale inline; English fills every key a locale lacks. Read it withctx.t(key, params)andctx.plural(key, count). In a window,ctx.talso reads the app’s shared keys, such ascommon.*. Inmain, a missing key returns the key.- Define
setup,main, or both. A GUI without either fails to load. - Without setups,
Plugin.definereturns the declarations as aPlugin.Declarations, so setups in other files can be typed from them.Plugin.Definitionis the type of the full definition. - A plugin does not declare the contracts it provides or uses. It provides and reads them at runtime; see Contracts and tokens.
Reference
interface Declarations {
os?: ("macos" | "windows" | "linux")[]
stores?: Record<string, StoreDeclaration>
i18n?: { en: Messages; [locale: string]: Messages }
}
interface Definition extends Declarations {
setup?(ctx: SetupContext<this>): Cleanup | void
main?(ctx: MainContext): Cleanup | void | Promise<Cleanup | void>
}
type Messages = Record<string, string>
type Cleanup = () => void | Promise<void>
Split across files
A small GUI plugin fits in src/gui.tsx. For a larger one, declare the plugin in definition.ts, put shared tokens in
contract.ts, write each setup in its own file, and combine them in src/gui.tsx.
src/
├── index.ts Server part: Plugin.define from @opencode/plugin
├── rpc.ts RPC contract between the server part and its clients
├── gui.tsx ./gui-experimental entry: export default Plugin.define({ ...definition, setup, main })
└── gui/
├── definition.ts Plugin.define: os, stores, i18n
├── contract.ts Tokens other code may import: Ipc, Contract, Registry
├── window.tsx Window setup: Plugin.Setup<typeof definition>
├── main.ts Main setup: Plugin.MainSetup (desktop only)
├── page.tsx Components that panels and pages render
└── i18n/en.ts English copy
The whole ./gui-experimental file evaluates when a window boots, so keep module scope and setup light. Register
contributions there and do expensive work inside components, createKeyed, or createLatest.
For a larger plugin, declare the plugin first, type each setup from the declarations, then define the plugin with them.
import { Schema } from "effect"
import { Plugin, Store } from "@opencode/plugin/gui"
export const definition = Plugin.define({
stores: { notes: Store.global(Schema.Struct({ count: Schema.Number }), { count: 0 }) },
})import type { Plugin } from "@opencode/plugin/gui"
import { Notes } from "./contract"
import type { definition } from "./definition"
export const setup: Plugin.Setup<typeof definition> = (ctx) => {
ctx.provide(Notes, { count: () => ctx.stores.notes.value.count })
}import type { Plugin } from "@opencode/plugin/gui"
export const main: Plugin.MainSetup = (ctx) => {
ctx.log.write("info", "acme started")
}import { Plugin } from "@opencode/plugin/gui"
import { definition } from "./gui/definition"
import { main } from "./gui/main"
import { setup } from "./gui/window"
export default Plugin.define({ ...definition, setup, main })Plugin.Setup<typeof definition>is the type of a window setup. It typesctx.storesfrom the declared stores.Plugin.MainSetupis the type of a main setup, andPlugin.Cleanupthe type of the cleanup function either setup may return.- Import registries, tokens, and helpers such as
Command,SettingsPage,Panel,Slot,Contract,Ipc,Registry,Store,createKeyed,usePlugin, andbindPluginfrom@opencode/plugin/gui. - Import main-only registries such as
MenubarItemfrom@opencode/plugin/gui/main.
Shared context
Both setups receive the plugin’s options, a client for the OpenCode server, and app metadata, like the server and TUI parts.
Options
ctx.options holds the options from the plugin’s opencode.json(c) entry: the same values the server part gets.
{
"plugins": [{ "package": "opencode-acme-plugin", "options": { "team": "core" } }],
}setup(ctx) {
const team = ctx.options.team
}
- Changing the options reloads the plugin.
Server client
ctx.client is the OpenCode client of the server that runs the plugin: the desktop app’s own server. Use it to call
your server part through its RPC.
import { Command } from "@opencode/plugin/gui"
import { Acme } from "./rpc"
ctx.add(Command, () => ({
id: "search",
title: ctx.t("command.search"),
run: async () => {
const result = await ctx.client.rpc(Acme).search({ query: "todo" })
console.log(result)
},
}))
- Read
ctx.clientwhere you call it. It changes after that server restarts. - While the server is unreachable, every call rejects.
App metadata
ctx.app describes the running app.
if (!ctx.app.packaged) console.log(`Acme on ${ctx.app.name} ${ctx.app.version} (${ctx.app.channel})`)
nameis"opencode", as in a server plugin’sctx.app.name.platformis"desktop"in the Electron app and inmain, and"web"in a browser tab.packagedistruewhen the desktop app runs packaged. It is alwaysfalseon the web.- Server plugins get
name,version, andchannel; TUI plugins getversionandchannel.
Reference
interface App {
name: string
version: string
channel: "local" | "dev" | "beta" | "prod"
platform: "web" | "desktop"
packaged: boolean
}
Contracts and tokens
Tokens let GUI plugins talk across processes and to each other without importing each other’s runtime code. Put every
token in contract.ts. A token holds only its kind and id, plus the schemas of an Ipc, so other plugins can import it
without your runtime code.
ctx.provide(token, impl)provides one of your own tokens: aContractfrom the window setup, or anIpcfrommain.ctx.use(token)follows a contract or anIpcthat another plugin provides. It returns aLiveaccessor.ctx.main(ipc)returns a client for anIpcthat your ownmainprovides.
A plugin provides only tokens whose id is its own id or starts with it, as <id>.<name>. ctx.provide throws for any
other id, and ctx.main throws for an Ipc outside your id. The examples on this page use the plugin id acme, so
their token ids start with acme..
Contract.define<AcmeBadge, "acme.badge">("acme.badge") // Plugin acme can provide it.
Contract.define<AcmeBadge, "badge">("badge") // ctx.provide throws in plugin acme.
Contracts
Use Contract.define when one plugin’s window setup exposes an API or a Solid component to other plugins’ window
setups.
import type { JSX } from "solid-js"
import { Contract } from "@opencode/plugin/gui"
export interface AcmeBadge {
Badge(props: { readonly label: string }): JSX.Element
count(): number
}
export const AcmeBadge = Contract.define<AcmeBadge, "acme.badge">("acme.badge")Provide it from the window setup with ctx.provide. When the contract holds a component, wrap it with bindPlugin
during setup. Then usePlugin() inside it reads the provider’s context, not the consumer’s.
import { bindPlugin, Plugin } from "@opencode/plugin/gui"
import { AcmeBadge } from "./gui/contract"
export default Plugin.define({
i18n: { en: { title: "Acme" } },
setup(ctx) {
ctx.provide(AcmeBadge, {
Badge: bindPlugin((props: { readonly label: string }) => (
<span>
{ctx.t("title")}: {props.label}
</span>
)),
count: () => 42,
})
},
})- Annotate the props of a component you wrap with
bindPlugin, as inbindPlugin((props: FileTreeProps) => ...). TypeScript cannot infer them insidectx.provide. - Leave data methods, such as
count, unwrapped. ctx.providereturns a function that withdraws the contract early. The host also withdraws it with the current owner, else when the plugin stops.
Use another plugin’s contract
Export contract.ts from your package, for example as ./contract, so other plugins can import its tokens. Another
plugin follows the contract with ctx.use(token). The accessor’s value changes as the provider starts, stops, and
restarts, so read it where you use it.
import { Plugin, Slot } from "@opencode/plugin/gui"
import { AcmeBadge } from "opencode-acme-plugin/contract"
export default Plugin.define({
setup(ctx) {
const badge = ctx.use(AcmeBadge)
const count = () => {
const live = badge()
return live.status === "active" ? live.value.count() : 0
}
ctx.add(Slot, { at: "window.bottom", render: () => <span>{count()}</span> })
},
})ctx.usereturns the same accessor for every call with the token.- The consumer keeps working while the provider is off. See Live dependencies and failure handling.
IPC between main and windows
Use Ipc.define with Effect Schema codecs to define a typed bridge between main and setup. main implements it
with ctx.provide, and the window calls it through ctx.main.
import { Schema } from "effect"
import { Ipc } from "@opencode/plugin/gui"
export const KeepAwake = Ipc.define({
id: "acme.awake",
methods: {
screenActive: { output: Schema.Boolean },
setScreenActive: { input: Schema.Boolean },
},
})const awake = ctx.main(KeepAwake)
await awake.setScreenActive(true)
- Other plugins follow your
Ipcwithctx.use(KeepAwake), like a contract. - See Define and provide an Ipc for state, events, and the provider.
Registries your plugin owns
Use Registry.define for a list your plugin owns. Other plugins contribute items with ctx.add, and your plugin reads
them with ctx.list.
import { Registry } from "@opencode/plugin/gui"
export interface Exporter {
readonly id: string
readonly title: string
run(text: string): void
}
export const Exporter = Registry.define<Exporter>("acme.exporter")// Another plugin contributes an item.
ctx.add(Exporter, () => ({ id: "gist", title: ctx.t("exporter.gist"), run: (text) => upload(text) }))
// The owner lists every contribution. Reactive: read it in render or in a memo.
const exporters = () => ctx.list(Exporter)
The window
A plugin’s setup runs in every desktop app window. Pass it to Plugin.define from @opencode/plugin/gui in the
plugin’s ./gui-experimental file. setup is synchronous and may return a
cleanup function; see Lifetimes and cleanup.
import { Command, Plugin } from "@opencode/plugin/gui"
export default Plugin.define({
i18n: { en: { open: "Open Acme" } },
setup(ctx) {
ctx.add(Command, () => ({
id: "open",
title: ctx.t("open"),
run: () => ctx.system.openExternal("https://acme.example"),
}))
},
})Components
Inside Solid components, call usePlugin<typeof definition>() for the same context object that setup receives. Type
it from the declarations in their own file (split across files), so the
component and the definition do not import each other.
import { Slot, usePlugin, type Plugin } from "@opencode/plugin/gui"
import type { definition } from "./definition"
function StatusLabel() {
const ctx = usePlugin<typeof definition>()
return <span>{ctx.t("title")}</span>
}
export const setup: Plugin.Setup<typeof definition> = (ctx) => {
ctx.add(Slot, { at: "window.bottom", render: () => <StatusLabel /> })
}usePlugin throws outside a plugin contribution. In a component that another plugin renders, it returns that plugin’s
context, so wrap a component you provide on a contract with bindPlugin; see Contracts.
Registries
Pass a registry token and an item to ctx.add to contribute to the window shell. Pass the item as a function,
() => item | undefined, when it follows reactive state such as the language. The host runs the function again when
what it reads changes. Return undefined to withdraw the item until the function returns one again.
ctx.add(Command, () => ({ id: "open", title: ctx.t("command.open"), run: () => open() }))
- Read labels with
ctx.tinside the function. A label read once, in a fixed item, keeps the language it was read in. - When the item has a field of a literal type, such as
hidden: true, asection, anicon, or a menu name, annotate the function’s return type:(): Command => ({ ... }). Without it, TypeScript widens the field tostringorboolean, and no overload matches. - Pass a fixed item when it never changes, such as a style.
- The host keeps some objects stable: the tabs a panel returns from
list, and items with arender, such as panels and settings pages. Read their labels from getters, such asget title() { return ctx.t("title") }. ctx.addreturns a function that withdraws the item early.
Commands
Command adds an action to the command palette, Settings > Shortcuts, and optionally the composer’s / menu. The
host publishes the command as <plugin id>.<command id>.
import { Command } from "@opencode/plugin/gui"
ctx.add(
Command,
(): Command => ({
id: "toggle",
title: ctx.t("command.toggle"),
keywords: "notes scratch",
group: ctx.t("command.category.view"),
section: "terminal",
bind: "ctrl+`",
slash: { name: "notes", after: "open" },
featured: true,
enabled: !!ctx.sessions.current(),
run: () => {
const session = ctx.sessions.current()
if (session) ctx.layout.toggle(`${ctx.id}:main`, session)
},
}),
)
titleis the palette label,descriptionits second line, andgroupits category, as translated text.keywordsholds extra words the palette matches, separated by spaces.sectionis the Settings > Shortcuts section. It defaults togeneral.bindis the default keybind, for examplemod+shift+t. Users can change it.slashalso offers the command in the/menu. Witharguments: true, the text after the name goes torun(input).hiddenkeeps the command out of the palette, but its keybind still works.suggestedalso lists the command under Suggested while it is enabled.featuredlists it when the palette opens without a query.enabled: falsetakes the command out of the palette and turns its keybind off. It is reactive.scopeis a CSS selector that keyboard focus must be inside for the binding to apply. Witheditable, the binding also fires while a text field has focus.- To run a command from your own code, use
ctx.commands.run; see Run commands.
From a keybind, run can return false to decline the key. The event then goes on to its target, as if the command
had no keybind. This command continues the session when the user presses Enter in an empty prompt, and leaves every
other Enter to the prompt:
ctx.add(Command, () => ({
id: "continue",
title: ctx.t("command.continue"),
bind: "enter",
editable: true,
hidden: true,
run: () => {
const composer = ctx.screen.current()?.composer
const session = ctx.sessions.current()
if (!composer?.focused() || composer.text().trim() || !session) return false
void session.server.client.session.prompt({ sessionID: session.id, text: ctx.t("continue") })
},
}))
- Return
falsesynchronously; a promise handles the key. falsefrom the palette or a slash command does nothing more.
Reference
interface Command {
id: string
title: string
description?: string
keywords?: string
group?: string
section?: "general" | "session" | "navigation" | "model" | "terminal" | "prompt"
bind?: string
slash?: { name: string; arguments?: true; after?: string }
hidden?: true
suggested?: boolean
featured?: true
enabled?: boolean
scope?: string
editable?: true
run(input?: string): void | false | Promise<void>
}
Panels
Panel contributes tabs to a session’s side region (region: "side"). The user can drag a tab to a bottom pane below
the timeline.
import { Panel, type PanelTab } from "@opencode/plugin/gui"
const mainTab: PanelTab = {
id: "main",
get title() {
return ctx.t("panel.title")
},
closable: "compact",
fallback: true,
}
ctx.add(Panel, {
id: "main",
region: "side",
mobile: {
get title() {
return ctx.t("panel.title")
},
order: 45,
kind: "menu",
icon: "file-tree",
},
list: (input) => (input.open.includes("main") ? [mainTab] : []),
render: (props) => <NotesView session={props.session} screen={props.screen} tab={props.tab} />,
})
All panel callbacks (list, render, focus, enter, close, closed, restore, normalize) receive a single
input object:
- Read
props.sessionandprops.tabas reactive getters where you use them. Never destructure them at the top ofrender: when the user switches from session A to session B, the panel stays mounted andprops.sessionupdates in place. props.screenis the constant owningSessionScreen(file,comment,composer). Its actions always target the session that is routed.- Return the same
PanelTabobject fromlistwhile a tab is unchanged, so the host does not remount its trigger. Read its labels from getters, so they follow the language without a new object.
A panel’s callbacks:
listreturns the tabs to show.input.openholds this plugin’s tab ids stored in the session. It is reactive.renderrenders a tab’s content once.focusruns when a tab is selected.restoredistruefor the selection the region mounts with.enterruns when the user opens the side region on the tab with its toggle, once the tab is on screen. Move keyboard focus to the tab’s own target, such as a text field. When focus is still outside the region, the host focuses the region.closeruns after the host removes a tab.closedandrestorelet the user reopen a closed tab; see Reopen closed tabs.normalizereturns the canonical form of a stored tab id that can be stored more than one way.
A panel’s options:
mobileadds a narrow-screen view of the panel. Withcached: true, the view stays mounted while the user shows another view. By default, the view mounts each time it shows.wideasks for the wider session minimum while the side region is open.transientpanels’ tabs are not restored.
A tab’s options:
idis the tab id; its host key is<plugin id>:<id>.titleis its accessible name, andlabelrenders custom trigger content.pinnedtabs are listed without being opened, come first, and are never closed or dragged.draggablelets the user reorder the tab. It defaults totrue.closablesets the close button’s look:"compact","hover", orfalsefor none.transientmarks a launcher tab, such as Open file, that the next preview replaces.fallbackopts into fallback selection when the stored selection is missing.- Tabs in one
groupshare one render that stays mounted. missingstrikes the tab through, for example for a file that no longer exists.hiddenkeeps a restored tab listed and selectable but not drawn, while its content is not known yet.fileis the workspace path the tab shows, listed as one of the session’s open files.tabbableputs the tab panel in the tab order, for content without focusable elements.
Reference
interface Panel {
id: string
region: "side"
mobile?: MobileView
wide?: boolean
transient?: boolean
list(input: { session: MountedSession; screen: SessionScreen; open: string[] }): PanelTab[]
render(props: PanelProps): JSX.Element
focus?(input: PanelProps & { restored: boolean }): void
enter?(input: PanelProps): void
close?(input: PanelProps): void
closed?(input: PanelProps): ClosedPanelTab | false | undefined
restore?(input: { session: MountedSession; screen: SessionScreen; id: string; data?: string }): void
normalize?(input: { id: string; session: MountedSession; screen: SessionScreen }): string
}
interface PanelProps {
tab: PanelTab
session: MountedSession
screen: SessionScreen
}
interface PanelTab {
id: string
title: string
label?(state: { active: boolean; preview: boolean; session: MountedSession }): JSX.Element
pinned?: boolean
draggable?: boolean
closable?: "compact" | "hover" | false
transient?: boolean
fallback?: boolean
group?: string
missing?: boolean
hidden?: boolean
file?: string
tabbable?: boolean
}
interface MobileView {
title: string
order: number
kind: "tab" | "menu" | "drawer"
icon?: IconName
cached?: boolean
}
Reopen closed tabs
The host keeps an entry for each side tab the user closes. While focus is in the side region, ⌘⇧T reopens the newest
one. The side region’s New tab view lists closed tabs that show a file under Recently closed. Add closed and
restore to control how a tab reads there and how it comes back.
ctx.add(Panel, {
id: "page",
region: "side",
list: (input) => input.open.map(pageTab),
render: (props) => <PageView tab={props.tab} />,
closed: (input) => {
const url = pages.get(input.tab.id)
return url ? { icon: "globe", detail: new URL(url).host, data: url } : false
},
restore: (input) => {
if (input.data) openPage(input.session, input.data)
},
})
closedruns as the user closes a tab. Capture what reopening needs now: return how the row reads and thedatathatrestoreneeds, orfalsewhen the tab cannot come back.titledefaults to the tab’s title, andiconto the panel’s mobile icon. A tab with afileshows its file icon, and its folder asdetail.- The host stores the entry, so keep
datasmall, such as a URL. restorereceives the closed tab’sidand thedatathatclosedreturned. Withoutrestore, the host opens the same key again.- Without
closed, a tab that is nottransientlists under its title and reopens under the same key.
Reference
interface ClosedPanelTab {
title?: string
icon?: IconName
detail?: string
data?: string
}
Settings pages
SettingsPage contributes a page to Settings, a section on a host page (page: "general" or page: "servers"), or
rows in the host’s General section (page: "general", section: "general").
import { SettingsPage } from "@opencode/plugin/gui"
import { AcmeSettings } from "./settings"
ctx.add(SettingsPage, {
id: "acme",
icon: "server",
get title() {
return ctx.t("title")
},
get entries() {
return [{ id: "sync", title: ctx.t("settings.sync"), keywords: "sync remote" }]
},
render: (input) => <AcmeSettings highlight={input.target} />,
})
idis the page’s settings tab value (/settings?tab=<id>) and the argument toctx.layout.settings.pageadds a section to a host page instead of a page. Withsection: "general", it adds rows to that page’s General section.titleandiconare a page’s navigation label and icon.entriesis search metadata, indexed without mounting the page. When search reveals an entry,renderreceives its id astarget.- A settings page has a
render, so the host keeps it stable. Read its labels and entries from getters.
Reference
interface SettingsPage {
id: string
page?: "general" | "servers"
section?: "general"
title: string
icon?: IconName
entries?: SettingEntry[]
render(input: { target?: string }): JSX.Element
}
interface SettingEntry {
id: string
title: string
description?: string
keywords?: string
}
Slots
Slot renders UI into one of four window slots.
import { Slot } from "@opencode/plugin/gui"
ctx.add(Slot, {
at: "session.header",
order: 10,
render: (input) => <UsageRing session={input.session} active={input.active} />,
})
window.bottomis a full-width strip at the bottom of the window, above toasts.session.headeris the session timeline’s title row. A cached timeline stays mounted while hidden, withactivefalse.session.location.menuadds items to the project menu, after its location details.session.panel.endholds actions at the trailing end of the side region’s tab strip.ordersorts the items in one slot.
session.location.menu receives shown, which is true while the project menu is open. Subscribe to location data
only while it is true:
ctx.add(Slot, {
at: "session.location.menu",
render: (input) => <BranchStatus session={input.session} screen={input.screen} shown={input.shown} />,
})
Reference
type Slot = {
[At in keyof SlotMap]: { at: At; order?: number; render(input: SlotMap[At]): JSX.Element }
}[keyof SlotMap]
interface SlotMap {
"window.bottom": Record<string, never>
"session.header": { screen: SessionScreen; session: MountedSession; active: boolean }
"session.location.menu": { screen: SessionScreen; session: MountedSession; shown: boolean }
"session.panel.end": { screen: SessionScreen; session: MountedSession }
}
Menu items
MenuItem adds an entry to the side panel’s + menu ("session.panel"), the Add server menu ("server.add"),
or each server’s row menu in Settings ("server.row").
import { MenuItem } from "@opencode/plugin/gui"
ctx.add(MenuItem, (): MenuItem => {
const session = ctx.sessions.current()
return {
menu: "session.panel",
id: "open",
title: ctx.t("menu.open"),
icon: "folder",
keybind: `${ctx.id}.open`,
opened: !!session && ctx.layout.state(`${ctx.id}:main`, session) !== "closed",
run: () => {
if (session) ctx.layout.open(`${ctx.id}:main`, session)
},
}
})
ordersorts the items in ascending order. It defaults to0.- A
server.rowitem’srunreceives the row’s server key.whenlists the item only on some rows, andenabledshows it disabled on others.
A session.panel item is also a launcher. When the user opens the side region with no tabs, or clicks + while no
item is primary, the host shows a New tab view that lists the launchers.
iconis the item’s icon, andkeybinda published command id whose shortcut the item shows.descriptionis a short muted detail after the title in a launcher row, such as a count.opened: truemeans the tab the item opens is already open. Launcher lists leave the item out, and the + menu still lists it.primary: truemakes + run the item directly instead of showing the launchers. The first primary item inorderwins. Opening the side region with no tabs still shows the New tab view, which lists the items that are not primary and not open.ctx.layout.side.menu()lists every plugin’ssession.panelitems, for a launcher of your own; see Layout.
Reference
type MenuItem = SessionPanelMenuItem | ServerAddMenuItem | ServerRowMenuItem
interface SessionPanelMenuItem {
menu: "session.panel"
id: string
title: string
order?: number
icon?: IconName
keybind?: string
description?: string
opened?: boolean
primary?: true
run(): void
}
interface ServerAddMenuItem {
menu: "server.add"
id: string
title: string
order?: number
run(): void
}
interface ServerRowMenuItem {
menu: "server.row"
id: string
title: string
order?: number
when?(server: string): boolean
enabled?(server: string): boolean
run(server: string): void
}
Titlebar items
TitlebarItem renders a pill button in the window titlebar or tabs footer. Its label shows on hover.
import { TitlebarItem } from "@opencode/plugin/gui"
ctx.add(TitlebarItem, (): TitlebarItem | undefined =>
syncing() ? { id: "sync", label: ctx.t("syncing"), icon: "server", busy: true, run: () => sync() } : undefined,
)
titleis the accessible name when it differs fromlabel.busyshows a spinner and disables the pill.pressedshows it pressed.runruns on click. Omit it for an item that only shows.
Reference
interface TitlebarItem {
id: string
label: string
title?: string
icon?: IconName
busy?: boolean
pressed?: boolean
run?(): void
}
Composer pills
ComposerPill adds a pill to the row above a session’s composer, beside the app’s background work and todo pills.
Use it for status that belongs to the session the user is working in, such as the pull requests of its branch. Pills
float over the timeline, so a pill that comes or goes never resizes the composer. The row follows the user’s background
activity alignment, and it does not show in a subagent’s session.
show returns the pill for one session, or undefined to show none there. The host runs it for each mounted
composer, and again when what it reads changes. This plugin lists the open pull requests of each session’s directory
through its server part’s RPC, and shows them as one pill:
import { createStore } from "solid-js/store"
import { ComposerPill, createKeyed } from "@opencode/plugin/gui"
import { GitHub, type Pull } from "./rpc"
const [pulls, setPulls] = createStore<Record<string, readonly Pull[]>>({})
createKeyed(
() => ctx.sessions.current()?.directory,
(directory) =>
void ctx.client
.rpc(GitHub)
.pulls({ directory })
.then((list) => setPulls(directory, list)),
)
ctx.add(ComposerPill, {
id: "pulls",
show: (input) => {
const list = pulls[input.session.directory] ?? []
if (list.length === 0) return undefined
return {
icon: "github",
label: ctx.plural("pulls", list.length),
tone: list.some((pull) => pull.checks === "failure") ? "danger" : undefined,
title: ctx.t("pulls.title"),
rows: list.map((pull) => ({
id: String(pull.number),
label: `#${pull.number} ${pull.title}`,
icon: pull.checks === "failure" ? "circle-x" : "circle-check",
tone: pull.checks === "failure" ? "danger" : "success",
detail: pull.branch,
run: () => ctx.system.openExternal(pull.url),
})),
}
},
})
labelis the pill’s text and truncates when long.iconis optional.tonecolors the icon:"success","warning"or"danger".rowsmake the pill open a menu under the headingtitle, orlabelwithout one. Each row has its ownicon,toneand muteddetail, and runsrunwhen picked; a row withoutrunonly shows.- Without
rows, the pill runsrunon click. titleis also the pill’s accessible name when it differs fromlabel.ordersorts your pills among other plugins’ pills, lowest first. The app’s own pills come first.- A
showthat throws fails only your plugin’s contribution.
Reference
interface ComposerPill {
id: string
order?: number
show(input: ComposerPillProps): ComposerPillView | undefined
}
interface ComposerPillProps {
session: MountedSession
screen: SessionScreen
}
interface ComposerPillView {
label: string
icon?: IconName
tone?: ComposerPillTone
title?: string
rows?: ComposerPillRow[]
run?(): void
}
interface ComposerPillRow {
id: string
label: string
icon?: IconName
tone?: ComposerPillTone
detail?: string
run?(): void
}
type ComposerPillTone = "success" | "warning" | "danger"
Link handlers
LinkHandler opens local file or custom links that users click in messages or composer chips. The handler with the
highest priority whose match returns true opens the link.
import { LinkHandler } from "@opencode/plugin/gui"
ctx.add(LinkHandler, {
priority: 10,
match: (link) => link.href.endsWith(".acme"),
open: (link) => openAcmeFile(link.href, link.session),
exists: (link) => acmeFileExists(link.href),
})
hrefis the target: a path, a URL, or afile://link.originis the plugin that produced the linked item.exactmeans the path is a known workspace file, not a guess from text.- A
backgroundlink must not switch the narrow-screen view. baseis the workspace-relative directory the link was written in.- Without
exists,ctx.links.existsanswersfalse, so text never looks like a link the handler cannot open.
Reference
interface LinkHandler {
priority?: number
match(link: Link): boolean
open(link: Link): void
exists?(link: Link): boolean | Promise<boolean>
}
interface Link {
href: string
origin?: string
exact?: boolean
background?: boolean
base?: string
session?: SessionRef
}
Servers
Server contributes a list of server connections, such as servers your plugin starts on other machines.
import { Server } from "@opencode/plugin/gui"
ctx.add(
Server,
(): Server => ({
ready: loaded(),
entries: machines().map((machine) => ({
id: machine.id,
name: machine.name,
label: "Acme",
state: machine.url ? "ready" : "starting",
http: machine.url ? { url: machine.url } : undefined,
})),
}),
)
- Startup waits for every source to be
ready. A ready source’s entries are its complete inventory. ordersorts the sources in ascending order.- Each entry’s key is
<plugin id>:<id>.labelis a short badge beside itsname. authmeans the server waits for the user to sign in, andincompatiblethat it runs a version this app does not support.listed: falsekeeps the entry out of the app’s server list. Settings still shows it.httpis the endpoint, once the server isready.reconnectresolves a dropped endpoint again,connectprepares a server that is not ready, andremoveruns before the entry is removed.rowrenders the server’s settings row, andcovercovers the routed session while the server is not ready.- A
rowreceives the host’s statusIndicator, the latesthealth()check, and the row’sserver.rowmenuItems. Itsremove()runs the entry’sremove, then closes the server’s tabs.
Reference
interface Server {
ready: boolean
order?: number
entries: ServerEntry[]
}
interface ServerEntry {
id: string
name: string
label?: string
state: "stopped" | "starting" | "auth" | "ready" | "failed" | "incompatible"
listed?: boolean
http?: { url: string; password?: string }
reconnect?(signal: AbortSignal): Promise<{ url: string; password?: string }>
connect?(): Promise<boolean>
remove?(): Promise<void>
row?(row: ServerRow): JSX.Element
cover?(input: { tab: string }): JSX.Element
}
interface ServerRow {
key: string
health(): ServerHealth | undefined
Indicator(props: { health?: ServerHealth; connecting?: boolean; auth?: boolean }): JSX.Element
remove(): Promise<void>
Items(): JSX.Element
}
interface ServerHealth {
healthy: boolean
version?: string
incompatible?: boolean
checking?: boolean
}
Styles
Style injects a CSS string into the document while the contribution is active.
import { Style } from "@opencode/plugin/gui"
import css from "./panel.css?inline"
ctx.add(Style, css)
Host APIs
Host APIs are properties of ctx. None of them throw before the app interface mounts: reads return their documented
defaults, and early writes, such as ctx.layout.open, ctx.dialogs.open, and store updates, wait and apply in call
order once the first route renders.
ctx.add(Command, () => ({
id: "copy-directory",
title: ctx.t("command.copyDirectory"),
run: () => {
const session = ctx.sessions.current()
if (session) void ctx.system.copy(session.directory)
},
}))
The sections below cover each API. ctx.options, ctx.client, and ctx.app are the
shared context, ctx.provide, ctx.use, and ctx.main work with
contracts and tokens, and ctx.stores holds the stores the plugin
declares.
Reference
interface SetupContext<D> extends Context {
stores: Record<string, Persisted<unknown> | ((session: SessionRef) => Persisted<unknown>)>
}
interface Context {
id: string
options: PluginOptions
client: OpenCodeClient
app: App
signal: AbortSignal
add<T>(registry: Registry<T>, item: () => T | undefined): Cleanup
add<T>(registry: Registry<T>, item: T): Cleanup
list<T>(registry: Registry<T>): T[]
provide<T>(token: Contract<T>, impl: T): Cleanup
use<T>(token: Contract<T>): () => Live<T>
use<S extends IpcSpec>(token: Ipc<S>): () => Live<IpcClient<S>>
main<S extends IpcSpec>(token: Ipc<S>): IpcClient<S>
t(key: string, params?: Record<string, string | number | boolean>): string
plural(key: string, count: number, params?: Record<string, string | number | boolean>): string
layout: Layout
sessions: Sessions
screen: Screen
servers: Servers
storage: Storage
dialogs: Dialogs
toast: Toasts
attention: Attention
tabs: Tabs
keybinds: Keybinds
commands: Commands
links: Links
embeds: Embeds
ui: Ui
system: System
desktop: Desktop | undefined
locale: Locale
appearance: Appearance
router: Router
workspaces: Workspaces
}
Layout
ctx.layout.open(key, session, options?) opens a panel tab by its key, <plugin id>:<tab id>. It works for sessions
that are not mounted.
const session = ctx.sessions.current()
if (session) ctx.layout.open(`${ctx.id}:main`, session, { tab: "select", background: true })
tab: "open", the default, selects the tab in the preview slot."preview"makes it the new preview,"append"adds it without selecting it, and"select"adds and selects it, keeping the preview.background: truekeeps the current view on narrow screens and opens the side region, for an action the user did not start from the panel.close(key, session)removes the tab and calls its panel’sclose.toggle(key, session)closes a visible tab, else opens it.hide(key, session)hides the pane that shows a visible tab, without closing the tab: the side region, or the bottom pane for a tab the user moved there. Opening the tab again shows its pane.state(key, session)is"closed"when the tab is not in the strip,"open"when it is but its pane shows another tab,"active"when its pane shows it but the pane is hidden (the side region is closed, or the bottom pane is hidden), and"visible"when it is on screen.stored(session)is this plugin’s tab ids stored in the session’s side strip.ready()istrueonce the stored layout has loaded, andnarrow()while the viewport is under 768px.side.opened(session)istruewhile the side region is open, andside.toggle(session)opens or closes it.side.menu()lists every plugin’ssession.panelmenu items inorder, for a launcher of your own.sidebar.opened()is the inner sidebar preference that every side panel shares.scrollstores scroll offsets per session and key.settings(page?)opens Settings.project(server, title)opens a project’s directory picker and a new draft.draft({ server, directory, prompt })opens a new draft session in its own tab, with the prompt filled in for the user to review and send.
const session = ctx.sessions.current()
const launchers = () => ctx.layout.side.menu().filter((item) => !item.primary && !item.opened)
if (session) {
ctx.layout.draft({ server: session.server.id, directory: session.directory, prompt: ctx.t("setup.prompt") })
}
Reference
interface Layout {
open(key: string, session: SessionRef, options?: OpenOptions): void
close(key: string, session: SessionRef): void
toggle(key: string, session: SessionRef): void
hide(key: string, session: SessionRef): void
state(key: string, session: SessionRef): PanelState
stored(session: SessionRef): string[]
ready(): boolean
narrow(): boolean
side: {
opened(session: SessionRef): boolean
toggle(session: SessionRef): void
menu(): SessionPanelMenuItem[]
}
sidebar: {
opened(): boolean
}
scroll: {
get(session: SessionRef, key: string): { x: number; y: number } | undefined
set(session: SessionRef, key: string, value: { x: number; y: number }): void
}
settings(page?: string): void
project(server: string, title: string): void
draft(input: { server: string; directory: string; prompt: string }): void
}
type PanelState = "closed" | "open" | "active" | "visible"
interface OpenOptions {
tab?: "open" | "preview" | "append" | "select"
background?: boolean
}
Sessions and the session screen
ctx.sessions.current() is the routed session, and ctx.screen.current() is its screen. Both are undefined on Home
and on a draft. Read them where you act, never once in setup.
const session = ctx.sessions.current()
const file = ctx.screen.current()?.file
if (session && file) void file.sync("README.md")
keyis<server id>\n<session id>, unique across servers. Key per-session state by it.tabis the key of the shell tab that owns the session.pendingistruewhile the server still creates it.directoryis the session’s workspace directory, andlocalistruewhen that is the project root.listedProjectis the sidebar project the session belongs to.visitis a new object each time the session is routed.backgroundlists the shell commands and subagents the session moved to the background.ctx.sessions.list()returns the sessions of the open shell tabs.- The screen’s
filereads the workspace’s files, andcommentholds the line comments of the next prompt.composeris the next prompt: its text, attached parts, and model; see Attach to the composer. file.sync(path)loads a file and asks the server to watch it for changes. Callfile.unwatch(path)when the file’s tab closes.
Reference
interface Sessions {
list(): SessionRef[]
current(): MountedSession | undefined
}
interface SessionRef {
key: string
id: string
tab: string
server: ServerRef
pending: boolean
location: LocationRef | undefined
}
interface MountedSession extends SessionRef {
visit: object
project: Project | undefined
listedProject: { worktree: string; name?: string; icon?: Project["icon"] } | undefined
directory: string
local: boolean
background: BackgroundTask[]
}
interface Screen {
current(): SessionScreen | undefined
}
interface SessionScreen {
file: Files
comment: Comments
composer: Composer
}
interface Files {
root: string
ready(): boolean
resolve(path: string): string
absolute(path: string): boolean
get(path: string): FileState | undefined
missing(path: string): boolean
sync(path: string, options?: { force?: boolean; silent?: boolean }): Promise<void>
unwatch(path: string): void
exists(path: string): Promise<boolean>
search(query: string, options?: { kind?: "file" | "any"; limit?: number; signal?: AbortSignal }): Promise<string[]>
selection: {
get(path: string): LineRange | null | undefined
set(path: string, range: LineRange | null): void
}
scroll: {
get(path: string): { top?: number; left?: number }
set(path: string, value: { top?: number; left?: number }): void
}
tree: {
list(path: string): FileNode[]
state(path: string): { expanded: boolean; loaded?: boolean; loading?: boolean; error?: string } | undefined
sync(path: string, options?: { force?: boolean }): Promise<void>
expand(path: string, options?: { list?: boolean }): void
collapse(path: string): void
}
}
interface Comments {
list(file?: string): Comment[]
add(input: Omit<Comment, "id" | "time">): Comment
update(id: string, comment: string): void
remove(id: string): void
focus: {
current(): { file: string; id: string } | null
set(value: { file: string; id: string } | null): void
}
active: {
current(): { file: string; id: string } | null
set(value: { file: string; id: string } | null): void
}
}
Attach to the composer
Use ctx.screen.current()?.composer in an event handler or command to attach a file, a note, or a quote to the user’s
next prompt.
const composer = ctx.screen.current()?.composer
composer?.attach({
type: "note",
origin: ctx.id,
commentID: crypto.randomUUID(),
label: "button#submit",
icon: "globe",
subject: 'element <button id="submit">Save</button>',
comment: "Make this button primary and disable it while saving.",
href: "http://localhost:3000/settings",
})
attachleaves keyboard focus where it is. Callfocus()to move it to the prompt.update(commentID, { comment?, preview? })changes an attached part; a note or a quote takes only a newcomment.detach(commentID)removes an attached part.- A note’s
livereplaces itssubjectandhrefwhile the note stays in this app process, for references only this process can resolve, such as a page element. A stored draft drops it, and so does a sent message that a revert or a fork restores.
A quote is text the user quoted from something your plugin shows, such as log output, with an optional comment. It
renders like a quote from the session timeline, under the source’s icon and label. Check quotable() first: it is
false in a subagent’s session, which has no composer, and while the composer runs shell commands.
const composer = ctx.screen.current()?.composer
if (composer?.quotable()) {
const commentID = crypto.randomUUID()
composer.attach({
type: "quote",
commentID,
text: selection,
source: { label: ctx.t("source.label"), icon: "console", subject: "the Acme log" },
})
composer.focus(commentID)
}
source.subjectnames the source for the model, for examplethe terminal. It is not localized.- Omit
commentto let the user add one from the chip. focus(commentID)opens the comment editor of an attached quote. While the composer is not shown, such as on a narrow screen that shows a panel, the editor opens once the composer shows.- The model reads
The user quoted the following from <subject>:, then the text with each line prefixed with>, then the comment, if any.
Reference
interface ComposerFile {
type: "file"
path: string
selection?: { startLine: number; endLine: number; startChar: number; endChar: number }
preview?: string
comment?: string
commentID?: string
commentOrigin?: "review" | "file"
}
interface ComposerNote {
type: "note"
origin: string
commentID: string
label: string
icon: IconName
subject: string
comment: string
href?: string
live?: { subject: string; href?: string }
}
interface ComposerQuote {
type: "quote"
commentID: string
text: string
comment?: string
source: { label: string; icon: IconName; subject: string }
}
Read and write the prompt
Read what the user typed with text(), and whether they are typing with focused():
const composer = ctx.screen.current()?.composer
const empty = !composer?.text().trim()
text()is the prompt as plain text: a mention reads as its@text, and attachments and attached parts are left out. In shell mode, it is the shell command. It is""until the session’s draft loads, and in a subagent’s session, which has no composer. It is reactive.focused()istruewhile keyboard focus is in the prompt. It is not reactive, so read it where you act, such as in a command’srun.
insert(text) types text into the prompt for the user to review and send. Nothing on the composer sends the prompt.
const composer = ctx.screen.current()?.composer
composer?.insert(`Explain ${path}`)
composer?.focus()
insertadds the text at the cursor the user last left in the prompt, else at the end, and moves the cursor after it. It does not replace selected text.- The text is not parsed:
@pathstays text, not a mention. setText(text)replaces the prompt’s text, mentions included. Attachments and attached parts stay.setText("")clears the text.- Both leave keyboard focus where it is. Call
focus()to move it to the prompt. - A write made before the session’s stored draft loads (
insert,setText,attach,update, ordetach) waits for it. It then applies to the session that was routed when you called it, in call order. It never replaces the stored draft. - In a subagent’s session, which has no composer, the writes do nothing.
Choose the model
model() is the model the next prompt is sent with, and setModel selects one as the model picker does:
const composer = ctx.screen.current()?.composer
if (composer && !composer.setModel({ providerID: "anthropic", modelID: "claude-sonnet-4-5", variant: "high" })) {
ctx.toast.show({ variant: "error", title: ctx.t("model.unavailable") })
}
model()is the user’s choice for the routed session, else the agent’s default, else the app’s default. It isundefinedwhile the model catalog loads, when no connected provider offers the model, and in a subagent’s session. It is reactive.setModelmakes the model the session’s choice and its first recent model. Withoutvariant, the model keeps the variant the picker would show for it.setVariant(variant)selects a variant of the current model.undefinedselects the model’s default variant.setModelreturnsfalse, and changes nothing, while the catalog loads, when no connected provider offers the model, or when the model has no such variant.setVariantreturnsfalse, and changes nothing, when there is no current model or the model has no such variant.- Both return
falsein a subagent’s session.
Reference
interface Composer {
text(): string
insert(text: string): void
setText(text: string): void
model(): ComposerModel | undefined
setModel(model: ComposerModel): boolean
setVariant(variant: string | undefined): boolean
attach(part: ComposerFile | ComposerNote | ComposerQuote): void
quotable(): boolean
focus(commentID?: string): void
update(id: string, patch: { comment?: string; preview?: string }): void
detach(id: string): void
focused(): boolean
}
interface ComposerModel {
providerID: string
modelID: string
variant?: string
}
Servers and server events
ctx.servers.get(id) returns a live ServerRef. The desktop app’s own server, which runs your plugin, has the id
"sidecar". React to its events with data.on:
import { onCleanup } from "solid-js"
import { createKeyed } from "@opencode/plugin/gui"
createKeyed(
() => ctx.servers.get("sidecar")?.data,
(data) => onCleanup(data.on("session.created", (event) => console.log(event))),
)
idis the host key:"sidecar", an http URL, or<plugin id>:<id>for a contributed server.datais the server’s synced data, such as sessions and projects, andon(type, handler)for its events. It is reactive.builtinistruefor the app’s own server.compatibleistruewhen the server’s version works with this app, andconnectedwhile its event connection is up.passwordis the HTTP basic password the user configured, if any.- Read
client,data, andurlfrom the liveServerRefeach time: a restarted server gets a new client under the same id. ctx.servers.list()returns the ids of the servers the app lists.
Reference
interface Servers {
list(): string[]
get(id: string): ServerRef | undefined
}
interface ServerRef {
id: string
name: string
url: string
password?: string
client: OpenCodeClient
data: Data
local: boolean
builtin: boolean
compatible: boolean
connected: boolean
}
Dialogs
ctx.dialogs.open(render, { replace? }) opens a dialog above the open ones and returns a handle whose close() closes
it. The dialog also closes when its owning scope ends.
const handle = ctx.dialogs.open((dialog) => <ConfirmDialog onDone={dialog.close} />)
replace: truecloses every open dialog first, including the host’s and other plugins’ dialogs.ctx.dialogs.active()istruewhile any dialog is open.- Render the content in
ctx.ui.Dialogfor the app’s dialog frame; see App components.
Ask the user
confirm, alert, prompt, and select ask in the app’s own dialogs, so your plugin needs no dialog UI of its own.
Each returns a promise of the answer.
const ok = await ctx.dialogs.confirm({ title: ctx.t("end.title"), confirm: ctx.t("end"), danger: true })
confirmresolvestrueonly when the user confirms.dangerstyles the confirm button as destructive and starts focus on Cancel.- Each dialog opens as
opendoes, and closes with the owner that asked, else with your plugin. - Cancelling, pressing Escape, clicking outside, a replacing dialog, or the owner ending resolves
falsefromconfirmandundefinedfrompromptandselect.
alert tells the user something they must acknowledge, with one button. It resolves once the dialog closes, however it
closes:
await ctx.dialogs.alert({ title: ctx.t("export.done"), description: path })
prompt asks for one line of text. Its initial value is selected, so typing replaces it. It never resolves an empty
string: submitting does nothing while the field is empty. secret: true hides what the user types, for a password or a
token:
const token = await ctx.dialogs.prompt({ title: ctx.t("token.title"), placeholder: "ghp_…", secret: true })
select lists options in a searchable list, like the command palette, and resolves the picked option’s value:
const url = await ctx.dialogs.select({
title: ctx.t("links.title"),
options: links.map((link) => ({ title: link.title, description: link.url, value: link.url, group: link.host })),
})
if (url) ctx.system.openExternal(url)
- Search matches each option’s
titleanddescription. - Options with the same
grouplist together under that heading. currentis the value of the option to highlight first.
Reference
interface Dialogs {
open(render: (dialog: DialogHandle) => JSX.Element, options?: { replace?: boolean }): DialogHandle
confirm(options: ConfirmOptions): Promise<boolean>
alert(options: AlertOptions): Promise<void>
prompt(options: PromptOptions): Promise<string | undefined>
select<T>(options: SelectOptions<T>): Promise<T | undefined>
active(): boolean
}
interface DialogHandle {
close(): void
}
interface ConfirmOptions {
title: string
description?: string
confirm?: string
cancel?: string
danger?: boolean
}
interface AlertOptions {
title: string
description?: string
ok?: string
}
interface PromptOptions {
title: string
description?: string
placeholder?: string
value?: string
secret?: boolean
submit?: string
}
interface SelectOptions<T> {
title: string
placeholder?: string
options: { title: string; value: T; description?: string; group?: string }[]
current?: T
}
App components
A plugin bundle cannot import @opencode/ui, so ctx.ui gives it the app’s own components. They render in the
user’s theme, fonts, and language, so your plugin’s UI looks and behaves like the app’s.
import { Show } from "solid-js"
import { createLatest, Panel } from "@opencode/plugin/gui"
const ui = ctx.ui
const summary = createLatest(
() => ctx.sessions.current()?.directory,
(directory, signal) => fetchSummary(directory, signal),
)
ctx.add(Panel, {
id: "summary",
region: "side",
list: () => [summaryTab],
render: () => (
<div>
<ui.Tooltip value={ctx.t("copy")}>
<ui.IconButton icon="copy" label={ctx.t("copy")} onClick={() => void ctx.system.copy(summary.latest ?? "")} />
</ui.Tooltip>
<Show when={summary.latest} fallback={<ui.Loader size="large" />}>
{(text) => <ui.Markdown text={text()} />}
</Show>
</div>
),
})
Icon,Button, andIconButtontake anyIconName, as everyiconfield of the SDK does. AnIconButton’slabelis its accessible name; wrap it inTooltipto show it on hover.busyon a button replaces its icon with the loading indicator and disables it until it isfalseagain.- Use
Loaderfor every loading state that is not the AI working, such as a pane, a search, or a connection that loads. Menuopens a list ofitems: actions,{ type: "separator" }, and{ type: "group", label, items }.SwitchandTextInputkeep no state: pass the value fromonChangeoronInputback tocheckedorvalue.Keybindshows keys as chips, such asctx.keybinds.keybind(command).Markdownrenders as the timeline renders a message, andCodeshows a highlighted, read-only code block. Both fill in shortly after they mount.
Dialog is the app’s dialog frame: a title, content, and buttons. Render it from ctx.dialogs.open; elsewhere it
renders nothing useful.
const ui = ctx.ui
ctx.dialogs.open((dialog) => (
<ui.Dialog
title={ctx.t("rename.title")}
footer={
<ui.Button variant="contrast" onClick={dialog.close}>
{ctx.t("done")}
</ui.Button>
}
>
<ui.TextInput label={ctx.t("rename.label")} value={name()} onInput={setName} onSubmit={dialog.close} autofocus />
</ui.Dialog>
))
sizeis"fit"by default: 480px wide and as tall as its content."normal","large", and"x-large"are fixed sizes.- Escape and a click outside close the dialog.
closable: falsehides the header’s close button.
Reference
interface Ui {
Icon(props: UiIconProps): JSX.Element
Button(props: UiButtonProps): JSX.Element
IconButton(props: UiIconButtonProps): JSX.Element
Tooltip(props: UiTooltipProps): JSX.Element
Menu(props: UiMenuProps): JSX.Element
Loader(props: UiLoaderProps): JSX.Element
Dialog(props: UiDialogProps): JSX.Element
Switch(props: UiSwitchProps): JSX.Element
TextInput(props: UiTextInputProps): JSX.Element
Keybind(props: UiKeybindProps): JSX.Element
Markdown(props: UiMarkdownProps): JSX.Element
Code(props: UiCodeProps): JSX.Element
}
interface UiIconProps {
name: IconName
size?: "small" | "normal" | "large"
class?: string
}
interface UiButtonProps {
children: JSX.Element
onClick?: (event: MouseEvent) => void
variant?: "neutral" | "contrast" | "ghost" | "ghost-muted" | "danger"
size?: "small" | "normal" | "large"
icon?: IconName
disabled?: boolean
busy?: boolean
type?: "button" | "submit"
autofocus?: boolean
class?: string
}
interface UiIconButtonProps {
icon: IconName
label: string
onClick?: (event: MouseEvent) => void
variant?: "neutral" | "ghost" | "ghost-muted"
size?: "small" | "normal" | "large"
disabled?: boolean
pressed?: boolean
busy?: boolean
class?: string
}
interface UiTooltipProps {
value: JSX.Element
keys?: string[]
placement?: "top" | "bottom" | "left" | "right"
children: JSX.Element
}
interface UiMenuProps {
label: string
items: UiMenuEntry[]
trigger?: "icon" | "button"
icon?: IconName
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end"
disabled?: boolean
}
type UiMenuEntry = UiMenuItem | UiMenuSeparator | UiMenuGroup
interface UiMenuItem {
type?: "item"
label: string
icon?: IconName
keys?: string[]
disabled?: boolean
onSelect(): void
}
interface UiMenuSeparator {
type: "separator"
}
interface UiMenuGroup {
type: "group"
label: string
items: UiMenuItem[]
}
interface UiLoaderProps {
size?: "small" | "normal" | "large"
class?: string
}
interface UiDialogProps {
title: string
description?: string
children?: JSX.Element
footer?: JSX.Element
size?: "fit" | "normal" | "large" | "x-large"
closable?: boolean
}
interface UiSwitchProps {
checked: boolean
onChange: (checked: boolean) => void
label: string
hideLabel?: boolean
description?: string
disabled?: boolean
}
interface UiTextInputProps {
value: string
onInput: (value: string) => void
label: string
placeholder?: string
onSubmit?: (value: string) => void
type?: "text" | "password" | "search" | "url"
size?: "normal" | "large"
disabled?: boolean
invalid?: boolean
autofocus?: boolean
class?: string
}
interface UiKeybindProps {
keys: string[]
class?: string
}
interface UiMarkdownProps {
text: string
class?: string
}
interface UiCodeProps {
content: string
language?: string
class?: string
}
Toasts
ctx.toast.show shows a short message in the corner of the window and returns a handle whose dismiss() closes it.
ctx.toast.show({ variant: "success", title: ctx.t("invite.copied") })
variantis"default","success","error", or"loading". Asuccesstoast shows a check icon unless you seticon.durationis how long the toast shows, in milliseconds. It defaults to 5 seconds.persistent: truekeeps the toast until the user ordismiss()closes it, and ignoresduration.- A toast equal to one still showing replaces it instead of stacking a copy.
- Your plugin’s toasts close when it stops, so no button outlives the code it runs. A toast does not close with the
component or
createKeyedrun that showed it.
Show a loading toast while work runs, and dismiss it when the work ends:
const saving = ctx.toast.show({ variant: "loading", title: ctx.t("saving"), persistent: true })
await save()
saving.dismiss()
actions adds buttons. A click runs the action and closes the toast:
ctx.toast.show({
variant: "error",
title: ctx.t("sync.failed"),
actions: [{ label: ctx.t("retry"), run: () => void sync() }],
})
Reference
interface Toasts {
show(options: ToastOptions): ToastHandle
}
interface ToastHandle {
dismiss(): void
}
interface ToastOptions {
title: string
description?: string
variant?: "default" | "success" | "error" | "loading"
icon?: IconName
duration?: number
persistent?: boolean
actions?: ToastAction[]
}
interface ToastAction {
label: string
run(): void
}
Notifications
ctx.attention.notify gets the user’s attention while they look elsewhere: a system notification and a sound, with
the same rules as the app’s own notifications.
const session = ctx.sessions.current()
if (session) {
void ctx.attention.notify({
title: ctx.t("tests.done"),
message: ctx.t("tests.summary", { failed: 0 }),
session: { server: session.server.id, id: session.id },
id: run.id,
})
}
- The system notification shows only while no app window has focus. The sound plays either way.
- Both follow Settings > Notifications.
variant: "default"follows the Agent rows, andvariant: "error"the Errors rows. Desktop notifications for both are off by default; their sounds are on. sound: falsenever plays a sound.- Clicking the notification brings the app forward and opens
session, when you give one. - Each app window runs its own copy of your plugin. Pass an
idof the event, such as a run’s id, so only the first window to send it notifies and plays the sound. Withoutid, every call notifies. - Before the app interface mounts,
notifywaits for it.
notify never rejects. It resolves what it did: notification is true when the app asked the system to show the
notification, and sound is true when the sound started.
const result = await ctx.attention.notify({ title: ctx.t("deploy.failed"), variant: "error" })
if (!result.notification) ctx.toast.show({ variant: "error", title: ctx.t("deploy.failed") })
notificationisfalsewhen the user turned it off, an app window has focus, or another window already sentid. The system can still hide a notification the app asked it to show, for example when the user denied a browser’s permission.
Reference
interface Attention {
notify(options: NotifyOptions): Promise<NotifyResult>
}
interface NotifyOptions {
title: string
message?: string
variant?: "default" | "error"
session?: { server: string; id: string }
sound?: boolean
id?: string
}
interface NotifyResult {
notification: boolean
sound: boolean
}
Tabs
ctx.tabs reads and changes the shell’s tab strip: the sessions and drafts open as tabs. This moves a session’s tab to
the front when its run finishes:
import { onCleanup } from "solid-js"
import { createKeyed } from "@opencode/plugin/gui"
createKeyed(
() => ctx.servers.get("sidecar")?.data,
(data) =>
onCleanup(
data.on("session.execution.succeeded", (event) => {
const tab = ctx.tabs.list().find((item) => item.session?.id === event.data.sessionID)
if (tab) ctx.tabs.move(tab.key, 0)
}),
),
)
list()returns the open tabs in strip order. It is reactive. A tab’ssessionisundefinedfor a draft, andactiveistruefor the tab on screen.open({ server, id }, { focus? })opens a session as a tab at the end of the strip, in the background unlessfocusistrue. A session that is already open keeps its tab, as does a subagent that its parent’s tab shows.focus(key)shows a tab,move(key, index)moves it, andclose(key)closes it as the user does.- A key that no open tab has does nothing. Writes made before the app interface mounts wait for it.
Reference
interface Tabs {
list(): ShellTab[]
open(session: { server: string; id: string }, options?: { focus?: boolean }): void
focus(key: string): void
move(key: string, index: number): void
close(key: string): void
}
interface ShellTab {
key: string
session: SessionRef | undefined
active: boolean
}
Keybinds
ctx.keybinds reads the user’s shortcut for a published command id.
const label = ctx.keybinds.keybind(`${ctx.id}.open`)
const chord = ctx.keybinds.keys("mod+k")
const pressed = (event: KeyboardEvent) => ctx.keybinds.matches(`${ctx.id}.open`, event)
keybind(command)returns the display parts of the command’s effective keybind, for example["Ctrl", "K"]. It is empty when the command is unbound.keys(bind)returns the display parts of a chord the app does not own, such as one a page handles itself.matches(command, event)istruewhen the event matches the command’s effective keybind.
Reference
interface Keybinds {
keybind(command: string): string[]
keys(bind: string): string[]
matches(command: string, event: KeyboardEvent): boolean
}
Run commands
ctx.commands.run(id) runs a command as choosing it in the command palette does. It returns false when the command
cannot run:
if (!ctx.commands.run("terminal.toggle")) ctx.toast.show({ title: ctx.t("terminal.unavailable") })
idis a published plugin command,<plugin id>.<command id>, of any plugin, or one of the app’s own commands, such ascommand.palette.- The app’s command ids are not a stable API and can change between releases.
runreturnsfalsewhen no enabled command has the id. A listed but disabled command does not run.- Before the app interface mounts, no command is registered, so
runreturnsfalse. rundoes not wait for a command’s async work.
Reference
interface Commands {
run(id: string): boolean
}
System and environment
ctx.system works on every platform. ctx.desktop adds desktop-only abilities and is undefined on the web.
const show = async (path: string) => {
if (ctx.desktop && (await ctx.desktop.reveal(path))) return
await ctx.system.copy(path)
}
system.saveshows a save dialog on desktop and downloads on the web. It returnsfalsewhen the user cancels the dialog.system.openExternalopens a URL in the system browser. For links inside the app, usectx.links.open, which routes aLinkto its link handler.desktop.launch(path, app?)opens a path with an app, or with the default app.desktop.reveal(path)shows it in the file manager.ctx.embedsrenders a main-process web view in a window; see Embedded web views.ctx.localereads the interface language and its writing direction.ctx.appearance.font("mono")is the CSS font family of the user’s terminal font.ctx.routerreads the current route path and whether a route transition is in progress.ctx.workspaces.on("remove", handler)runs when the user removes a workspace directory from a server, so you can clean up stores scoped to it.
desktop.pick(options) asks the user for files or folders with the system’s open dialog, and resolves their absolute
paths:
const [folder] = (await ctx.desktop?.pick({ directory: true, title: ctx.t("pick.folder") })) ?? []
if (folder) await ctx.client.rpc(Acme).index({ directory: folder })
directory: truepicks folders instead of files, andmultiple: truelets the user pick more than one.extensionslimits the files offered, without the dot, such as["json"]. Folders ignore it.titleis the dialog’s title. Not every system shows it.- The paths are empty when the user cancels.
pickrejects when the dialog cannot open. pickis desktop only: a browser never tells the page where a picked file is.
ctx.appearance.mode() is the color scheme the interface shows, "light" or "dark". It is reactive:
const background = () => (ctx.appearance.mode() === "dark" ? "#000" : "#fff")
- It follows the user’s choice in Settings > Appearance, with System resolved to the system’s current scheme.
- Before the app interface mounts, it is the system’s scheme.
Reference
interface System {
copy(text: string): Promise<void>
save(file: { name: string; content: string }): Promise<boolean>
openExternal(url: string): void
}
interface Desktop {
os: "macos" | "windows" | "linux"
zoom(): number
launch(path: string, app?: string): Promise<void>
reveal(path: string): Promise<boolean>
installed(app: string): Promise<boolean>
pick(options?: PickOptions): Promise<string[]>
}
interface PickOptions {
directory?: boolean
multiple?: boolean
title?: string
extensions?: string[]
}
interface Links {
open(link: Link): boolean
exists(link: Link): boolean | Promise<boolean>
}
interface Locale {
locale(): string
direction(): "ltr" | "rtl"
}
interface Appearance {
font(kind: "mono"): string
mode(): "light" | "dark"
}
interface Router {
path(): string
routing(): boolean
}
interface Workspaces {
on(event: "remove", handler: (value: { server: string; directory: string }) => void): Cleanup
}
Stored state
Desktop windows load stored state over IPC, while the web reads localStorage at once. Declare stores in
Plugin.define({ stores }), so the host loads them for you.
Store.globalkeeps one value for the app. It loads beforesetupruns, soctx.stores.name.valueis neverundefined.Store.sessionkeeps one value per session. It loads when the session mounts;ctx.stores.name(session).valueisundefineduntil then.
Declared stores are for windows. main opens its state with ctx.storage.store, which reads synchronously; see
Main context.
Mutate a value in place with update(draft => …), which must return nothing, or replace it with set(next):
const enabled = ctx.stores.prefs.value.enabled
ctx.stores.prefs.update((draft) => {
draft.enabled = !draft.enabled
})
ready()istrueonce the value has loaded. Writes made before then wait and apply in call order.fromnames other stored keys to import the value from once, while the store holds none, such as the key of a store you renamed:"plugin.acme.settings". Pass a list to try several keys, newest first. The other key keeps its copy until the store is removed.
stores: {
prefs: Store.global(Prefs, { enabled: true }, ["plugin.acme.settings.v2", "plugin.acme.settings"]),
}
Reference
const Store: {
global(schema: Schema, initial: T, from?: string | string[]): StoreDeclaration
session(schema: Schema, initial: T, from?: string | string[]): StoreDeclaration
}
interface Persisted<T, V = T | undefined> {
value: V
ready(): boolean
update(mutate: (draft: T) => undefined): void
set(next: T): void
}
Runtime stores
ctx.storage.store(key, options) opens a store for a key known only at runtime. scope keeps one value per server,
directory, or session.
import { Schema } from "effect"
import { usePlugin, type MountedSession } from "@opencode/plugin/gui"
import type { definition } from "./definition"
function DraftView(props: { readonly session: MountedSession }) {
const ctx = usePlugin<typeof definition>()
const draft = ctx.storage.store("draft", {
schema: Schema.Struct({ text: Schema.String }),
initial: { text: "" },
scope: { server: props.session.server.id, directory: props.session.directory },
})
return <p>{draft.value?.text}</p>
}
scope: "global", the default, keeps one value for the app.scope: { server, directory? }keeps one value per server, or per workspace directory on it.scope: { session }keeps one value per session, opened again when the session moves to a new directory.- A runtime store’s
valueisundefineduntil it loads. ctx.storage.memory(key, { initial })keeps window-local state that survives plugin reloads but not window reloads.ctx.storage.remove(key, { scope, from })deletes the value, so the key reads asinitialagain.
Reference
interface Storage {
store(key: string, options: StoreOptions): Persisted<T>
memory(key: string, options: { initial: T }): [Store<T>, (mutation: (draft: T) => void) => void]
remove(key: string, options?: { scope?: StorageScope; from?: string | string[] }): void
}
interface StoreOptions {
schema: Schema
initial: T
scope?: StorageScope
from?: string | string[]
}
type StorageScope = "global" | { server: string; directory?: string } | { session: SessionRef }
Reactive primitives
Prefer these helpers to raw createEffect, createRenderEffect, and createComputed. They follow a plugin’s lifetime
and its dependencies’ restarts, which hand-written effects tend to get wrong.
createKeyed
createKeyed(source, fn, { otherwise?, equals? }) runs fn in a fresh owner for each key: each active generation of a
ctx.use accessor, or each value of a plain accessor. Anything registered inside fn ends with that run.
import { onCleanup } from "solid-js"
import { createKeyed, usePlugin, type SessionScreen } from "@opencode/plugin/gui"
import { Changes } from "@opencode/plugin/gui/review"
function ChangedFiles(props: { readonly screen: SessionScreen }) {
const ctx = usePlugin()
// Each active generation of Changes keeps this view's changes loaded; the watch ends with it.
createKeyed(ctx.use(Changes), (changes) => onCleanup(changes.watch(props.screen, "files")))
return <FileMarkers screen={props.screen} />
}
otherwiseruns while the source has no key: aLiveaccessor that is not active, or a plain accessor whose value isundefined,null, orfalse.equalsdecides whether a plain accessor’s new value is the same key, sofndoes not run again. Use it when the source builds a new object each time. ALiveaccessor always keys by generation.
Reference
function createKeyed<T>(
source: () => Live<T> | T,
fn: (value: T) => void,
options?: { otherwise?(): void; equals?(previous: T, next: T): boolean },
): void
createLatest
createLatest(source, (value, signal) => promise) fetches whenever source changes, aborts stale requests, and never
suspends.
const summary = createLatest(
() => ctx.sessions.current()?.directory,
(directory, signal) => fetchSummary(directory, signal),
)
const text = () => summary.latest ?? ""
latestis the last result, kept while a newer request runs. It isundefinedbefore the first.loadingistruewhile a request for the current value is in flight.erroris the current request’s rejection, if any.
Reference
function createLatest<S, T>(
source: () => Live<S> | S,
fetch: (value: S, signal: AbortSignal) => Promise<T>,
): { latest: T | undefined; loading: boolean; error: unknown }
createVisitState
createVisitState(initial) creates a [value, setValue] signal that resets whenever the user leaves the session and
returns.
const [expanded, setExpanded] = createVisitState(false)
Reference
function createVisitState<T>(initial: T): [value: () => T, set: (next: T) => void]
usePanel and useDrawer
usePanel() reads the surrounding panel frame, and throws outside a panel render. useDrawer() returns the mobile
drawer around a contribution, or undefined outside one.
const panel = usePanel()
const drawer = useDrawer()
const compact = () => panel.placement() === "mobile"
const close = () => drawer?.close()
placement()is"side"in the side region and"mobile"in a narrow-screen view.visible()istruewhile the region is open and this tab is selected.present()staystruewhile the region animates closed.reserve()asks you to leave room at the end of a header for the host’s region toggle.sidebaris the inner sidebar preference that every side panel shares. A panel with a sidebar renders its own toggle.drawer.open(view)shows content in place of the drawer’s view, with a back control.
Reference
function usePanel(): PanelFrame
function useDrawer(): { close(): void; open(view: DrawerView): void } | undefined
interface PanelFrame {
visible(): boolean
present(): boolean
placement(): "side" | "mobile"
reserve(): boolean
animate(): boolean
open(): string[]
sidebar: {
opened(): boolean
width(): number
transition(): boolean
resize(width: number): void
toggle(): void
}
}
interface DrawerView {
title: string
content: JSX.Element
trigger: HTMLElement
}
App contracts
The app provides these contracts to every plugin. Follow one with ctx.use(token), and handle it while it is not
active, as for any other plugin’s contract. For example, Browser is inactive on the web.
Files
@opencode/plugin/gui/file exports the app’s file tree and Open in button, and the FileViewer registry.
import { bindPlugin } from "@opencode/plugin/gui"
import { FileViewer, type FileViewerProps } from "@opencode/plugin/gui/file"
// A viewer for a kind of file the file view does not render itself.
ctx.add(FileViewer, {
kinds: ["document"],
View: bindPlugin((props: FileViewerProps) => <AcmeDoc bytes={props.bytes} onError={props.onError} />),
})
FileTreeis a contract.TreeandListrender a virtualized tree or list of workspace files with change markers. Read it withctx.use(FileTree). A tree’slabelsadds text after a folder’s name, such as how many entries it has.OpenInAppis a contract.Buttonis the desktop Open in button for a session’s workspace.FileVieweris a registry. A viewer lists thekindsit renders, can reject bytes withproblem, and renders aViewthat reportsonDetailsandonError.
Reference
interface FileTree {
Tree(props: FileTreeProps): JSX.Element
List(props: FileListProps): JSX.Element
}
interface FileTreeProps {
screen: SessionScreen
session: MountedSession
allowed: string[]
kinds?: Map<string, ChangeKind>
labels?: Map<string, string>
active?: string
onFileClick(path: string): void
}
interface FileListProps {
screen: SessionScreen
session: MountedSession
files: string[]
kinds?: Map<string, ChangeKind>
active?: string
highlighted?: string
onFileClick(path: string): void
}
interface OpenInApp {
Button(props: { screen: SessionScreen; session: MountedSession }): JSX.Element
}
interface FileViewer {
kinds: ArtifactKind[]
problem?(bytes: Uint8Array): string | undefined
View: Component<FileViewerProps>
}
interface FileViewerProps {
bytes: Uint8Array
onDetails(details: string[]): void
onError(reason?: string): void
}
Review changes
@opencode/plugin/gui/review exports Changes, the app’s view of a session’s changes, and ChangeKind
("add", "del", or "mix").
import { Changes } from "@opencode/plugin/gui/review"
const changes = ctx.use(Changes)
const count = () => {
const live = changes()
const session = ctx.sessions.current()
return live.status === "active" && session ? live.value.diffs(session).length : 0
}
diffs(session)is the current review mode’s diffs, andready(session)istrueonce they have loaded. Both are reactive. Only the routed session has changes; other sessions read as empty.kinds(session)maps each changed file and directory to itsChangeKind.active(session)is the file the review panel shows.details(session)is the session directory’s uncommitted changes, whatever the review mode.focus(session, path)shows a file in the review panel, andopen(session)shows all changes.watch(screen, source)keeps the changes of a view loaded while it shows them, across the view’s session switches. Pass the owning screen from the panel or slot input. It returns a function that stops watching; see createKeyed.- A
treewatch is a persistent change list: opening the side region refreshes it. Adetailswatch loadsdetails(session).
Reference
interface Changes {
diffs(session: SessionRef): FileDiffInfo[]
ready(session: SessionRef): boolean
kinds(session: SessionRef): Map<string, ChangeKind>
active(session: SessionRef): string | undefined
details(session: SessionRef): FileDiffInfo[] | undefined
focus(session: SessionRef, path: string): void
open(session: SessionRef): void
watch(screen: SessionScreen, source: "tree" | "files" | "details"): () => void
}
type ChangeKind = "add" | "del" | "mix"
Browser
@opencode/plugin/gui/browser exports Browser, the desktop browser pane of a session.
import { Browser } from "@opencode/plugin/gui/browser"
const session = ctx.sessions.current()
const live = ctx.use(Browser)()
const url = "http://localhost:3000"
if (session && live.status === "active" && live.value.canOpen(session, url)) live.value.open(session, url)
attached(session)istruewhen the session has a browser pane.canOpen(session, path?)istruewhen a workspace file or an http(s) URL can open in the pane.open(session, url)opens a URL as a browser tab in the session’s side panel.openFile(session, path)opens a workspace file as afile://tab.
Reference
interface Browser {
attached(session: SessionRef): boolean
canOpen(session: SessionRef, path?: string): boolean
open(session: SessionRef, url: string): void
openFile(session: SessionRef, path: string): void
}
The main process
On OpenCode Desktop, a plugin’s main setup runs once in the Electron main process as an app-wide singleton. It has
full access to Node.js built-ins, electron, and the main host APIs. Pass it to Plugin.define in the plugin’s
./gui-experimental file, beside the window setup or on its own.
import { Schema } from "effect"
import { powerSaveBlocker } from "electron"
import { Plugin } from "@opencode/plugin/gui"
import { KeepAwake } from "./gui/contract"
export default Plugin.define({
main(ctx) {
const stored = ctx.storage.store("keepScreenActive", { schema: Schema.Boolean, initial: false })
let blockerId: number | undefined
const release = () => {
if (blockerId === undefined) return
powerSaveBlocker.stop(blockerId)
blockerId = undefined
}
const setActive = (enabled: boolean) => {
if (enabled && blockerId === undefined) blockerId = powerSaveBlocker.start("prevent-display-sleep")
if (!enabled) release()
stored.set(enabled)
}
if (stored.value) setActive(true)
ctx.provide(KeepAwake, {
screenActive: () => blockerId !== undefined && powerSaveBlocker.isStarted(blockerId),
setScreenActive: (enabled) => setActive(enabled),
})
return release
},
})In a window, electron is an inert placeholder, so the import is safe in the shared ./gui-experimental file. See
One file, two processes.
Main context
main receives a MainContext. Unlike the window setup, main may be async, and it may return, or resolve to, a
cleanup function.
main(ctx) {
ctx.log.write("info", "acme started", { version: ctx.app.version })
}
ctx.options,ctx.client, andctx.appare the shared context. Inmain,ctx.app.platformis always"desktop".ctx.storage.store(key, { schema, initial })opens a store synchronously in the plugin’s namespace. Itsvalueis always defined, and eachsetorupdatereaches the database before it returns.ctx.storage.remove(key)deletes the value.ctx.signalaborts when the plugin is disabled, reloaded, or removed, or the app quits.ctx.onCleanup(fn)runsfnthen; see Lifetimes and cleanup.ctx.provideimplements anIpcunder the plugin’s id; see Define and provide an Ipc.ctx.addcontributes to main-process registries such asMenubarItem.ctx.windowslists the app’s windows and reports when one opens or closes.ctx.embedsplaces aWebContentsViewin a window’s layout; see Embedded web views.ctx.serverEndpoints.get(id)returns a server’s URL and credentials. The desktop app’s own server is"sidecar".ctx.clidescribes the OpenCode CLI the desktop app runs.ctx.log.writewrites to the desktop log file.
Reference
@opencode/plugin/gui/main exports MainStorage and MainEmbeds below as Storage and Embeds.
interface MainContext {
id: string
options: PluginOptions
client: OpenCodeClient
app: App
signal: AbortSignal
onCleanup(fn: Cleanup): void
storage: MainStorage
provide<S extends IpcSpec>(token: Ipc<S>, impl: IpcImpl<S>): IpcProvider<S>
add<T>(registry: Registry<T>, item: () => T | undefined): Cleanup
add<T>(registry: Registry<T>, item: T): Cleanup
list<T>(registry: Registry<T>): T[]
t(key: string, params?: Record<string, string | number | boolean>): string
plural(key: string, count: number, params?: Record<string, string | number | boolean>): string
windows: Windows
embeds: MainEmbeds
serverEndpoints: { get(id: string): ServerEndpoint | undefined }
cli: { version: string; command: string[]; binary?: string; development: boolean }
log: { write(level: "debug" | "info" | "warn" | "error", message: string, data?: Record<string, unknown>): void }
}
interface MainStorage {
store(key: string, options: { schema: Schema; initial: T }): Persisted<T, T>
remove(key: string): void
}
interface Windows {
get(id: number): BrowserWindow | undefined
list(): BrowserWindow[]
focused(): BrowserWindow | undefined
on(event: "open" | "close", handler: (window: BrowserWindow) => void): Cleanup
}
interface ServerEndpoint {
id: string
url: string
headers: Record<string, string>
sameMachine: boolean
password?: string
}
Define and provide an Ipc
An Ipc spec can declare per-window state, callable methods, and pushed events. Effect Schema validates and
encodes every value that crosses the bridge.
import { Schema } from "effect"
import { Ipc } from "@opencode/plugin/gui"
export const Counter = Ipc.define({
id: "acme.counter",
state: Schema.Struct({ count: Schema.Number }),
methods: {
increment: {
input: Schema.Number,
output: Schema.Number,
},
reset: {},
},
events: {
overflow: Schema.Struct({ limit: Schema.Number }),
},
})- The id is your plugin’s id or starts with it, as
acme.counter.ctx.provideinmainandctx.mainin a window throw for any other id. - Omit
inputfor a method that takes no argument, andoutputfor a method that returns nothing.
Reference
interface IpcSpec {
id: string
state?: Schema
methods: Record<string, { input?: Schema; output?: Schema }>
events?: Record<string, Schema>
}
Implement the provider in main
Each method receives the decoded input and a Caller ({ window, signal }). Call provider.changed(window?) when
state changes to push the new snapshot to subscribed windows, or provider.emit(name, payload, window?) to send an
event.
import type { Plugin } from "@opencode/plugin/gui"
import { Counter } from "./contract"
export const main: Plugin.MainSetup = (ctx) => {
const counts = new Map<number, number>()
const provider = ctx.provide(Counter, {
state: (window: number) => ({ count: counts.get(window) ?? 0 }),
increment: (step, caller) => {
const next = (counts.get(caller.window) ?? 0) + step
counts.set(caller.window, next)
provider.changed(caller.window)
if (next > 100) provider.emit("overflow", { limit: 100 }, caller.window)
return next
},
reset: (_input, caller) => {
counts.set(caller.window, 0)
provider.changed(caller.window)
},
})
}- A spec with
statealso needsstate(window)in the implementation. Main reads it when a window subscribes and on everychanged. - A method that throws rejects the window’s call.
changedandemitreach every window when you omitwindow.dispose()withdraws theIpcearly. Windows see itpending, andctx.maincalls wait, until the plugin provides it again or stops. The host also withdraws it when the plugin stops.
Reference
type IpcImpl<S extends IpcSpec> = {
[Name in keyof S["methods"]]: (input: Input<Name>, caller: Caller) => Output<Name> | Promise<Output<Name>>
} & { state(window: number): State }
interface Caller {
window: number
signal: AbortSignal
}
interface IpcProvider<S extends IpcSpec> {
changed(window?: number): void
emit(name: keyof S["events"], data: EventData, window?: number): void
dispose(): void
}
Use the Ipc in a window
In a window, ctx.main(Counter) returns the client of the Ipc your main provides. A method without an input
schema takes only optional { signal } call options. Do not pass undefined as its first argument.
import { Command, type Plugin } from "@opencode/plugin/gui"
import { Counter } from "./contract"
import type { definition } from "./definition"
export const setup: Plugin.Setup<typeof definition> = (ctx) => {
const counter = ctx.main(Counter)
// Follows main across restarts; removed when the plugin stops.
counter.on("overflow", (data) => console.log(`Counter exceeded ${data.limit}`))
ctx.add(Command, () => ({
id: "increment",
title: ctx.t("command.increment"),
enabled: ctx.app.platform === "desktop",
run: async () => {
await counter.increment(1, { signal: ctx.signal })
await counter.reset({ signal: ctx.signal })
},
}))
}- A call waits while
mainstarts. It rejects whenmaincannot answer: on the web, while the plugin’smainis off or failed, or when the method throws. Catch the rejection where the user needs feedback. state()andon()followmainacross restarts. A listener ends with the current owner, else with the plugin.ctx.mainreturns the same client for every call with the token.
Read this window’s state with client.state(). It is undefined until the first snapshot arrives, and updates each
time main calls changed.
const count = () => ctx.main(Counter).state()?.count ?? 0
Reference
type IpcClient<S extends IpcSpec> = {
[Name in keyof S["methods"]]: S["methods"][Name] extends { input: Schema }
? (input: Input<Name>, options?: { signal?: AbortSignal }) => Promise<Output<Name>>
: (options?: { signal?: AbortSignal }) => Promise<Output<Name>>
} & {
state(): State | undefined
on(name: keyof S["events"], listener: (data: EventData) => void): Cleanup
}
Define the plugin with both setups in src/gui.tsx: Plugin.define({ ...definition, setup, main }).
Other plugins
Another plugin follows your Ipc with ctx.use(Counter), as it follows a contract. While it is active, its value is
the same IpcClient.
const counter = ctx.use(Counter)
const count = () => {
const live = counter()
return live.status === "active" ? (live.value.state()?.count ?? 0) : 0
}
- Every
Ipcisinactiveon the web, where no main process runs.
Native app menu
main adds items to the native menus (the macOS app menu, File, Edit, View, Go, Window, and Help) and to the in-app
menu on Windows and Linux with MenubarItem from @opencode/plugin/gui/main:
import { MenubarItem } from "@opencode/plugin/gui/main"
ctx.add(
MenubarItem,
(): MenubarItem => ({
menu: "help",
id: "diagnostics",
after: "logs.export",
label: ctx.t("menu.diagnostics"),
enabled: () => true,
run: (focusedWindow) => {
focusedWindow?.webContents.focus()
},
}),
)
afternames the item it follows: one of this plugin’s items by its id, or a built-in item by its command, else its action, else its role, for examplelogs.export,edit.copy, orabout. When nothing matches, the item goes at the end of the menu.appitems appear only on macOS.runreceives the focused window, if any.- Main reads the function each time it builds the menu, so the label follows the language.
Reference
interface MenubarItem {
menu: "app" | "file" | "edit" | "view" | "go" | "window" | "help"
id: string
label: string
after?: string
enabled?(): boolean
run(window: BrowserWindow | undefined): void
}
Embedded web views
To embed an isolated WebContentsView in a panel, create it in main and render its box from the window.
import { type BrowserWindow, WebContentsView } from "electron"
import type { Plugin } from "@opencode/plugin/gui"
export const main: Plugin.MainSetup = (ctx) => {
const attach = (window: BrowserWindow) => {
const view = new WebContentsView()
const embed = ctx.embeds.create(view, window)
void view.webContents.loadURL("https://acme.example")
embed.show(true)
// Send embed.id to the window over your Ipc.
}
ctx.windows.list().forEach(attach)
ctx.windows.on("open", attach)
}<ctx.embeds.View id={embedId} visible radius={8} />"open"fires only for windows that open later, so attach toctx.windows.list()first.- A window passes the embed’s
idtoctx.embeds.Viewandctx.embeds.capture. show(visible)is the page’s side of visibility: the view shows only while the window lays it out andshow(true)holds.on("visible", handler)runs when the view goes on or off screen, including when the window paints a still in its place.capture()returns a still of the page, orundefinedwhile it is hidden.dispose()removes the view from the window, but does not close its page; callview.webContents.close()when you are done with it. The host also disposes the embed when the plugin goes away.- In the window,
Viewfills its box with the embed. The host keeps the view’s bounds, zoom, rounded corners, and dialog occlusion in step, and hides it while the window is hidden or a dialog is open. frozenpaints a still in place of the live view, so DOM content can float above it.radiusrounds the bottom corners, andbackgroundis the color the corners show.pageis what the embed shows, such as its URL. While a panel moves, the host paints a still of the embed in its place, and drops a still taken whilepagehad another value.- The window’s
capture(id)returns a JPEG still, orundefinedwhile the embed is hidden and always on the web.
Reference
interface MainEmbeds {
create(view: WebContentsView, window: BrowserWindow): Embed
}
interface Embed {
id: string
show(visible: boolean): void
on(event: "visible", handler: (visible: boolean) => void): Cleanup
capture(): Promise<NativeImage | undefined>
dispose(): void
}
interface Embeds {
View(props: EmbedProps): JSX.Element
capture(id: string): Promise<Uint8Array | undefined>
}
interface EmbedProps {
id: string | undefined
visible: boolean
frozen?: boolean
page?: string
radius?: number
background?: string
class?: string
children?: JSX.Element
}
Live dependencies and failure handling
ctx.use(token) returns an accessor of a reactive Live<T>: the state of the plugin that provides the token. Your
plugin keeps running while that provider is off, so handle the states that are not active. No control should be dead,
and no spinner should run forever.
import { Browser } from "@opencode/plugin/gui/browser"
const browser = ctx.use(Browser)
ctx.add(Command, () => {
const live = browser()
const session = ctx.sessions.current()
return {
id: "preview",
title: ctx.t("command.preview"),
enabled: live.status === "active" && !!session,
run: () => {
if (live.status === "active" && session) live.value.open(session, "http://localhost:3000")
},
}
})
pending: the provider has not started yet, for example while its plugin loads or restarts. Show a loading state, or hide optional controls.active: the provider runs, andvalueis its contract orIpcclient.generationcounts activations, so a provider that restarts comes back with a new one.inactive,disabled: the provider is turned off, absent, or not available on this platform, as everyIpcis on the web. Show an unavailable state, or omit the control.inactive,failed: the provider’s setup threw an error. Show an unavailable state.- Your own
mainhas noLivestate. Calls throughctx.mainwait while it starts, and reject when it cannot answer.
Reference
type Live<T> =
| { status: "pending" }
| { status: "active"; value: T; generation: number }
| { status: "inactive"; reason: "disabled" | "failed" }
Lifetimes and cleanup
Everything registered through ctx (ctx.add, ctx.provide, ctx.dialogs.open, ctx.embeds.create, and Ipc
event listeners) is withdrawn when its owning scope ends:
- Inside a component or a
createKeyedcallback, registrations end when that component orcreateKeyedrun disposes. - At the top level of
setup, registrations end when the plugin is disabled, reloaded, or removed, or its window closes. - In
main, registrations end when the plugin is disabled, reloaded, or removed, or the app quits. An embed also ends when its window closes.
For other resources, such as timers or sockets, return a cleanup function from the setup. It runs at the same time.
- Window
setupcannot beasync, because it runs under a Solid owner. Return a cleanup function, or useonCleanuporctx.signal. mainmay beasync. Return, or resolve to, a cleanup function, or usectx.onCleanup(fn).
Window setup
Window setup must be synchronous. Start asynchronous work inside createKeyed or createLatest.
import { Command, createKeyed, Plugin } from "@opencode/plugin/gui"
import { Changes } from "@opencode/plugin/gui/review"
export default Plugin.define({
i18n: { en: { changes: "Show changes" } },
setup(ctx) {
// Runs once per active generation of Changes; the command is withdrawn with it.
createKeyed(ctx.use(Changes), (changes) => {
ctx.add(Command, () => ({
id: "changes",
title: ctx.t("changes"),
run: () => {
const session = ctx.sessions.current()
if (session) changes.open(session)
},
}))
})
const timer = setInterval(() => console.log("still here"), 60_000)
return () => clearInterval(timer)
},
})Main setup
main may be async. Return, or resolve to, a cleanup function for resources it opens, or register it with
ctx.onCleanup(fn).
async main(ctx) {
const socket = await connect(ctx.options)
ctx.onCleanup(() => socket.close())
}
ctx.onCleanupruns its functions when the plugin is disabled, reloaded, or removed, or the app quits, last registered first. It runs a function at once when the plugin is already gone.- After an
await, return ifctx.signalaborted, before you register or change anything. - An
asynccleanup delays quitting until it settles, for at most 3 seconds.