feat(plugin): add toasts, dialogs, notifications, UI, tabs and composer APIs to GUI plugins (#59469)

This commit is contained in:
Luke Parker 2026-10-27 12:40:22 +10:00 • committed by GitHub
parent c11a32ff1e
commit 758cf5adbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 2626 additions and 50 deletions

View file

@ -1,5 +1,6 @@
import type { SessionMessageInfo } from "@opencode/client/promise"
import { DialogProvider } from "@opencode/ui/context/dialog"
import { Toast } from "@opencode/ui/toast"
import {
Panel,
type Appearance,
@ -45,6 +46,7 @@ import { persistedHandle } from "../src/runtime/plugin/stores"
import { LanguageProvider } from "../src/runtime/i18n/language"
import { PluginsSettings } from "../src/runtime/plugin/settings-plugins"
import { SessionPluginPills } from "../src/session/composer/plugin-pills"
import { ui } from "../src/runtime/plugin/ui"
export {
bindPlugin,
@ -95,7 +97,7 @@ const about: App = { name: "opencode", version: "", channel: "dev", platform: "d
const locale: BuiltinLocale = { locale: () => "en", direction: () => "ltr", setDirection() {} }
const appearance: Appearance = { font: () => "monospace" }
const appearance: Appearance = { font: () => "monospace", mode: () => "light" }
const router: Router = { routing: () => false, path: () => "/" }
@ -105,7 +107,7 @@ const servers: Servers = { list: () => [], get: () => undefined }
const workspaces: Workspaces = { on: () => () => undefined }
/** Every HostApi faked at its boundary, with the given storage. */
/** Every HostApi faked at its boundary, with the given storage; `ui` is the app's own components, which have none. */
function fakeApis(storage: (plugin: string) => Storage): HostApiFactories {
return {
app: () => about,
@ -122,6 +124,11 @@ function fakeApis(storage: (plugin: string) => Storage): HostApiFactories {
storage,
system: () => ({ copy: async () => {}, save: async () => false, openExternal() {} }),
embeds: () => ({ View: () => null, capture: async () => undefined }),
toast: () => ({ show: () => ({ dismiss() {} }) }),
tabs: () => ({ list: () => [], open() {}, focus() {}, move() {}, close() {} }),
attention: () => ({ notify: async () => ({ notification: false, sound: false }) }),
commands: () => ({ run: () => false }),
ui: () => ui,
}
}
@ -357,6 +364,7 @@ function standIn(overrides: Partial<Interface>): Interface {
return {
sessions: () => [],
current: () => undefined,
tabs: { list: () => [], open() {}, focus() {}, move() {}, close() {} },
screen: () => undefined,
scope: () => ServerScope.local,
scoped: () => undefined,
@ -376,7 +384,10 @@ function standIn(overrides: Partial<Interface>): Interface {
project() {},
draft() {},
command() {},
run: () => false,
notify: async () => ({ notification: false, sound: false }),
font: () => "fixture mono",
mode: () => "dark",
routing: () => false,
path: () => "/",
keybind: () => [],
@ -602,9 +613,9 @@ export function mountComposerPills(input: { definitions: readonly Entry[] }) {
}
/**
* Mounts the real host over the real HostApis of a web window, before the app interface mounts. `attach()` mounts a
* stand-in for the interface: its layout records each write and reports a tab it opened as visible, its server is
* the local one, and its mono font is "fixture mono".
* Mounts the real host over the real HostApis of a web window, before the app interface mounts, with the window's
* toast region. `attach()` mounts a stand-in for the interface: its layout records each write and reports a tab it
* opened as visible, its server is the local one, and its mono font is "fixture mono".
*/
export function mountHostApis(input: { definitions: readonly Entry[] }) {
const container = document.createElement("div")
@ -644,6 +655,7 @@ export function mountHostApis(input: { definitions: readonly Entry[] }) {
<RealHost definitions={input.definitions} captured={captured}>
<Capture />
</RealHost>
<Toast.Region />
</DialogProvider>
</PlatformProvider>
</LanguageProvider>
@ -656,6 +668,7 @@ export function mountHostApis(input: { definitions: readonly Entry[] }) {
writes,
/** Mounts the interface. */
attach: () => void captured.apis?.attach(mounted),
reload: (id: string) => hosts[0]?.reload(id),
status: (id: string) => hosts[0]?.state.status[id],
unmount: () => {
dispose()

View file

@ -12,6 +12,9 @@ type Scope = { owner?: Owner | null; end: () => void; ctx?: Context }
/** The context the session-routing case keeps from inside its plugin. */
type Kept = { ctx?: Context }
/** What the asking case keeps from inside its plugin: its dialogs, the end of its scope, and that scope's prompt. */
type Asked = { dialogs?: Dialogs; end?: () => void; prompt?: Promise<string | undefined> }
/** What the pre-mount case reads from inside its plugin's setup. */
type Reads = { state?: () => string; font?: () => string; prefs?: { value?: { count: number }; ready(): boolean } }
@ -182,6 +185,249 @@ story("a dialog pushed in the same tick as a reload never mounts", async ({ page
expect(shown).toBe(false)
})
story(
"confirm, alert, prompt and select answer once: with the user's pick, else when their dialog or plugin goes away",
async ({ page }) => {
const result = await page.evaluate(async (fixture) => {
const { mountPluginHost, createKeyed, createSignal, until } = await import(fixture)
const host = mountPluginHost()
const kept: Asked = {}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const button = (label: string) =>
Array.from(document.querySelectorAll("button")).find((element) => element.textContent?.trim() === label)
host.load((ctx: Context) => {
const [on, set] = createSignal(true)
kept.dialogs = ctx.dialogs
kept.end = () => set(false)
createKeyed(on, () => void (kept.prompt = ctx.dialogs.prompt({ title: "Scoped prompt" })))
}, 0)
await until(() => !!document.body.textContent?.includes("Scoped prompt"))
// The scope that asked ends: its prompt closes and answers undefined.
kept.end?.()
const scoped = await kept.prompt
const dialogs = kept.dialogs
if (!dialogs) throw new Error("The fixture plugin never set up")
const confirmed = dialogs.confirm({ title: "End the meetup?", confirm: "End", danger: true })
await until(() => !!button("End"))
button("End")?.click()
const alerted = dialogs.alert({ title: "Meetup ended", ok: "Got it" })
await until(() => !!button("Got it"))
button("Got it")?.click()
const picked = dialogs.select({
title: "Links",
options: [
{ title: "Alpha", value: "a" },
{ title: "Beta", value: "b", description: "second" },
],
})
const beta = () =>
Array.from(document.querySelectorAll<HTMLElement>('[role="option"]')).find((element) =>
element.textContent?.includes("Beta"),
)
await until(() => !!beta())
beta()?.click()
const dismissed = dialogs.confirm({ title: "Dismiss me" })
await until(() => !!button("Cancel"))
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }))
const outcome = {
scoped,
confirmed: await confirmed,
alerted: await alerted,
picked: await picked,
dismissed: await dismissed,
}
await wait(300)
host.unmount()
// Asked of a plugin that went away: nothing opens, and the fallback answers at once.
return { ...outcome, late: await dialogs.prompt({ title: "Late" }) }
}, fixture)
expect(result).toEqual({
scoped: undefined,
confirmed: true,
alerted: undefined,
picked: "b",
dismissed: false,
late: undefined,
})
},
)
story("a plugin's toasts close when it goes away, and a handle closes only its own toast", async ({ page }) => {
const result = await page.evaluate(async (fixture) => {
const { mountHostApis, until } = await import(fixture)
const shown = (title: string) => !!document.body.textContent?.includes(title)
const handles: { dismiss: () => void }[] = []
const setups = { count: 0 }
const host = mountHostApis({
definitions: [
{
id: "fixture",
windowEntry: async () => ({
default: (ctx: Context) => {
// Only the first instance shows toasts, so the reload's instance shows none of its own.
if (setups.count++ > 0) return
ctx.toast.show({ title: "kept toast", persistent: true })
handles.push(ctx.toast.show({ title: "dismissed toast", persistent: true }))
},
}),
},
],
})
await until(() => shown("kept toast") && shown("dismissed toast"))
handles[0]?.dismiss()
await until(() => !shown("dismissed toast"))
const kept = shown("kept toast")
host.reload("fixture")
await until(() => setups.count === 2 && !shown("kept toast"))
host.unmount()
return { kept, setups: setups.count }
}, fixture)
expect(result).toEqual({ kept: true, setups: 2 })
})
story("ctx.ui renders the app's own components in a plugin's contribution and dialog", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountSessionRegion, until, createComponent, createSignal, Slot } = await import(fixture)
// What the components' handlers received, for the test to read.
const record = (value: string) => {
document.body.dataset.ui = `${document.body.dataset.ui ?? ""}${value};`
}
const host = mountSessionRegion({
active: "none:main",
definitions: [
{
id: "fixture",
windowEntry: async () => ({
default: (ctx: Context) => {
const ui = ctx.ui
const [on, setOn] = createSignal(false)
const [name, setName] = createSignal("")
const dialog = () =>
ctx.dialogs.open((handle) =>
createComponent(ui.Dialog, {
title: "Fixture dialog",
children: "Fixture body",
get footer() {
return createComponent(ui.Button, { onClick: handle.close, children: "Close fixture" })
},
}),
)
ctx.add(Slot, {
at: "session.panel.end",
render: () => [
createComponent(ui.Icon, { name: "branch" }),
createComponent(ui.Button, { icon: "check", onClick: () => record("button"), children: "Apply" }),
createComponent(ui.Button, { onClick: dialog, children: "Open dialog" }),
createComponent(ui.Tooltip, {
value: "Reload tip",
keys: ["R"],
get children() {
return createComponent(ui.IconButton, { icon: "outline-rotate-clockwise", label: "Reload" })
},
}),
createComponent(ui.Menu, {
label: "Options",
items: [
{ type: "group", label: "Fixture group", items: [{ label: "Rename", onSelect() {} }] },
{ type: "separator" },
{ label: "Delete", icon: "trash", keys: ["Del"], onSelect: () => record("menu") },
],
}),
createComponent(ui.Loader, { size: "large" }),
createComponent(ui.Switch, {
label: "Wrap",
get checked() {
return on()
},
onChange: setOn,
}),
createComponent(ui.TextInput, {
label: "Name",
get value() {
return name()
},
onInput: setName,
onSubmit: (value: string) => record(`submit ${value}`),
}),
createComponent(ui.Keybind, { keys: ["Ctrl", "K"] }),
createComponent(ui.Markdown, { text: "# Fixture heading\n\nSome *text*." }),
createComponent(ui.Code, { content: "const fence = `\n```\n`", language: "ts" }),
],
})
},
}),
},
],
})
host.container.dataset.testid = "ui-host"
await until(() => host.status("fixture") === "active")
}, fixture)
const root = page.getByTestId("ui-host")
await expect(root.locator('svg[data-slot="icon-svg"] > use[href="#opencode-v2-icon-branch"]')).toHaveCount(1)
await expect(root.locator('svg[data-component="loader-v2"]')).toHaveAttribute("width", "20")
await expect(root.locator('[data-component="keybind-v2"]')).toHaveText("CtrlK")
await expect(root.locator('[data-component="markdown"] h1')).toHaveText("Fixture heading")
// A line of backticks in the code does not end its block.
const code = root.locator('[data-component="markdown"] code.language-ts')
await expect(code).toHaveText("const fence = `\n```\n`")
await expect(code.locator("span[style]").first()).toBeVisible()
const apply = root.getByRole("button", { name: "Apply" })
await expect(apply).toHaveAttribute("data-component", "button-v2")
await expect(apply.locator('use[href="#opencode-v2-icon-check"]')).toHaveCount(1)
await apply.click()
const reload = root.getByRole("button", { name: "Reload" })
await expect(reload).toHaveAttribute("data-component", "icon-button-v2")
await reload.hover()
await expect(page.locator('[data-component="tooltip-v2"]')).toHaveText("Reload tipR")
await root.getByRole("button", { name: "Options" }).click()
const menu = page.getByRole("menu")
await expect(menu.getByText("Fixture group")).toBeVisible()
await expect(menu.getByRole("separator")).toHaveCount(1)
await menu.getByRole("menuitem", { name: "Delete" }).click()
// The switch's input is visually hidden under its label, which takes the click.
await root.getByText("Wrap", { exact: true }).click()
await expect(root.getByRole("switch", { name: "Wrap" })).toBeChecked()
const field = root.getByRole("textbox", { name: "Name" })
await field.fill("acme")
await field.press("Enter")
await expect(page.locator("body")).toHaveAttribute("data-ui", "button;menu;submit acme;")
await root.getByRole("button", { name: "Open dialog" }).click()
const dialog = page.getByRole("dialog", { name: "Fixture dialog" })
await expect(dialog).toContainText("Fixture body")
await expect(dialog.locator('[data-slot="dialog-close-button"]')).toHaveCount(1)
await dialog.getByRole("button", { name: "Close fixture" }).click()
await expect(dialog).toHaveCount(0)
})
story(
"routing A, B, then A again: a kept session stays A, the same screen follows the route, and no render remounts",
async ({ page }) => {

View file

@ -340,7 +340,10 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
if (!selected) return
return () => command.trigger(selected.id, "slash", selected.input)
// A command declines only keys, so a slash command's `false` means nothing more.
return async () => {
await command.trigger(selected.id, "slash", selected.input)
}
},
})

View file

@ -306,6 +306,7 @@ export const dict = {
"common.loading": "Loading",
"common.loading.ellipsis": "…",
"common.cancel": "Cancel",
"common.ok": "OK",
"common.clear": "Clear",
"common.color.orange": "orange",
"common.color.yellow": "yellow",

View file

@ -17,6 +17,8 @@ type OpenAttachmentPickerOptions = {
defaultPath?: string
}
type OpenFilePathPickerOptions = { title?: string; multiple?: boolean; extensions?: string[] }
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type PlatformName = "web" | "desktop"
@ -64,6 +66,9 @@ type PlatformBase = {
onFile: (file: File) => Promise<Value>,
): Promise<void>
/** Open a native file picker and return the selected paths without reading the files (desktop only) */
openFilePathPickerDialog?(opts: OpenFilePathPickerOptions): Promise<string[] | null>
/** Resolve the native source path for a desktop File. */
getPathForFile?(file: File): string

View file

@ -0,0 +1,177 @@
import { createMemo, Show } from "solid-js"
import { createStore } from "solid-js/store"
import fuzzysort from "fuzzysort"
import type { AlertOptions, ConfirmOptions, PromptOptions, SelectOptions } from "@opencode/plugin/gui"
import { Button } from "@opencode/ui/button"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle, DialogTitleGroup } from "@opencode/ui/dialog"
import { SearchHighlight, SearchList } from "@opencode/ui/search-list"
import { TextInput } from "@opencode/ui/text-input"
import { useLanguage } from "@/runtime/i18n/language"
import "@/shell/commands/dialog.css"
import "@/shell/palette-menu.css"
// The dialogs behind `Dialogs.confirm`, `alert`, `prompt` and `select`. Each answers through `done`; the host closes
// the dialog and settles the plugin's promise.
/** `Dialogs.confirm`: the app's confirm dialog. A destructive action focuses Cancel, as the app's own do. */
export function PluginConfirmDialog(props: { options: ConfirmOptions; done: (confirmed: boolean) => void }) {
const language = useLanguage()
return (
<Dialog fit>
<DialogHeader hideClose>
<DialogTitleGroup title={props.options.title} description={props.options.description} />
</DialogHeader>
<DialogFooter>
<Button variant="ghost" autofocus={!!props.options.danger} onClick={() => props.done(false)}>
{props.options.cancel ?? language.t("common.cancel")}
</Button>
<Button
variant={props.options.danger ? "danger" : "contrast"}
autofocus={!props.options.danger}
onClick={() => props.done(true)}
>
{props.options.confirm ?? language.t("ui.common.confirm")}
</Button>
</DialogFooter>
</Dialog>
)
}
export function PluginAlertDialog(props: { options: AlertOptions; done: () => void }) {
const language = useLanguage()
return (
<Dialog fit>
<DialogHeader hideClose>
<DialogTitleGroup title={props.options.title} description={props.options.description} />
</DialogHeader>
<DialogFooter>
<Button variant="contrast" autofocus onClick={() => props.done()}>
{props.options.ok ?? language.t("common.ok")}
</Button>
</DialogFooter>
</Dialog>
)
}
/** `Dialogs.prompt`: one text field. Submitting needs a value, so the dialog never answers with "". */
export function PluginPromptDialog(props: { options: PromptOptions; done: (value: string | undefined) => void }) {
const language = useLanguage()
const [store, setStore] = createStore({ value: props.options.value ?? "", selected: false })
return (
<Dialog fit>
<DialogHeader hideClose>
<DialogTitleGroup title={props.options.title} description={props.options.description} />
</DialogHeader>
<form
class="contents"
onSubmit={(event) => {
event.preventDefault()
if (store.value) props.done(store.value)
}}
>
<DialogBody class="px-4">
<TextInput
type={props.options.secret ? "password" : "text"}
appearance="large"
class="!w-full self-stretch"
value={store.value}
placeholder={props.options.placeholder}
aria-label={props.options.title}
autofocus
autocomplete="off"
spellcheck={false}
// The initial text is selected once, so typing replaces it.
onFocus={(event) => {
if (store.selected) return
setStore("selected", true)
event.currentTarget.select()
}}
onInput={(event) => setStore("value", event.currentTarget.value)}
/>
</DialogBody>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => props.done(undefined)}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="contrast" disabled={!store.value}>
{props.options.submit ?? language.t("common.submit")}
</Button>
</DialogFooter>
</form>
</Dialog>
)
}
/** `Dialogs.select`: a searchable list in the command palette's dialog. Rows are keyed by their index. */
export function PluginSelectDialog<T>(props: { options: SelectOptions<T>; done: (value: T | undefined) => void }) {
const language = useLanguage()
const [store, setStore] = createStore({ query: "" })
const rows = createMemo(() =>
props.options.options.map((option, index) => ({
key: String(index),
option,
title: option.title,
description: option.description ?? "",
})),
)
const results = createMemo(() => {
const query = store.query.trim()
if (!query) return rows()
return fuzzysort.go(query, rows(), { keys: ["title", "description"] }).map((result) => result.obj)
})
return (
<Dialog class="command-palette" size="large">
<div class="sr-only">
<DialogTitle>{props.options.title}</DialogTitle>
</div>
<DialogBody class="command-palette-body">
<SearchList
query={store.query}
onQuery={(query) => setStore("query", query)}
placeholder={props.options.placeholder ?? language.t("common.search.placeholder")}
label={props.options.title}
items={results()}
key={(row) => row.key}
section={(row) => row.option.group ?? ""}
header={(row) => row.option.group || undefined}
empty={language.t("palette.empty")}
ref={(handle) => {
const current = rows().find((row) => row.option.value === props.options.current)
if (current) handle.setActive(current.key)
}}
onSelect={(row) => props.done(row.option.value)}
onClose={() => props.done(undefined)}
>
{(row, attrs) => (
<button type="button" class="command-palette-row group" {...attrs}>
<div class="command-palette-row-main">
<div class="command-palette-row-text">
<span class="command-palette-title palette-menu-title">
<SearchHighlight text={row().title} />
</span>
<Show when={row().description}>
{(description) => (
<span class="command-palette-description">
<SearchHighlight text={description()} />
</span>
)}
</Show>
</div>
</div>
</button>
)}
</SearchList>
</DialogBody>
</Dialog>
)
}

View file

@ -13,19 +13,25 @@ import {
import { createStore, produce, type Store } from "solid-js/store"
import type { Schema } from "effect"
import { useDialog } from "@opencode/ui/context/dialog"
import { useTheme } from "@opencode/ui/theme/context"
import { toaster } from "@opencode/ui/toast"
import { base64Encode } from "@opencode/util/encode"
import {
type App,
type Layout,
type MountedSession,
type NotifyOptions,
type NotifyResult,
type OpenOptions,
type PanelSidebar,
type PanelState,
type ServerRef,
type SessionRef,
type SessionScreen,
type ShellTab,
type Storage,
type StorageScope,
type Tabs,
} from "@opencode/plugin/gui"
import { BuiltinPanel, type BuiltinStoreFrom, type BuiltinStoreOptions } from "@opencode/plugin/gui/builtin"
import { usePlatform } from "@/runtime/platform/platform"
@ -36,6 +42,9 @@ import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useDirectoryPicker } from "@/workspaces/selection/picker"
import { SessionRouteKey, SessionStateKey, type ServerScope } from "@/runtime/server/scope"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
import { openNotificationSession } from "@/shell/notifications/notification"
import { playSoundById } from "@/shell/notifications/sound"
import { showToast } from "@/shell/notifications/toast"
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
import { sessionPaneTab, sessionTabPane, type SessionPane } from "@/shell/state/session-tabs"
import { terminalFontFamily, useSettings } from "@/settings/model"
@ -60,10 +69,12 @@ import {
storeImports,
storeName,
} from "./stores"
import { ui } from "./ui"
type Attached = {
sessions: Accessor<readonly SessionRef[]>
current: Accessor<MountedSession | undefined>
tabs: Tabs
screen: Accessor<SessionScreen | undefined>
scope: (server: string) => ServerScope
/** Records a session-scoped store so layout pruning drops it with the session. */
@ -75,7 +86,10 @@ type Attached = {
project: (server: string, title: string) => void
draft: Layout["draft"]
command: (id: string) => void
run: (id: string) => boolean
notify: (plugin: string, options: NotifyOptions) => Promise<NotifyResult>
font: Accessor<string>
mode: Accessor<"light" | "dark">
routing: Accessor<boolean>
path: Accessor<string>
keybind: (command: string) => readonly string[]
@ -95,6 +109,7 @@ export function createHostApis() {
// The session screen's breakpoint, negated, so no fractional width is both narrow and desktop.
const desktop = createMediaQuery("(min-width: 768px)")
const narrow = () => !desktop()
const systemDark = createMediaQuery("(prefers-color-scheme: dark)")
const [attached, setAttached] = createSignal<Attached>()
const removed = new Set<(value: { server: string; directory: string }) => void>()
const memory = new Map<string, ReturnType<Storage["memory"]>>()
@ -265,6 +280,26 @@ export function createHostApis() {
reveal: (path) => platform.revealPath?.(path) ?? Promise.resolve(false),
installed: (app) => platform.checkAppExists?.(app) ?? Promise.resolve(false),
forceFocus: (enabled) => platform.setForceFocus?.(enabled) ?? Promise.resolve(),
async pick(options) {
if (options?.directory) {
const picked = await platform.openDirectoryPickerDialog({
title: options.title,
multiple: options.multiple,
})
return picked === null ? [] : [picked].flat()
}
if (!platform.openFilePathPickerDialog) throw new Error("The file picker is unavailable")
return (
(await platform.openFilePathPickerDialog({
title: options?.title,
multiple: options?.multiple,
extensions: options?.extensions && [...options.extensions],
})) ?? []
)
},
}
: undefined,
app: () => ({
@ -280,8 +315,11 @@ export function createHostApis() {
direction: language.direction,
setDirection: language.setDirection,
}),
// Before the interface mounts, the default mono font.
appearance: () => ({ font: () => current()?.font() ?? terminalFontFamily(undefined) }),
// Before the interface mounts, the default mono font and the system's color scheme.
appearance: () => ({
font: () => current()?.font() ?? terminalFontFamily(undefined),
mode: () => current()?.mode() ?? (systemDark() ? "dark" : "light"),
}),
router: () => ({
routing: () => current()?.routing() ?? false,
path: () => current()?.path() ?? "",
@ -308,6 +346,49 @@ export function createHostApis() {
list: () => current()?.sessions() ?? [],
current: () => current()?.current(),
}),
// Before the interface mounts, no tabs are listed and writes wait for it.
tabs: () => ({
list: () => current()?.tabs.list() ?? [],
open: (session, options) => write((value) => value.tabs.open(session, options)),
focus: (key) => write((value) => value.tabs.focus(key)),
move: (key, index) => write((value) => value.tabs.move(key, index)),
close: (key) => write((value) => value.tabs.close(key)),
}),
toast: (_plugin, owner, _context, register) => ({
show(options) {
const id = showToast({
title: options.title,
description: options.description,
variant: options.variant,
icon: options.icon,
duration: options.duration,
persistent: options.persistent,
actions: options.actions?.map((action) => ({
label: action.label,
// Promise.try isolates a throw, so the toast still closes.
onClick: () =>
void Promise.try(() => action.run()).catch((cause: unknown) => console.error("[plugin]", cause)),
})),
})
const dismiss = () => void toaster.dismiss(id)
// Under the plugin's root owner, so the toast closes with the plugin, not with the component that showed it.
return { dismiss: runWithOwner(owner, () => register(dismiss)) ?? dismiss }
},
}),
// The user's notification settings live in the app interface, so a notification waits for it to mount.
attention: (plugin, _owner, context) => ({
notify: (options) =>
new Promise((resolve) =>
write((value) => {
if (context.signal.aborted) return resolve({ notification: false, sound: false })
void value.notify(plugin, options).then(resolve)
}),
),
}),
// No command is registered before the interface mounts.
commands: () => ({ run: (id) => untrack(attached)?.run(id) ?? false }),
screen: () => ({
current: () => {
const attached = current()
@ -341,6 +422,7 @@ export function createHostApis() {
command: (id) => write((value) => value.command(id)),
}),
embeds: () => embeds,
ui: () => ui,
}
return {
@ -390,6 +472,8 @@ export function createPluginAttachment(apis: HostApis) {
const host = usePluginHost()
const command = useCommand()
const location = useLocation()
const platform = usePlatform()
const theme = useTheme()
const menu = createSessionPanelMenu()
const desktop = createMediaQuery("(min-width: 768px)")
const narrow = () => !desktop()
@ -852,9 +936,126 @@ export function createPluginAttachment(apis: HostApis) {
layout.panel.setScroll(value, key, next)
}
// The tab the route shows: a session's, a draft's, or none (Home, Settings).
const routedTab = () => {
const value = route()
if (value.type === "session") return findSessionTab(tabs.store, value.server, value.sessionId)
if (value.type !== "draft") return
return tabs.store.find((tab) => tab.type === "draft" && tab.draftID === value.draftID)
}
const shellTabs = createMemo((): readonly ShellTab[] => {
const refs = new Map(sessions().map((ref) => [ref.key, ref]))
const active = routedTab()
return tabs.store.map((tab) => ({
key: tabKey(tab),
session: tab.type === "session" ? refs.get(`${tab.server}\n${tab.sessionId}`) : undefined,
active: tab === active,
}))
})
const findTab = (key: string) => tabs.store.find((tab) => tabKey(tab) === key)
// Opens a session as the session list and the app's notifications do; an unlisted server opens nothing.
const openSession: Tabs["open"] = (session, options) => {
const conn = servers.list.find((item) => ServerConnection.key(item) === session.server)
if (!conn) return
const key = ServerConnection.key(conn)
const shown = findSessionTab(tabs.store, key, session.id)
// A tab that already routes the session, e.g. a subagent opened from its parent's tab, keeps its route.
if (shown?.type === "session" && shown.routeSessionId === session.id) {
if (options?.focus) tabs.select(shown)
return
}
if (!options?.focus) return void tabs.addSessionTab({ server: key, sessionId: session.id })
openNotificationSession(tabs, key, session.id)
}
const shell: Tabs = {
list: shellTabs,
open: openSession,
focus(key) {
const tab = findTab(key)
if (tab) tabs.select(tab)
},
move(key, index) {
const keys = tabs.store.map(tabKey).filter((item) => item !== key)
if (keys.length === tabs.store.length) return
const at = Math.min(Math.max(0, Math.trunc(index)), keys.length)
tabs.reorder([...keys.slice(0, at), key, ...keys.slice(at)])
},
close(key) {
const index = tabs.store.findIndex((tab) => tabKey(tab) === key)
if (index !== -1) tabs.closeTab(index)
},
}
// Runs a command as the palette does; a listed but disabled command does not run.
const run = (id: string) => {
const option = untrack(() => command.options.find((item) => item.id === id))
if (!option?.onSelect || option.disabled) return false
const select = option.onSelect
void Promise.try(() => select("palette")).catch((cause: unknown) => console.error("[plugin]", cause))
return true
}
// Follows the user's settings for the app's own notifications: Agent, or Errors for `error`. The coordinator shows a
// keyed notification once across windows, and only while none of them has focus.
const notify = async (plugin: string, options: NotifyOptions): Promise<NotifyResult> => {
const error = options.variant === "error"
const allowed = untrack(() => ({
notification: error ? settings.notifications.errors() : settings.notifications.agent(),
sound: options.sound !== false && (error ? settings.sounds.errorsEnabled() : settings.sounds.agentEnabled()),
id: error ? settings.sounds.errors() : settings.sounds.agent(),
}))
const key = `plugin:${plugin}\0${options.id ?? crypto.randomUUID()}`
const result = { notification: false, sound: false }
const session = options.session
await Promise.allSettled([
allowed.sound &&
global.notifications.sound(key, () => {
result.sound = true
return playSoundById(allowed.id)
}),
allowed.notification &&
global.notifications.system(key, () => {
result.notification = true
return platform.notify(
options.title,
options.message,
session && (() => openSession(session, { focus: true })),
)
}),
])
return result
}
const mounted: Attached = {
sessions,
current,
tabs: shell,
run,
notify,
mode: theme.mode,
screen,
scope,
scoped: layout.sessionState.track,
@ -864,7 +1065,7 @@ export function createPluginAttachment(apis: HostApis) {
if (server) void tabs.newDraft({ server: ServerConnection.key(server), directory: input.directory }, input.prompt)
},
command: (id) => command.trigger(id),
command: (id) => void run(id),
font: () => terminalFontFamily(settings.appearance.terminalFont()),
routing: useIsRouting(),
path: () => `${location.pathname}${location.search}`,

View file

@ -16,6 +16,7 @@ import {
untrack,
useContext,
type Accessor,
type JSX,
type Owner,
type ParentProps,
} from "solid-js"
@ -31,6 +32,7 @@ import {
type Cleanup,
type Context,
type Contract,
type DialogHandle,
type Dialogs,
type Link,
type Links,
@ -39,6 +41,7 @@ import {
type Persisted,
type PluginOptions,
type Registry,
type SelectOptions,
type Ipc,
type IpcClient,
type IpcSpec,
@ -48,6 +51,7 @@ import {
} from "@opencode/plugin/gui"
import { OpenCode, type OpenCodeClient } from "@opencode/client/promise"
import { useLanguage } from "@/runtime/i18n/language"
import { PluginAlertDialog, PluginConfirmDialog, PluginPromptDialog, PluginSelectDialog } from "./dialogs"
import { createSessionStore, whenLoaded } from "./stores"
type Contribution = { key: string; registry: string; plugin: string; value: Accessor<unknown> }
@ -292,8 +296,13 @@ function createHost(input: HostInput) {
...input.apis,
links: () => links,
// Bound to the instance that asked, so an older async call after a disable or reload opens and closes nothing.
dialogs: (plugin, _, context, register): Dialogs => ({
open(render, options) {
dialogs: (plugin, _, context, register): Dialogs => {
// `ended` runs when the dialog closes, however it closes, and when it can no longer open.
const open = (
render: (dialog: DialogHandle) => JSX.Element,
options?: { readonly replace?: boolean },
ended?: () => void,
): DialogHandle => {
const id = `plugin:${plugin}:${sequence.value++}`
const waiting = { cancel: () => {} }
// This open alone: once it aborts, a deferred open neither mounts nor replaces another dialog.
@ -305,11 +314,16 @@ function createHost(input: HostInput) {
abort(opening)
waiting.cancel()
dialog.close(id)
ended?.()
}
const handle = { close }
if (context.signal.aborted) return handle
if (context.signal.aborted) {
ended?.()
return handle
}
// Closes this dialog, not whichever is on top, when the plugin or the scope that opened it goes away.
const release = register(close)
@ -338,7 +352,8 @@ function createHost(input: HostInput) {
</ErrorBoundary>
)
},
undefined,
// Escape, a click outside, a replacing dialog, or an open the stack dropped.
ended,
id,
// The stack mounts in a later transition. Its handle, its owner or the plugin ending before then keeps
// it closed.
@ -347,9 +362,34 @@ function createHost(input: HostInput) {
)
return handle
},
active: () => !!dialog.active,
}),
}
// Settles with the content's answer, or with `fallback` once the dialog ends without one; the first wins.
const ask = <T,>(fallback: T, content: (answer: (value: T) => void) => JSX.Element) =>
new Promise<T>((resolve) => {
open(
(handle) =>
content((value) => {
resolve(value)
handle.close()
}),
undefined,
() => resolve(fallback),
)
})
return {
open: (render, options) => open(render, options),
active: () => !!dialog.active,
confirm: (options) => ask(false, (answer) => <PluginConfirmDialog options={options} done={answer} />),
alert: (options) =>
ask(undefined, (answer) => <PluginAlertDialog options={options} done={() => answer(undefined)} />),
prompt: (options) =>
ask<string | undefined>(undefined, (answer) => <PluginPromptDialog options={options} done={answer} />),
select: <T,>(options: SelectOptions<T>) =>
ask<T | undefined>(undefined, (answer) => <PluginSelectDialog options={options} done={answer} />),
}
},
} satisfies { readonly [K in keyof HostApis]: HostApiFactory<HostApis[K]> }
const activate = async (definition: Entry) => {

View file

@ -22,7 +22,10 @@ import type {
SessionScreen,
} from "@opencode/plugin/gui"
import { useComments } from "@/composer/comments"
import { createComposerEditorActions } from "@/composer/editor/actions"
import { useComposerState } from "@/composer/persistence"
import type { ComposerStateValue } from "@/composer/state"
import { useLocal } from "@/providers/models/selection"
import { useServer } from "@/runtime/server/current"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { sessionInWorkspace, sessionProject, type SessionModel } from "@/session/model"
@ -44,6 +47,7 @@ export function createMountedSession(session: SessionModel) {
const file = useFile()
const comments = useComments()
const composer = useComposerState()
const local = useLocal()
const server = useServer()
const servers = useServers()
const location = useWorkspaceLocation()
@ -51,7 +55,7 @@ export function createMountedSession(session: SessionModel) {
// The composer region owns background tasks and is created after the view.
const [background, setBackground] = createSignal<Accessor<readonly BackgroundTask[]>>()
// The screen's composer view, created after this object, says whether it takes quotes and focuses its prompt.
const [prompt, setPrompt] = createSignal<{ quotable: Accessor<boolean>; focus: () => void }>()
const [prompt, setPrompt] = createSignal<{ quotable: Accessor<boolean>; focus: () => void; focused: () => boolean }>()
const serverRef: ServerRef = {
get id() {
@ -150,8 +154,80 @@ export function createMountedSession(session: SessionModel) {
active: { current: comments.active, set: (value) => void comments.setActive(value) },
}
// A write before the routed session's draft loads would replace the stored draft, so it waits for the load and then
// applies to that session. Later writes queue behind it to keep call order.
let writes: Promise<void> | undefined
const write = (apply: (draft: ComposerStateValue) => void) => {
const target = composer.capture()
if (!writes && composer.ready()) return apply(target)
const loaded = composer.ready() ? undefined : composer.ready.promise
const next = Promise.resolve(writes)
.then(() => loaded)
.catch(() => undefined)
.then(() => apply(target))
writes = next
const settle = () => {
if (writes === next) writes = undefined
}
void next.then(settle, settle)
}
// A subagent's session has no composer, so its draft is never sent.
const edit = (apply: (draft: ReturnType<typeof createComposerEditorActions>) => void) => {
if (!session.data.isChild()) write((target) => apply(createComposerEditorActions(target.store)))
}
const composerRef: Composer = {
attach: (part) => composer.context.add(part),
text: () =>
session.data.isChild()
? ""
: composer
.capture()
.current()
.map((part) => ("content" in part ? part.content : ""))
.join(""),
insert(text) {
const value = text.replace(/\r\n?/g, "\n")
if (value) edit((draft) => draft.addText(value))
},
setText: (value) => edit((draft) => draft.setText(value.replace(/\r\n?/g, "\n"))),
model() {
const item = session.data.isChild() ? undefined : local.model.current()
if (!item) return
return { providerID: item.provider.id, modelID: item.id, variant: local.model.variant.current() }
},
setModel(model) {
const item = session.data.isChild()
? undefined
: local.model.list().find((entry) => entry.provider.id === model.providerID && entry.id === model.modelID)
if (!item || (model.variant !== undefined && !Object.keys(item.variants ?? {}).includes(model.variant)))
return false
// As the model picker selects: the model first, then a variant chosen with it.
local.model.set({ providerID: model.providerID, modelID: model.modelID }, { recent: true })
if (model.variant !== undefined) local.model.variant.set(model.variant)
return true
},
setVariant(variant) {
if (session.data.isChild() || !local.model.current()) return false
if (variant !== undefined && !local.model.variant.list().includes(variant)) return false
local.model.variant.set(variant)
return true
},
attach: (part) => write((draft) => draft.context.add(part)),
quotable: () => !!prompt()?.quotable(),
focus(commentID) {
if (commentID === undefined) return prompt()?.focus()
@ -160,8 +236,9 @@ export function createMountedSession(session: SessionModel) {
if (item) composer.capture().quoteComment.set(item.key)
},
update: (id, patch) => composer.context.updateComment(id, patch),
detach: (id) => composer.context.removeComment(id),
update: (id, patch) => write((draft) => draft.context.updateComment(id, patch)),
detach: (id) => write((draft) => draft.context.removeComment(id)),
focused: () => !!prompt()?.focused(),
}
// Each object's memos live in its own root, which ends when the next object replaces it: a kept object stops
@ -270,6 +347,7 @@ export function createMountedSession(session: SessionModel) {
screen,
view: mounted,
bindBackground: (tasks: Accessor<readonly BackgroundTask[]>) => setBackground(() => tasks),
bindPrompt: (value: { quotable: Accessor<boolean>; focus: () => void }) => setPrompt(() => value),
bindPrompt: (value: { quotable: Accessor<boolean>; focus: () => void; focused: () => boolean }) =>
setPrompt(() => value),
}
}

View file

@ -0,0 +1,205 @@
import { children, For, Show } from "solid-js"
import type { Ui, UiMenuItem } from "@opencode/plugin/gui"
import { Markdown } from "@opencode/session-ui/markdown"
import { Button } from "@opencode/ui/button"
import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Keybind } from "@opencode/ui/keybind"
import { Loader } from "@opencode/ui/loader"
import { Menu } from "@opencode/ui/menu"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { Tooltip } from "@opencode/ui/tooltip"
const loaderSize = { small: 14, normal: 16, large: 20 }
/**
* `Ui`: the app's components behind the SDK's small, stable props. Each maps those props onto the real component, so
* a plugin renders exactly what the app renders. The components keep no state of their own.
*/
export const ui: Ui = {
Icon: (props) => <Icon name={props.name} size={props.size} class={props.class} />,
Button: (props) => (
<Button
type={props.type ?? "button"}
variant={props.variant}
size={props.size}
icon={props.busy ? undefined : props.icon}
disabled={props.disabled || props.busy}
aria-busy={props.busy || undefined}
autofocus={props.autofocus}
class={props.class}
onClick={(event: MouseEvent) => props.onClick?.(event)}
>
<Show when={props.busy}>
<Loader width={14} height={14} />
</Show>
{props.children}
</Button>
),
IconButton: (props) => (
<IconButton
type="button"
variant={props.variant ?? "ghost-muted"}
size={props.size}
disabled={props.disabled || props.busy}
state={props.pressed ? "pressed" : undefined}
aria-label={props.label}
aria-pressed={props.pressed}
aria-busy={props.busy || undefined}
class={props.class}
onClick={(event) => props.onClick?.(event)}
icon={
<Show when={props.busy} fallback={<Icon name={props.icon} />}>
<Loader />
</Show>
}
/>
),
Tooltip: (props) => (
<Tooltip
placement={props.placement ?? "top"}
value={
<div class="flex items-center gap-2">
<span>{props.value}</span>
<Show when={props.keys?.length}>
<Keybind keys={[...(props.keys ?? [])]} variant="neutral" />
</Show>
</div>
}
>
{props.children}
</Tooltip>
),
Menu: (props) => (
<Menu gutter={4} modal={false} placement={props.placement ?? "bottom-end"}>
<Show
when={props.trigger === "button"}
fallback={
<Menu.Trigger
as={IconButton}
variant="ghost-muted"
disabled={props.disabled}
icon={<Icon name={props.icon ?? "outline-dots"} />}
aria-label={props.label}
/>
}
>
<Menu.Trigger as={Button} variant="neutral" icon={props.icon} disabled={props.disabled}>
{props.label}
</Menu.Trigger>
</Show>
<Menu.Portal>
<Menu.Content>
<For each={props.items}>
{(entry) => {
if (entry.type === "separator") return <Menu.Separator />
if (entry.type === "group")
return (
<Menu.Group>
<Menu.GroupLabel>{entry.label}</Menu.GroupLabel>
<For each={entry.items}>{(item) => <MenuRow item={item} />}</For>
</Menu.Group>
)
return <MenuRow item={entry} />
}}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
),
Loader: (props) => (
<Loader
width={loaderSize[props.size ?? "normal"]}
height={loaderSize[props.size ?? "normal"]}
class={props.class}
/>
),
Dialog: (props) => {
const footer = children(() => props.footer)
return (
<Dialog
fit={(props.size ?? "fit") === "fit"}
size={props.size === "large" || props.size === "x-large" ? props.size : "normal"}
>
<DialogHeader hideClose={props.closable === false}>
<DialogTitleGroup title={props.title} description={props.description} />
</DialogHeader>
<DialogBody class="min-h-0">
{/* Without a footer, the content keeps the dialog's bottom padding itself. */}
<div data-scrollable class="min-h-0 flex-1 overflow-y-auto px-4" classList={{ "pb-4": !footer() }}>
{props.children}
</div>
</DialogBody>
<Show when={footer()}>
<DialogFooter>{footer()}</DialogFooter>
</Show>
</Dialog>
)
},
Switch: (props) => (
<Switch
checked={props.checked}
onChange={(checked) => props.onChange(checked)}
disabled={props.disabled}
hideLabel={props.hideLabel}
description={props.description}
>
{props.label}
</Switch>
),
TextInput: (props) => (
<TextInput
type={props.type ?? "text"}
appearance={props.size === "large" ? "large" : "base"}
value={props.value}
placeholder={props.placeholder}
aria-label={props.label}
disabled={props.disabled}
invalid={props.invalid}
autofocus={props.autofocus}
class={props.class}
onInput={(event) => props.onInput(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.isComposing || !props.onSubmit) return
event.preventDefault()
props.onSubmit(event.currentTarget.value)
}}
/>
),
Keybind: (props) => (
<Show when={props.keys.length > 0}>
<Keybind keys={[...props.keys]} variant="neutral" class={props.class} />
</Show>
),
Markdown: (props) => <Markdown text={props.text} class={props.class} />,
// The timeline's code block: a fenced block in Markdown, highlighted by the same worker.
Code: (props) => <Markdown text={fence(props.content, props.language)} class={props.class} />,
}
function MenuRow(props: { item: UiMenuItem }) {
return (
<Menu.Item
disabled={props.item.disabled}
onSelect={() => props.item.onSelect()}
shortcut={props.item.keys?.length ? <Keybind keys={[...props.item.keys]} variant="neutral" /> : undefined}
>
<Show when={props.item.icon}>{(icon) => <Icon name={icon()} class="text-v2-icon-icon-muted" />}</Show>
<span class="min-w-0 truncate">{props.item.label}</span>
</Menu.Item>
)
}
// A fence longer than any run of backticks in the code, so no line of the code can close it. The language keeps only
// the characters a fence's info string may hold.
function fence(content: string, language: string | undefined) {
const longest = Array.from(content.matchAll(/`+/g)).reduce((most, run) => Math.max(most, run[0].length), 0)
const ticks = "`".repeat(Math.max(3, longest + 1))
const info = (language ?? "").replace(/[^\w#+.-]/g, "")
return `${ticks}${info}\n${content.endsWith("\n") ? content : `${content}\n`}${ticks}`
}

View file

@ -111,6 +111,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
retry: serverHealth.retry,
},
models,
/** Runs each keyed sound and system notification once across windows; notifications only while none has focus. */
notifications: notificationCoordinator,
ensureServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn)
},

View file

@ -327,6 +327,7 @@ export function createActiveSessionRegion(input: {
return {
active,
focused: () => !!promptRef?.contains(document.activeElement),
drop: {
active: () => active()?.drop.active() ?? false,
input: () => active()?.drop.input(),

View file

@ -72,7 +72,7 @@ function SessionScreenContent(props: {
view: Accessor<MountedSession>
screen: SessionScreen
bindBackground: (tasks: () => readonly BackgroundTask[]) => void
bindPrompt: (prompt: { quotable: () => boolean; focus: () => void }) => void
bindPrompt: (prompt: { quotable: () => boolean; focus: () => void; focused: () => boolean }) => void
}) {
const session = props.session
const language = useLanguage()
@ -235,7 +235,7 @@ function SessionScreenContent(props: {
})
props.bindBackground(composer.requests.background.tasks)
props.bindPrompt({ quotable: composer.quote.available, focus: composer.quote.focus })
props.bindPrompt({ quotable: composer.quote.available, focus: composer.quote.focus, focused: composer.focused })
useUsageExceededDialogs({
selection: () => composer.active()?.composer.model.selection,
onDone: () => composer.active()?.composer.restoreFocus(),

View file

@ -128,7 +128,8 @@ export interface CommandOption {
when?: (event: KeyboardEvent) => boolean
/** The keybind leaves this event to its target, as if the command had no keybind. */
skip?: (event: KeyboardEvent) => boolean
onSelect?: (source?: "palette" | "keybind" | "slash", input?: string) => void | Promise<void>
/** From a keybind, returning false declines the key: the event goes on to its target. */
onSelect?: (source?: "palette" | "keybind" | "slash", input?: string) => void | false | Promise<void>
onHighlight?: () => (() => void) | void
}
@ -520,9 +521,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
if (!option) return
// A command that returns false declines the key, which goes on to its target.
const result = option.onSelect?.("keybind")
if (result === false) return
event.preventDefault()
event.stopPropagation()
void option.onSelect?.("keybind")
void result
}
onMount(() => {

View file

@ -61,6 +61,14 @@ describe("desktop attachment files", () => {
])
})
test("returns picked paths without reading the files, and releases the token", async () => {
const events: string[] = []
const files = createDesktopFiles(fileApi(events), "windows")
expect(await files.openFilePathPickerDialog({})).toEqual(["C:\\first.txt", "C:\\second.txt"])
expect(events).toEqual(["release:selection"])
})
test("releases the token when a selected file callback fails", async () => {
const events: string[] = []
const files = createDesktopFiles(fileApi(events), "windows")

View file

@ -54,9 +54,24 @@ export function createDesktopFiles(api: DesktopFileAPI, os: DesktopOS) {
}
}
// Only the paths: the token that would let this window read the files is released at once.
const openFilePathPickerDialog: NonNullable<Platform["openFilePathPickerDialog"]> = async (options) => {
const result = await api.openFilePicker({
multiple: options.multiple ?? false,
title: options.title,
extensions: options.extensions,
})
if (!result) return null
await api.releasePickedFiles(result.token)
return result.files.map((file) => file.path)
}
return {
openDirectoryPickerDialog,
openAttachmentPickerDialog,
openFilePathPickerDialog,
getPathForFile: (file: File) => attachmentPaths.get(file) ?? api.getPathForFile(file),
saveFile: (options: { title?: string; defaultPath?: string }, content: string) =>
api.saveFile({ title: options.title, defaultPath: options.defaultPath }, content),

View file

@ -210,7 +210,7 @@ const screenActive = useQuery(() => ({
Each line links to the file whose TSDoc covers every field.
SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the app's icon catalog (`@opencode/ui/icons/catalog`), which `bun run generate` in `packages/plugin` copies into `packages/plugin/src/gui/icon-name.ts`. `src/icon-name.typecheck.ts` fails the typecheck when the two drift.
SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the app's icon catalog (`@opencode/ui/icons/catalog`), which `bun run generate` in `packages/plugin` copies into `packages/plugin/src/gui/icon-name.ts`. `src/icon-name.typecheck.ts` fails the typecheck when the two drift. A plugin bundle cannot require `@opencode/ui`, so it renders an icon with `ctx.ui.Icon`, and the app's buttons, menus, dialog frame, fields, Markdown and code with the other `ctx.ui` components.
### Registries
@ -243,16 +243,21 @@ to the displayed repository or worktree instead of adding a second menu item.
| ---------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------- |
| `ctx.layout` | `Layout` | Side panel tabs, scroll offsets, settings, open project |
| `ctx.sessions` | `Sessions` | Sessions of open tabs, and the mounted `MountedSession` |
| `ctx.screen` | `Screen` | The session screen's files, comments and composer |
| `ctx.screen` | `Screen` | Session screen files, comments, composer text, parts and model |
| `ctx.storage` | `Storage` | Stores for keys known only at runtime, and window memory |
| `ctx.system` | `System` | Clipboard, saving files, `openExternal` |
| `ctx.desktop` | `Desktop \| undefined` | Desktop-only: reveal, launch, installed, zoom |
| `ctx.dialogs` | `Dialogs` | `open` returns a handle to close; a dialog closes with its owner |
| `ctx.desktop` | `Desktop \| undefined` | Desktop-only: reveal, launch, installed, zoom, pick files |
| `ctx.dialogs` | `Dialogs` | `open`, `confirm`, `alert`, `prompt`, `select`; close with owner |
| `ctx.toast` | `Toasts` | Toasts that close when the plugin goes away |
| `ctx.tabs` | `Tabs` | The shell's tab strip: list, open, show, move and close tabs |
| `ctx.attention` | `Attention` | System notifications and sounds, per the user's settings |
| `ctx.commands` | `Commands` | Runs a published or app command, as the palette does |
| `ctx.links` | `Links` | Opens a local link, or checks it exists, with the best handler |
| `ctx.embeds` | `Embeds` | Shows a web page the main entry created |
| `ctx.ui` | `Ui` | The app's icons, buttons, menus, dialog frame, Markdown and code |
| `ctx.app` | `App` | Version, channel, platform, packaged |
| `ctx.locale` | `Locale` | Locale and writing direction |
| `ctx.appearance` | `Appearance` | The user's mono font |
| `ctx.appearance` | `Appearance` | The user's mono font, and the light or dark scheme |
| `ctx.router` | `Router` | The route path, and whether it is changing |
| `ctx.keybinds` | `Keybinds` | Display and matching of command keybinds |
| `ctx.servers` | `Servers` | The servers the app lists, and each one's live `ServerRef` |

View file

@ -879,7 +879,7 @@ export function mountBrowserRegion(input: RegionHost) {
const about: App = { name: "opencode", version: "", channel: "dev", platform: "desktop", packaged: false }
const locale: Locale = { locale: () => "en", direction: () => "ltr" }
const appearance: Appearance = { font: () => "monospace" }
const appearance: Appearance = { font: () => "monospace", mode: () => "light" }
const router: Router = { routing: () => false, path: () => "/" }
const keybinds: Keybinds = { keybind: () => [], keys: (bind) => bind.split("+"), matches: () => false }
const servers: Servers = { list: () => [server.id], get: () => undefined }
@ -962,6 +962,8 @@ export function mountBrowserRegion(input: RegionHost) {
system: () => ({ copy: async () => undefined, openExternal() {}, save: async () => false }),
sessions: () => ({ list: () => refs, current: view }),
screen: () => ({ current: () => screen }),
toast: () => ({ show: () => ({ dismiss() {} }) }),
tabs: () => ({ list: () => [], open() {}, focus() {}, move() {}, close() {} }),
layout,
storage,
}

View file

@ -16,6 +16,8 @@ import type {
} from "./core.js"
import type {
Appearance,
Attention,
Commands,
Desktop,
Dialogs,
Embeds,
@ -30,6 +32,9 @@ import type {
Sessions,
Storage,
System,
Tabs,
Toasts,
Ui,
Workspaces,
} from "./host-apis.js"
@ -152,6 +157,44 @@ export interface Context extends BaseContext {
* ```
*/
readonly dialogs: Dialogs
/**
* Short messages in a corner of the window. A plugin's toasts close when it goes away.
*
* @example
* ```ts
* const saving = ctx.toast.show({ title: ctx.t("saving"), variant: "loading", persistent: true })
* await save()
* saving.dismiss()
* ```
*/
readonly toast: Toasts
/**
* The shell's tab strip: open, show, move and close session tabs.
*
* @example
* ```ts
* const first = (key: string) => ctx.tabs.move(key, 0)
* ```
*/
readonly tabs: Tabs
/**
* System notifications and sounds that follow the user's notification settings, for when they look elsewhere.
*
* @example
* ```ts
* void ctx.attention.notify({ title: ctx.t("tests.done"), session: { server: session.server.id, id: session.id } })
* ```
*/
readonly attention: Attention
/**
* Runs published plugin commands and the app's own, as the command palette does.
*
* @example
* ```ts
* if (!ctx.commands.run("terminal.toggle")) ctx.toast.show({ title: ctx.t("terminal.unavailable") })
* ```
*/
readonly commands: Commands
/**
* Routes local links to the plugins that handle them.
*
@ -170,6 +213,16 @@ export interface Context extends BaseContext {
* ```
*/
readonly embeds: Embeds
/**
* The app's own components: icons, buttons, tooltips, menus, the loading indicator, the dialog frame, switches,
* text fields, keybind chips, Markdown and highlighted code.
*
* @example
* ```tsx
* <ctx.ui.Button icon="check" onClick={apply}>{ctx.t("apply")}</ctx.ui.Button>
* ```
*/
readonly ui: Ui
/**
* The running app: its version, release channel and platform, as `ctx.app` in server and TUI plugins.
*

View file

@ -526,10 +526,78 @@ export interface ComposerQuote {
}
}
/** The session screen's composer: the parts attached to the routed session's next prompt. */
/** A model the composer sends a prompt with, as the model picker names it. */
export interface ComposerModel {
/** The provider's id, e.g. `anthropic`. */
providerID: string
/** The model's id within its provider, e.g. `claude-sonnet-4-5`. */
modelID: string
/** The model's variant, e.g. `high`. Undefined for the model's default. */
variant?: string
}
/**
* The session screen's composer: the routed session's next prompt, with its text, its attached parts and the model it
* is sent with. Nothing here sends the prompt; the user does. A write to the draft (`insert`, `setText`, `attach`,
* `update`, `detach`) made before the session's stored draft loads waits for it, then applies to the session routed
* when it was made, in call order with the other draft writes; the stored draft is never replaced.
*/
export interface Composer {
/**
* Attaches a file, a note or a quote to the next prompt. Focus stays where it is; call `focus` to move it.
* The prompt's text, as plain text: a mention reads as its `@` text, and attachments and attached parts are left
* out. In shell mode, the shell command. Empty until the session's draft loads, and in a subagent's session, which
* has no composer. Reactive.
*
* @example
* const empty = () => screen.composer.text().trim() === ""
*/
text(): string
/**
* Inserts plain text into the prompt as typing would, at the cursor the user last left in it, else at the end, and
* moves the cursor after it. Selected text is not replaced. The text is not parsed: `@path` stays text rather than a
* mention, and line breaks stay line breaks. Focus stays where it is; call `focus` to move it. Before the session's
* draft loads, it waits and applies in call order. Does nothing in a subagent's session, which has no composer, or
* for empty text.
*
* @param text - The text to insert.
*
* @example
* screen.composer.insert(`Explain ${path}`)
*/
insert(text: string): void
/**
* Replaces the prompt's text, mentions included, and moves the cursor to the end. Attachments and attached parts
* stay. Before the session's draft loads, it waits and applies in call order. Does nothing in a subagent's session,
* which has no composer.
*
* @param text - The new text. Empty clears the text.
*/
setText(text: string): void
/**
* The model the next prompt is sent with: the user's choice for the routed session, else the session's agent
* default, else the app's default. Undefined while the model catalog loads, when no connected provider offers the
* choice, and in a subagent's session, which has no composer. Reactive.
*/
model(): ComposerModel | undefined
/**
* Selects the model for the routed session's next prompts, as picking it in the model picker does: it becomes the
* session's choice and the first recent model. Without `variant`, the model keeps the variant the picker would show
* for it. Returns false, and changes nothing, while the model catalog loads, when no connected provider offers the
* model, when the model has no such variant, and in a subagent's session.
*
* @param model - The model, and optionally its variant.
*/
setModel(model: ComposerModel): boolean
/**
* Selects a variant of the current model, as the composer's variant control does. Returns false, and changes
* nothing, when there is no current model, when the model has no such variant, and in a subagent's session.
*
* @param variant - The variant. Undefined selects the model's default.
*/
setVariant(variant: string | undefined): boolean
/**
* Attaches a file, a note or a quote to the next prompt. Focus stays where it is; call `focus` to move it. Before
* the session's draft loads, it waits and applies in call order.
*
* @param part - The part to attach.
*/
@ -551,7 +619,8 @@ export interface Composer {
*/
focus(commentID?: string): void
/**
* Changes an attached part. Files, notes and quotes alike; a note or a quote takes only `comment`.
* Changes an attached part. Files, notes and quotes alike; a note or a quote takes only `comment`. Before the
* session's draft loads, it waits and applies in call order.
*
* @param id - The part's `commentID`.
* @param patch - The fields to change.
@ -566,11 +635,18 @@ export interface Composer {
},
): void
/**
* Removes an attached part.
* Removes an attached part. Before the session's draft loads, it waits and applies in call order.
*
* @param id - The part's `commentID`.
*/
detach(id: string): void
/**
* Whether keyboard focus is in the prompt now. Not reactive: read it where you act, such as in `Command.run`.
*
* @example
* run: () => (screen.composer.focused() ? send() : false)
*/
focused(): boolean
}
/** Work the session moved to the background. */
@ -1000,6 +1076,32 @@ export interface Desktop {
* @param app - The app's name.
*/
installed(app: string): Promise<boolean>
/**
* Asks the user for files or folders with the system's open dialog. Desktop only: a browser never tells the page
* where a picked file is.
*
* @param options - What to pick.
* @returns The absolute paths the user picked, in the order the dialog reports them; empty when the user cancelled.
* Rejects when the dialog cannot open.
*
* @example
* ```ts
* const [folder] = (await ctx.desktop?.pick({ directory: true, title: ctx.t("pick.folder") })) ?? []
* ```
*/
pick(options?: PickOptions): Promise<readonly string[]>
}
/** What `Desktop.pick` asks for. */
export interface PickOptions {
/** Picks folders instead of files. Defaults to false. */
readonly directory?: boolean
/** Lets the user pick more than one. Defaults to false. */
readonly multiple?: boolean
/** The dialog's title; the system's default title when omitted. Not every system shows it. */
readonly title?: string
/** File extensions to offer, without the dot (e.g. `["json"]`); every file when omitted. Ignored for folders. */
readonly extensions?: readonly string[]
}
/** The interface language and its writing direction. */
@ -1022,6 +1124,18 @@ export interface Appearance {
* @param kind - `mono`, the terminal font.
*/
font(kind: "mono"): string
/**
* The color scheme the interface shows: the user's choice in Settings › Appearance, with "System" resolved to the
* system's current scheme. Before the app interface mounts, the system's scheme. Reactive.
* - `light`: dark text on light backgrounds.
* - `dark`: light text on dark backgrounds.
*
* @example
* ```ts
* const background = () => (ctx.appearance.mode() === "dark" ? "#000" : "#fff")
* ```
*/
mode(): "light" | "dark"
}
/** The app's route. */
@ -1153,6 +1267,319 @@ export interface Dialogs {
): DialogHandle
/** Some dialog is open. Reactive. */
active(): boolean
/**
* Asks the user to confirm an action, in the app's own confirmation dialog. Opens as `open` does, with the same
* lifetime: it waits for the app interface, and closes with the owner that asked, else with the plugin.
*
* @param options - The question and its buttons.
* @returns True only when the user chose the confirm button. False when they chose cancel, pressed Escape or clicked
* outside, when another dialog replaced it, and when it closed or never opened because its owner or plugin went
* away.
*
* @example
* ```ts
* if (await ctx.dialogs.confirm({ title: ctx.t("end.title"), confirm: ctx.t("end"), danger: true })) end()
* ```
*/
confirm(options: ConfirmOptions): Promise<boolean>
/**
* Tells the user something they must acknowledge, in a dialog with one button. Opens and closes as `confirm` does.
*
* @param options - What the dialog says.
* @returns Resolves once the dialog closes, however it closes.
*
* @example
* ```ts
* await ctx.dialogs.alert({ title: ctx.t("export.done"), description: path })
* ```
*/
alert(options: AlertOptions): Promise<void>
/**
* Asks the user for a line of text, in the app's own dialog. Enter submits. Opens and closes as `confirm` does.
*
* @param options - The question, the field and its button.
* @returns The text the user submits, which is never empty: the submit button and Enter do nothing while the field
* is empty. Undefined when the user cancels, dismisses the dialog, or it closes with its owner.
*
* @example
* ```ts
* const invite = await ctx.dialogs.prompt({ title: ctx.t("join.title"), placeholder: "https://…" })
* ```
*/
prompt(options: PromptOptions): Promise<string | undefined>
/**
* Asks the user to pick one option from a searchable list, in the app's own dialog, as the command palette lists
* commands. Opens and closes as `confirm` does.
*
* @param options - The title, the search field and the options.
* @returns The picked option's `value`; undefined when the user dismisses the dialog or it closes with its owner.
*
* @example
* ```ts
* const url = await ctx.dialogs.select({ title: ctx.t("links"), options: links.map((url) => ({ title: url, value: url })) })
* ```
*/
select<T>(options: SelectOptions<T>): Promise<T | undefined>
}
/** What `Dialogs.confirm` asks. */
export interface ConfirmOptions {
/** The question, as the dialog's title. */
readonly title: string
/** More detail under the title, such as what the action cannot undo. Omit it for none. */
readonly description?: string
/** The confirm button's label. Defaults to the app's "Confirm". */
readonly confirm?: string
/** The cancel button's label. Defaults to the app's "Cancel". */
readonly cancel?: string
/**
* Styles the confirm button as destructive, for an action that deletes or ends something, and starts focus on
* Cancel. Defaults to false.
*/
readonly danger?: boolean
}
/** What `Dialogs.alert` says. */
export interface AlertOptions {
/** The headline, as the dialog's title. */
readonly title: string
/** More detail under the title. Omit it for none. */
readonly description?: string
/** The button's label. Defaults to the app's "OK". */
readonly ok?: string
}
/** What `Dialogs.prompt` asks. */
export interface PromptOptions {
/** The question, as the dialog's title. */
readonly title: string
/** More detail under the title. Omit it for none. */
readonly description?: string
/** The field's placeholder. Omit it for none. */
readonly placeholder?: string
/** The field's initial text, selected so typing replaces it. Defaults to "". */
readonly value?: string
/**
* Hides what the user types, for a password or a token. The host neither stores nor logs the value. Defaults to
* false.
*/
readonly secret?: boolean
/** The submit button's label. Defaults to the app's "Submit". */
readonly submit?: string
}
/** One option of `Dialogs.select`. */
export interface SelectOption<T> {
/** The option's label; search matches it. */
readonly title: string
/** What `select` resolves with when the user picks this option. */
readonly value: T
/** A muted second line; search matches it too. Omit it for none. */
readonly description?: string
/** The heading the option lists under. Options of one group list together, in the order first seen. */
readonly group?: string
}
/** What `Dialogs.select` asks. */
export interface SelectOptions<T> {
/** The dialog's title. */
readonly title: string
/** The search field's placeholder. Defaults to the app's "Search". */
readonly placeholder?: string
/** The options, in display order. */
readonly options: readonly SelectOption<T>[]
/** The value of the option to highlight first, compared with `===`. Defaults to the first option. */
readonly current?: T
}
/** A button on a toast. */
export interface ToastAction {
/** The button's label. */
readonly label: string
/** Runs when the user clicks it; the toast then closes. A throw is logged to the console and the toast still closes. */
run(): void
}
/** What `Toasts.show` shows. */
export interface ToastOptions {
/** The headline. */
readonly title: string
/** More detail under the title. Omit it for none. */
readonly description?: string
/**
* The toast's tone. Defaults to `default`.
* - `default`: neutral information.
* - `success`: something finished; shows a check icon unless `icon` is set.
* - `error`: something failed.
* - `loading`: something is in progress. Pair it with `persistent` and dismiss it when the work ends.
*/
readonly variant?: "default" | "success" | "error" | "loading"
/** An icon before the title. Omit it for none, or the variant's own. */
readonly icon?: IconName
/** Milliseconds before the toast closes itself. Ignored when `persistent` is true. Defaults to the app's 5 seconds. */
readonly duration?: number
/** Stays until the user or `dismiss` closes it, instead of closing after `duration`. Defaults to false. */
readonly persistent?: boolean
/** Buttons on the toast, in order. Omit it for none. */
readonly actions?: readonly ToastAction[]
}
/** One toast `Toasts.show` showed. */
export interface ToastHandle {
/** Closes the toast. Does nothing once it closed. */
dismiss(): void
}
/**
* The app's toasts: short messages in a corner of the window that do not take focus. Every toast of a plugin that is
* still showing closes when the plugin goes away (disabled, reloaded or removed), so no button outlives the code it
* runs; a toast does not close with the component or `createKeyed` run that showed it.
*/
export interface Toasts {
/**
* Shows a toast at once, also before the app interface mounts: it appears as soon as the window shows its toasts.
* Showing a toast equal to one still showing (same title, description, variant, duration, persistence and action
* labels) replaces that one instead of stacking a copy.
*
* @param options - What the toast shows.
* @returns The toast's handle.
*/
show(options: ToastOptions): ToastHandle
}
/** What `Attention.notify` sends. */
export interface NotifyOptions {
/** The notification's headline. */
readonly title: string
/** The notification's body. Omit it for none. */
readonly message?: string
/**
* Which of the user's notification settings it follows, both the system notification and the sound. Defaults to
* `default`.
* - `default`: Settings › Notifications › Agent ("complete or needs attention") and its sound.
* - `error`: Settings › Notifications › Errors and its sound.
*/
readonly variant?: "default" | "error"
/** The session the notification is about: clicking the notification opens and shows it, as `Tabs.open` does. */
readonly session?: {
/** The server's `ServerRef.id`. */
readonly server: string
/** The session's id on that server. */
readonly id: string
}
/** Plays the sound the user chose for `variant`, when its sound is on. False never plays one. Defaults to true. */
readonly sound?: boolean
/**
* Shows the notification and plays its sound at most once across the app's windows, each of which runs its own
* instance of the plugin: the first window to send an id wins, and later sends with that id do nothing. Use an id
* of the event, such as a run's id. Omit it to always notify.
*/
readonly id?: string
}
/** What `Attention.notify` did. */
export interface NotifyResult {
/**
* The app asked the system to show the notification. False when the user turned it off for `variant`, when one of
* the app's windows has focus, or when another window already sent `id`. The system may still hide one it was
* asked to show, for example when the user denied a browser's permission.
*/
readonly notification: boolean
/** The sound started. False when `sound` is false, the user turned it off for `variant`, or `id` was sent before. */
readonly sound: boolean
}
/** Gets the user's attention while they look elsewhere, with the same rules as the app's own notifications. */
export interface Attention {
/**
* Sends a system notification while no window of the app has focus, and plays a sound, each only when the user's
* notification settings for `variant` allow it. Clicking the notification brings the window forward and opens
* `session`, when given. Before the app interface mounts, it waits for it.
*
* @param options - What to send.
* @returns What it did; never rejects.
*/
notify(options: NotifyOptions): Promise<NotifyResult>
}
/** The commands the app runs from its command palette, keybinds and slash menu. */
export interface Commands {
/**
* Runs a command as choosing it in the command palette does: a published plugin command (`${plugin}.${id}`, any
* plugin's) or one of the app's own. The app's command ids are not a stable API and may change between releases.
* A command that is listed but disabled does not run. A command that runs async work is not awaited.
*
* @param id - The command's id.
* @returns False when no enabled command has the id, and before the app interface mounts, when the app has
* registered no commands yet.
*/
run(id: string): boolean
}
/** One tab of the shell's tab strip. */
export interface ShellTab {
/** The tab's key, `SessionRef.tab` for a session's tab. Stays the same while the tab is open. */
readonly key: string
/** The session the tab shows; undefined for a draft. */
readonly session: SessionRef | undefined
/** The tab is the one on screen. */
readonly active: boolean
}
/**
* The shell's tab strip: the sessions and drafts open as tabs. Nothing throws before the app interface mounts:
* `list` is empty, and writes wait and apply in call order once it mounts.
*/
export interface Tabs {
/** The open tabs, in strip order. Reactive. */
list(): readonly ShellTab[]
/**
* Opens a session as a tab at the end of the strip, or keeps its open tab where it is. A tab that shows the session
* already, such as a subagent opened from its parent's tab, is its tab. Does nothing when the app does not list the
* server. A session id the server does not know still opens a tab, which shows the app's "session not found" page.
*
* @param session - The session's server and id.
* @param options - Whether to show the tab.
*
* @example
* ```ts
* ctx.tabs.open({ server: session.server.id, id: session.id }, { focus: true })
* ```
*/
open(
session: {
/** The server's `ServerRef.id`. */
readonly server: string
/** The session's id on that server. */
readonly id: string
},
options?: {
/**
* Also shows the tab and the session in it, as clicking the session in the session list does. Defaults to
* false: the tab opens in the background.
*/
readonly focus?: boolean
},
): void
/**
* Shows an open tab, as a click on it does. Does nothing when no open tab has the key.
*
* @param key - The tab's `ShellTab.key`.
*/
focus(key: string): void
/**
* Moves an open tab to a place in the strip. Does nothing when no open tab has the key.
*
* @param key - The tab's `ShellTab.key`.
* @param index - The tab's new place, from 0; clamped to the strip.
*/
move(key: string, index: number): void
/**
* Closes an open tab, as the user does: it lists under recently closed tabs. Does nothing when no open tab has the key.
*
* @param key - The tab's `ShellTab.key`.
*/
close(key: string): void
}
/** Props of `Embeds.View`. */
@ -1195,3 +1622,429 @@ export interface Embeds {
*/
capture(id: string): Promise<Uint8Array | undefined>
}
/** Props of `Ui.Icon`. */
export interface UiIconProps {
/** The artwork, by its name in the app's icon catalog. */
readonly name: IconName
/**
* The icon's size. Defaults to `normal`.
* - `small`: 14px.
* - `normal`: 16px.
* - `large`: 20px.
*/
readonly size?: "small" | "normal" | "large"
/** Classes of the `svg`, for layout and color: the icon draws in `currentColor`. */
readonly class?: string
}
/** Props of `Ui.Button`. */
export interface UiButtonProps {
/** The button's text. */
readonly children: JSX.Element
/** Runs when the user clicks the button, or presses Enter or Space on it. Not called while it is disabled or busy. */
readonly onClick?: (event: MouseEvent) => void
/**
* How the button looks. Defaults to `neutral`.
* - `neutral`: a bordered button for most actions.
* - `contrast`: the main action of a dialog or form.
* - `ghost`: no border or background until hovered, for toolbars.
* - `ghost-muted`: like `ghost`, with muted text, for secondary toolbar actions.
* - `danger`: an action that deletes or discards.
*/
readonly variant?: "neutral" | "contrast" | "ghost" | "ghost-muted" | "danger"
/**
* The button's height. Defaults to `normal`.
* - `small`: for dense rows.
* - `normal`: for most places.
* - `large`: for toolbars that use large icon buttons.
*/
readonly size?: "small" | "normal" | "large"
/** An icon before the text. Omit it for none. */
readonly icon?: IconName
/** The button cannot be clicked. Defaults to false. */
readonly disabled?: boolean
/**
* The action is running: a loading indicator replaces the icon, and the button cannot be clicked until it is false
* again. Defaults to false.
*/
readonly busy?: boolean
/** `submit` submits the form the button is in. Defaults to `button`. */
readonly type?: "button" | "submit"
/** Takes focus when its dialog opens, as the app's own dialogs focus their main button. Defaults to false. */
readonly autofocus?: boolean
/** Classes of the button, for layout. The app's styles give it its look. */
readonly class?: string
}
/** Props of `Ui.IconButton`. */
export interface UiIconButtonProps {
/** The button's icon. */
readonly icon: IconName
/** The button's accessible name, which screen readers announce. Pass translated copy; it is not shown. */
readonly label: string
/** Runs when the user clicks the button, or presses Enter or Space on it. Not called while it is disabled or busy. */
readonly onClick?: (event: MouseEvent) => void
/**
* How the button looks. Defaults to `ghost-muted`.
* - `neutral`: a bordered button.
* - `ghost`: no border or background until hovered.
* - `ghost-muted`: like `ghost`, with a muted icon, for toolbars.
*/
readonly variant?: "neutral" | "ghost" | "ghost-muted"
/**
* The button's size. Defaults to `normal`.
* - `small`: for dense rows.
* - `normal`: for most places.
* - `large`: for panel toolbars.
*/
readonly size?: "small" | "normal" | "large"
/** The button cannot be clicked. Defaults to false. */
readonly disabled?: boolean
/** The button is a toggle that is on: it shows pressed and reports `aria-pressed`. Omit it for a plain button. */
readonly pressed?: boolean
/**
* The action is running: a loading indicator replaces the icon, and the button cannot be clicked until it is false
* again. Defaults to false.
*/
readonly busy?: boolean
/** Classes of the button, for layout. The app's styles give it its look. */
readonly class?: string
}
/** Props of `Ui.Tooltip`. */
export interface UiTooltipProps {
/** What the tooltip shows: usually translated text. */
readonly value: JSX.Element
/** Keys shown after `value`, such as `ctx.keybinds.keybind(command)`. Omit it, or pass an empty list, for none. */
readonly keys?: readonly string[]
/** Where the tooltip opens from the element. Defaults to `top`; it flips when there is no room. */
readonly placement?: "top" | "bottom" | "left" | "right"
/** The element that shows the tooltip while the pointer rests on it or it has keyboard focus. */
readonly children: JSX.Element
}
/** An action in `UiMenuProps.items`. */
export interface UiMenuItem {
/** Marks the entry as an action. Optional: an entry without `type` is an action. */
readonly type?: "item"
/** The action's text. */
readonly label: string
/** An icon before the text. Omit it for none. */
readonly icon?: IconName
/** Keys shown at the end of the row, such as `ctx.keybinds.keybind(command)`. Omit it for none. */
readonly keys?: readonly string[]
/** The action cannot be chosen. Defaults to false. */
readonly disabled?: boolean
/** Runs when the user chooses the action; the menu then closes. */
onSelect(): void
}
/** A line between entries in `UiMenuProps.items`. */
export interface UiMenuSeparator {
/** Marks the entry as a separator. */
readonly type: "separator"
}
/** Actions under a heading in `UiMenuProps.items`. */
export interface UiMenuGroup {
/** Marks the entry as a group. */
readonly type: "group"
/** The group's heading. */
readonly label: string
/** The group's actions, in order. */
readonly items: readonly UiMenuItem[]
}
/** One entry of `UiMenuProps.items`: an action, a separator, or a group of actions under a heading. */
export type UiMenuEntry = UiMenuItem | UiMenuSeparator | UiMenuGroup
/** Props of `Ui.Menu`. */
export interface UiMenuProps {
/** The trigger's accessible name, and its text when `trigger` is `button`. Pass translated copy. */
readonly label: string
/** The menu's entries, in order. */
readonly items: readonly UiMenuEntry[]
/**
* The button that opens the menu. Defaults to `icon`.
* - `icon`: an icon button, as `Ui.IconButton` with the `ghost-muted` look.
* - `button`: a button that shows `label`, as `Ui.Button`.
*/
readonly trigger?: "icon" | "button"
/** The trigger's icon. Defaults to the "more" dots for an `icon` trigger, and to no icon for a `button` trigger. */
readonly icon?: IconName
/** Where the menu opens from the trigger. Defaults to `bottom-end`; it flips when there is no room. */
readonly placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end"
/** The trigger cannot open the menu. Defaults to false. */
readonly disabled?: boolean
}
/** Props of `Ui.Loader`. */
export interface UiLoaderProps {
/**
* The indicator's size. Defaults to `normal`.
* - `small`: 14px, for buttons and dense rows.
* - `normal`: 16px.
* - `large`: 20px, for a pane that loads.
*/
readonly size?: "small" | "normal" | "large"
/** Classes of the indicator, for layout and color. */
readonly class?: string
}
/** Props of `Ui.Dialog`. */
export interface UiDialogProps {
/** The dialog's title, which also names it for screen readers. */
readonly title: string
/** Text under the title. Omit it for none. */
readonly description?: string
/** The dialog's content, with the app's dialog padding. It scrolls when it is taller than the dialog. */
readonly children?: JSX.Element
/** The dialog's buttons, aligned to the end of its last row. Omit it for none. */
readonly footer?: JSX.Element
/**
* The dialog's size. Defaults to `fit`.
* - `fit`: 480px wide and as tall as its content.
* - `normal`: 480 by 368px.
* - `large`: 640 by 480px.
* - `x-large`: up to 980 by 600px, smaller in a small window.
*/
readonly size?: "fit" | "normal" | "large" | "x-large"
/**
* Shows the close button in the header, labelled with the app's "Close". Escape and a click outside close the dialog
* either way. Defaults to true.
*/
readonly closable?: boolean
}
/** Props of `Ui.Switch`. */
export interface UiSwitchProps {
/** The switch is on. */
readonly checked: boolean
/** Runs with the new state when the user flips the switch. Pass it on to `checked` to keep the switch in step. */
readonly onChange: (checked: boolean) => void
/** The switch's label, which also names it for screen readers. Pass translated copy. */
readonly label: string
/** Hides the label from view and keeps it for screen readers, for a row that shows its own text. Defaults to false. */
readonly hideLabel?: boolean
/** Text under the label. Omit it for none. */
readonly description?: string
/** The switch cannot be flipped. Defaults to false. */
readonly disabled?: boolean
}
/** Props of `Ui.TextInput`. */
export interface UiTextInputProps {
/** The field's text. */
readonly value: string
/** Runs with the field's text on every edit. Pass it on to `value` to keep the field in step. */
readonly onInput: (value: string) => void
/** The field's accessible name, which screen readers announce. Pass translated copy; it is not shown. */
readonly label: string
/** Text the empty field shows. Omit it for none. */
readonly placeholder?: string
/** Runs with the field's text when the user presses Enter, except while an input method composes text. */
readonly onSubmit?: (value: string) => void
/**
* What the field holds. Defaults to `text`.
* - `text`: plain text.
* - `password`: hidden text.
* - `search`: a search query.
* - `url`: a web address.
*/
readonly type?: "text" | "password" | "search" | "url"
/**
* The field's height. Defaults to `normal`.
* - `normal`: 28px.
* - `large`: 32px, as in dialogs.
*/
readonly size?: "normal" | "large"
/** The field cannot be edited. Defaults to false. */
readonly disabled?: boolean
/** Shows the field as invalid and reports `aria-invalid`. Defaults to false. */
readonly invalid?: boolean
/** Takes focus when its dialog opens. Defaults to false. */
readonly autofocus?: boolean
/** Classes of the field, for layout. The app's styles give it its look. */
readonly class?: string
}
/** Props of `Ui.Keybind`. */
export interface UiKeybindProps {
/** The keys, one chip each, as `Keybinds.keybind` and `Keybinds.keys` return them. Empty shows nothing. */
readonly keys: readonly string[]
/** Classes of the chips' row, for layout. */
readonly class?: string
}
/** Props of `Ui.Markdown`. */
export interface UiMarkdownProps {
/** The Markdown source. */
readonly text: string
/** Classes of the rendered block, such as a text size. */
readonly class?: string
}
/** Props of `Ui.Code`. */
export interface UiCodeProps {
/** The code, shown as it is. */
readonly content: string
/**
* The language to highlight, by a highlighter id or a common alias (`ts`, `tsx`, `py`, `json`, `bash`). A language
* the app cannot highlight shows plain text; `mermaid` draws the diagram, as the timeline does. Defaults to plain
* text.
*/
readonly language?: string
/** Classes of the code block, for layout. */
readonly class?: string
}
/**
* The app's own components, so a plugin's UI looks and behaves like the app's: the same buttons, menus, dialogs and
* fields, in the user's theme, fonts and language. Each takes a small set of props the SDK keeps stable. A component
* renders with the context of the plugin that renders it, and works in any of its contributions and dialogs.
*/
export interface Ui {
/**
* An icon from the app's catalog, the artwork every `icon` field of the SDK names.
*
* @param props - The icon and its size.
*
* @example
* ```tsx
* <ctx.ui.Icon name="branch" size="small" />
* ```
*/
Icon(props: UiIconProps): JSX.Element
/**
* A button with text.
*
* @param props - Its text, look and action.
*
* @example
* ```tsx
* <ctx.ui.Button variant="contrast" busy={saving()} onClick={save}>{ctx.t("save")}</ctx.ui.Button>
* ```
*/
Button(props: UiButtonProps): JSX.Element
/**
* A button that shows only an icon. Wrap it in `Tooltip` to show its label on hover.
*
* @param props - Its icon, accessible name and action.
*
* @example
* ```tsx
* <ctx.ui.IconButton icon="outline-rotate-clockwise" label={ctx.t("reload")} onClick={reload} />
* ```
*/
IconButton(props: UiIconButtonProps): JSX.Element
/**
* A tooltip on an element.
*
* @param props - What it shows, and the element.
*
* @example
* ```tsx
* <ctx.ui.Tooltip value={ctx.t("toggle")} keys={ctx.keybinds.keybind(`${ctx.id}.toggle`)}>
* <ctx.ui.IconButton icon="sidebar" label={ctx.t("toggle")} onClick={toggle} />
* </ctx.ui.Tooltip>
* ```
*/
Tooltip(props: UiTooltipProps): JSX.Element
/**
* A button that opens a menu of actions.
*
* @param props - The trigger and the entries.
*
* @example
* ```tsx
* <ctx.ui.Menu label={ctx.t("more")} items={[{ label: ctx.t("delete"), icon: "trash", onSelect: remove }]} />
* ```
*/
Menu(props: UiMenuProps): JSX.Element
/**
* The app's loading indicator, for anything that loads, connects or runs that is not the AI working.
*
* @param props - Its size.
*
* @example
* ```tsx
* <Show when={!loading()} fallback={<ctx.ui.Loader />}>{content()}</Show>
* ```
*/
Loader(props: UiLoaderProps): JSX.Element
/**
* The app's dialog frame: title, content and buttons. Render it from `ctx.dialogs.open`; it renders nothing useful
* elsewhere.
*
* @param props - The title, content and buttons.
*
* @example
* ```tsx
* ctx.dialogs.open((dialog) => (
* <ctx.ui.Dialog title={ctx.t("about")} footer={<ctx.ui.Button onClick={dialog.close}>{ctx.t("done")}</ctx.ui.Button>}>
* {ctx.t("about.body")}
* </ctx.ui.Dialog>
* ))
* ```
*/
Dialog(props: UiDialogProps): JSX.Element
/**
* An on/off switch with a label.
*
* @param props - Its state, label and handler.
*
* @example
* ```tsx
* <ctx.ui.Switch label={ctx.t("wrap")} checked={wrap()} onChange={setWrap} />
* ```
*/
Switch(props: UiSwitchProps): JSX.Element
/**
* A one-line text field.
*
* @param props - Its text, accessible name and handlers.
*
* @example
* ```tsx
* <ctx.ui.TextInput label={ctx.t("name")} value={name()} onInput={setName} onSubmit={save} />
* ```
*/
TextInput(props: UiTextInputProps): JSX.Element
/**
* Keys shown as chips, as menus and tooltips show shortcuts.
*
* @param props - The keys.
*
* @example
* ```tsx
* <ctx.ui.Keybind keys={ctx.keybinds.keybind(`${ctx.id}.toggle`)} />
* ```
*/
Keybind(props: UiKeybindProps): JSX.Element
/**
* Markdown rendered as the timeline renders a message: headings, lists, tables, links, math and highlighted code
* blocks with a copy button. On the session screen, links to local files open through the LinkHandler
* contributions, as the timeline's do. Rendering is asynchronous: the block fills in shortly after it mounts.
*
* @param props - The Markdown source.
*
* @example
* ```tsx
* <ctx.ui.Markdown text={summary()} />
* ```
*/
Markdown(props: UiMarkdownProps): JSX.Element
/**
* A read-only code block, highlighted as the timeline highlights code, with a copy button. Highlighting is
* asynchronous: the block fills in shortly after it mounts.
*
* @param props - The code and its language.
*
* @example
* ```tsx
* <ctx.ui.Code content={JSON.stringify(config, null, 2)} language="json" />
* ```
*/
Code(props: UiCodeProps): JSX.Element
}

View file

@ -58,11 +58,19 @@ export interface Command {
*/
readonly editable?: true
/**
* Runs the command from the palette, the keybind or the slash command.
* Runs the command from the palette, the keybind or the slash command. From the keybind, return `false`
* synchronously to decline the key: the event goes on to its target, as if the command had no keybind, e.g. Enter
* reaches the prompt. `false` from the palette or a slash command does nothing more.
*
* @param input - The slash command's arguments, when `slash.arguments` is set.
* @returns `false` to decline the key; anything else handles it.
*
* @example
* ```ts
* run: () => (ctx.screen.current()?.composer.focused() ? keepGoing() : false)
* ```
*/
run(input?: string): void | Promise<void>
run(input?: string): void | false | Promise<void>
}
/** The fields every `MenuItem` has, whichever menu it is in. */

View file

@ -1,19 +1,26 @@
import { createResource, onCleanup, type JSX } from "solid-js"
import type { Entry } from "@opencode/plugin/gui/host"
import { pluralCategory } from "@opencode/ui/context/i18n"
import { useTheme } from "@opencode/ui/theme/context"
import { toaster } from "@opencode/ui/toast"
import { useLanguage } from "@/runtime/i18n/language"
import { ui } from "@/runtime/plugin/ui"
import { showToast } from "@/shell/notifications/toast"
import type { BuiltinLocale } from "../../plugin/src/gui/builtin"
import { PluginContext } from "../../plugin/src/gui/internal"
import {
type App,
type Appearance,
type Attention,
type Catalog,
type Commands,
type Context,
type Keybinds,
type Messages,
type Params,
type Router,
type Servers,
type Toasts,
type Workspaces,
} from "../../plugin/src/gui/index"
@ -81,13 +88,16 @@ function createStoryContext(id: string, definition: Entry) {
},
}
// The HostApis a story has no stand-in for fail when read.
;["layout", "sessions", "screen", "storage", "system", "dialogs", "links", "embeds"].forEach((name) =>
;["layout", "sessions", "screen", "storage", "system", "dialogs", "tabs", "links", "embeds"].forEach((name) =>
Object.defineProperty(context, name, { get: unavailable(`ctx.${name}`) }),
)
return context as unknown as Context
}
/** The host's app, locale, appearance, router, keybind, server and workspace APIs for a story, by context property. */
/**
* The host's app, locale, appearance, router, keybind, server, workspace, toast, attention, command and UI APIs for a
* story, by context property.
*/
export function createStoryHostApis(platform: App["platform"]) {
const language = useLanguage()
const app: App = { name: "opencode", version: "", channel: "dev", platform, packaged: false }
@ -97,11 +107,24 @@ export function createStoryHostApis(platform: App["platform"]) {
direction: language.direction,
setDirection: language.setDirection,
}
const appearance: Appearance = { font: () => "var(--font-family-mono)" }
const theme = useTheme()
const appearance: Appearance = { font: () => "var(--font-family-mono)", mode: theme.mode }
const router: Router = { routing: () => false, path: () => "/" }
const keybinds: Keybinds = { keybind: () => [], keys: () => [], matches: () => false }
const servers: Servers = { list: () => [], get: () => undefined }
const workspaces: Workspaces = { on: () => () => {} }
// Stories show real toasts; they have no command registry and send no system notifications.
const toast: Toasts = {
show: (options) => {
const id = showToast({
...options,
actions: options.actions?.map((action) => ({ label: action.label, onClick: action.run })),
})
return { dismiss: () => void toaster.dismiss(id) }
},
}
const attention: Attention = { notify: async () => ({ notification: false, sound: false }) }
const commands: Commands = { run: () => false }
return {
app: () => app,
locale: () => locale,
@ -110,6 +133,11 @@ export function createStoryHostApis(platform: App["platform"]) {
keybinds: () => keybinds,
servers: () => servers,
workspaces: () => workspaces,
toast: () => toast,
attention: () => attention,
commands: () => commands,
// The app's own components, as the host provides them.
ui: () => ui,
}
}

View file

@ -200,6 +200,12 @@ OpenCode Desktop loads GUI entries from its own background server, which already
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.
<Callout type="note">
The TUI reads its plugins from `cli.json`, but the desktop app reads only `opencode.json(c)`. A package that shipped
only a TUI part, and that users list in `cli.json`, must also be listed in `opencode.json(c)` once it adds
`./gui-experimental`.
</Callout>
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.
@ -672,6 +678,31 @@ ctx.add(
- `enabled: false` takes the command out of the palette and turns its keybind off. It is reactive.
- `scope` is a CSS selector that keyboard focus must be inside for the binding to apply. With `editable`, 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](#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:
```ts
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 `false` synchronously; a promise handles the key.
- `false` from the palette or a slash command does nothing more.
#### Reference
@ -691,7 +722,7 @@ interface Command {
enabled?: boolean
scope?: string
editable?: true
run(input?: string): void | Promise<void>
run(input?: string): void | false | Promise<void>
}
```
@ -1347,9 +1378,14 @@ interface Context {
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
@ -1457,7 +1493,8 @@ if (session && file) void file.sync("README.md")
- `background` lists 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 `file` reads the workspace's files, and `comment` holds the line comments of the next prompt.
`composer` attaches parts to it; see [the next section](#attach-to-the-composer).
`composer` is the next prompt: its text, attached parts, and model; see
[Attach to the composer](#attach-to-the-composer).
- `file.sync(path)` loads a file and asks the server to watch it for changes. Call `file.unwatch(path)` when the file's
tab closes.
@ -1598,14 +1635,6 @@ if (composer?.quotable()) {
#### Reference
```ts
interface Composer {
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
}
interface ComposerFile {
type: "file"
path: string
@ -1637,6 +1666,89 @@ interface ComposerQuote {
}
```
### Read and write the prompt
Read what the user typed with `text()`, and whether they are typing with `focused()`:
```ts
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()` is `true` while keyboard focus is in the prompt. It is not reactive, so read it where you act, such as in
a command's `run`.
`insert(text)` types text into the prompt for the user to review and send. Nothing on the composer sends the prompt.
```ts
const composer = ctx.screen.current()?.composer
composer?.insert(`Explain ${path}`)
composer?.focus()
```
- `insert` adds 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: `@path` stays 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`, or `detach`) 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:
```ts
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 is
`undefined` while the model catalog loads, when no connected provider offers the model, and in a subagent's session.
It is reactive.
- `setModel` makes the model the session's choice and its first recent model. Without `variant`, the model keeps the
variant the picker would show for it.
- `setVariant(variant)` selects a variant of the current model. `undefined` selects the model's default variant.
- `setModel` returns `false`, and changes nothing, while the catalog loads, when no connected provider offers the
model, or when the model has no such variant.
- `setVariant` returns `false`, and changes nothing, when there is no current model or the model has no such variant.
- Both return `false` in a subagent's session.
#### Reference
```ts
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
@ -1696,18 +1808,475 @@ const handle = ctx.dialogs.open((dialog) => <ConfirmDialog onDone={dialog.close}
- `replace: true` closes every open dialog first, including the host's and other plugins' dialogs.
- `ctx.dialogs.active()` is `true` while any dialog is open.
- Render the content in `ctx.ui.Dialog` for the app's dialog frame; see [App components](#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.
```ts
const ok = await ctx.dialogs.confirm({ title: ctx.t("end.title"), confirm: ctx.t("end"), danger: true })
```
- `confirm` resolves `true` only when the user confirms. `danger` styles the confirm button as destructive and starts
focus on Cancel.
- Each dialog opens as `open` does, and closes with the owner that asked, else with your plugin.
- Cancelling, pressing Escape, clicking outside, a replacing dialog, or the owner ending resolves `false` from `confirm`
and `undefined` from `prompt` and `select`.
`alert` tells the user something they must acknowledge, with one button. It resolves once the dialog closes, however it
closes:
```ts
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:
```ts
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`:
```ts
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 `title` and `description`.
- Options with the same `group` list together under that heading.
- `current` is the value of the option to highlight first.
#### Reference
```ts
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.
```tsx
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`, and `IconButton` take any `IconName`, as every `icon` field of the SDK does. An `IconButton`'s
`label` is its accessible name; wrap it in `Tooltip` to show it on hover.
- `busy` on a button replaces its icon with the loading indicator and disables it until it is `false` again.
- Use `Loader` for every loading state that is not the AI working, such as a pane, a search, or a connection that
loads.
- `Menu` opens a list of `items`: actions, `{ type: "separator" }`, and `{ type: "group", label, items }`.
- `Switch` and `TextInput` keep no state: pass the value from `onChange` or `onInput` back to `checked` or `value`.
- `Keybind` shows keys as chips, such as `ctx.keybinds.keybind(command)`.
- `Markdown` renders as the timeline renders a message, and `Code` shows 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.
```tsx
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>
))
```
- `size` is `"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: false` hides the header's close button.
#### Reference
```ts
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.
```ts
ctx.toast.show({ variant: "success", title: ctx.t("invite.copied") })
```
- `variant` is `"default"`, `"success"`, `"error"`, or `"loading"`. A `success` toast shows a check icon unless you set
`icon`.
- `duration` is how long the toast shows, in milliseconds. It defaults to 5 seconds.
- `persistent: true` keeps the toast until the user or `dismiss()` closes it, and ignores `duration`.
- 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 `createKeyed` run that showed it.
Show a `loading` toast while work runs, and dismiss it when the work ends:
```ts
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:
```ts
ctx.toast.show({
variant: "error",
title: ctx.t("sync.failed"),
actions: [{ label: ctx.t("retry"), run: () => void sync() }],
})
```
#### Reference
```ts
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.
```ts
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, and `variant: "error"` the
**Errors** rows. Desktop notifications for both are off by default; their sounds are on.
- `sound: false` never 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 `id` of the event, such as a run's id, so only the first
window to send it notifies and plays the sound. Without `id`, every call notifies.
- Before the app interface mounts, `notify` waits 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.
```ts
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") })
```
- `notification` is `false` when the user turned it off, an app window has focus, or another window already sent
`id`. The system can still hide a notification the app asked it to show, for example when the user denied a
browser's permission.
#### Reference
```ts
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:
```ts
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's `session` is `undefined` for a draft, and
`active` is `true` for the tab on screen.
- `open({ server, id }, { focus? })` opens a session as a tab at the end of the strip, in the background unless `focus`
is `true`. 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, and `close(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
```ts
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
@ -1735,6 +2304,30 @@ interface Keybinds {
}
```
### 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:
```ts
if (!ctx.commands.run("terminal.toggle")) ctx.toast.show({ title: ctx.t("terminal.unavailable") })
```
- `id` is a published plugin command, `<plugin id>.<command id>`, of any plugin, or one of the app's own commands,
such as `command.palette`.
- The app's command ids are not a stable API and can change between releases.
- `run` returns `false` when no enabled command has the id. A listed but disabled command does not run.
- Before the app interface mounts, no command is registered, so `run` returns `false`.
- `run` does not wait for a command's async work.
#### Reference
```ts
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.
@ -1760,6 +2353,30 @@ const show = async (path: string) => {
- `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:
```ts
const [folder] = (await ctx.desktop?.pick({ directory: true, title: ctx.t("pick.folder") })) ?? []
if (folder) await ctx.client.rpc(Acme).index({ directory: folder })
```
- `directory: true` picks folders instead of files, and `multiple: true` lets the user pick more than one.
- `extensions` limits the files offered, without the dot, such as `["json"]`. Folders ignore it.
- `title` is the dialog's title. Not every system shows it.
- The paths are empty when the user cancels. `pick` rejects when the dialog cannot open.
- `pick` is 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:
```ts
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
```ts
@ -1775,6 +2392,14 @@ interface Desktop {
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 {
@ -1789,6 +2414,7 @@ interface Locale {
interface Appearance {
font(kind: "mono"): string
mode(): "light" | "dark"
}
interface Router {