mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-11 20:22:46 +00:00
feat(plugin): add toasts, dialogs, notifications, UI, tabs and composer APIs to GUI plugins (#59469)
This commit is contained in:
parent
c11a32ff1e
commit
758cf5adbe
23 changed files with 2626 additions and 50 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
177
packages/app/src/runtime/plugin/dialogs.tsx
Normal file
177
packages/app/src/runtime/plugin/dialogs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
205
packages/app/src/runtime/plugin/ui.tsx
Normal file
205
packages/app/src/runtime/plugin/ui.tsx
Normal 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}`
|
||||
}
|
||||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue