feat(plugin): add settings rows, selects and menu rows to ctx.ui (#61527)

This commit is contained in:
Luke Parker 2026-11-03 18:10:41 -06:00 • committed by GitHub
parent 922994ac1e
commit 3fcdcb35e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 723 additions and 170 deletions

View file

@ -1,5 +1,6 @@
import type { PluginInfo, SessionMessageInfo } from "@opencode/client/promise"
import { DialogProvider } from "@opencode/ui/context/dialog"
import { Menu } from "@opencode/ui/menu"
import { Toast } from "@opencode/ui/toast"
import {
Panel,
@ -545,8 +546,9 @@ export function mountPanelWidths(input: { definitions: readonly Entry[]; active:
/**
* Mounts the real host and HostApis, and the parts of the session screen that render plugin content for the routed
* session: the side region (`createRegion`, `RegionContent`) with every panel's tabs stored, and the
* `session.panel.end` slot, whose input the screen builds this way. `route(id)` routes a session the way the screen
* session: the side region (`createRegion`, `RegionContent`) with every panel's tabs stored, the
* `session.panel.end` slot, whose input the screen builds this way, and the `session.location.menu` slot inside a
* "Project menu" the way the session header's project menu holds it. `route(id)` routes a session the way the screen
* does: a new session object each time, delivered through the same reactive inputs, while the one screen object
* `ctx.screen` returns follows the route. Its composer records which session each attachment reached.
*/
@ -627,6 +629,24 @@ export function mountSessionRegion(input: {
screen,
}}
/>
<Menu modal={false}>
{/* Without text, so the cases that read the region's text see only the plugins'. */}
<Menu.Trigger aria-label="Project menu" style={{ width: "16px", height: "16px" }} />
<Menu.Portal>
<Menu.Content>
<PluginSlot
at="session.location.menu"
input={{
get session() {
return view()
},
screen,
shown: true,
}}
/>
</Menu.Content>
</Menu.Portal>
</Menu>
<RegionContent
pane={region.pane("side")}
view={view()}

View file

@ -303,7 +303,7 @@ story("a plugin's toasts close when it goes away, and a handle closes only its o
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 }) => {
story("ctx.ui renders the app's components in a contribution, a host menu and a dialog", async ({ page }) => {
await page.evaluate(async (fixture) => {
const { mountSessionRegion, until, createComponent, createSignal, Slot } = await import(fixture)
@ -322,6 +322,7 @@ story("ctx.ui renders the app's own components in a plugin's contribution and di
const ui = ctx.ui
const [on, setOn] = createSignal(false)
const [name, setName] = createSignal("")
const [period, setPeriod] = createSignal("day")
const dialog = () =>
ctx.dialogs.open((handle) =>
@ -374,6 +375,64 @@ story("ctx.ui renders the app's own components in a plugin's contribution and di
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" }),
createComponent(ui.SettingsLayout, {
title: "Fixture settings",
description: "Fixture page description",
get children() {
return createComponent(ui.SettingsSection, {
title: "Fixture section",
footer: "Fixture footer",
get children() {
return createComponent(ui.SettingsRow, {
id: "fixture-period",
title: "Period",
description: "How far back to count",
get children() {
return createComponent(ui.Select<"day" | "month">, {
label: "Period",
options: [
{ value: "day", label: "Day" },
{ value: "month", label: "Month" },
],
get value() {
return period()
},
onChange: (value: "day" | "month") => {
setPeriod(value)
record(`select ${value}`)
},
})
},
})
},
})
},
}),
],
})
ctx.add(Slot, {
at: "session.location.menu",
render: () => [
createComponent(ui.MenuSeparator, {}),
createComponent(ui.MenuItem, {
label: "Usage",
icon: "status",
keys: ["U"],
onSelect: () => record("usage"),
}),
createComponent(ui.MenuGroup, {
label: "Fixture menu group",
get children() {
return createComponent(ui.MenuSub, {
label: "Open in",
onOpenChange: (open: boolean) => record(`sub ${open}`),
get children() {
return createComponent(ui.MenuItem, { label: "Editor", onSelect: () => record("editor") })
},
})
},
}),
],
})
},
@ -427,6 +486,43 @@ story("ctx.ui renders the app's own components in a plugin's contribution and di
await expect(dialog.locator('[data-slot="dialog-close-button"]')).toHaveCount(1)
await dialog.getByRole("button", { name: "Close fixture" }).click()
await expect(dialog).toHaveCount(0)
// Settings components render the host's settings markup, and search finds the row by its id.
await expect(root.locator(".settings-tab-header h2.settings-tab-title")).toHaveText("Fixture settings")
const section = root.getByRole("region", { name: "Fixture section" })
await expect(section.locator("h3.settings-section-title")).toHaveText("Fixture section")
await expect(section).toContainText("Fixture footer")
const row = section.locator('[data-component="settings-list"] > [data-component="settings-row"]')
await expect(row).toHaveAttribute("data-action", "fixture-period")
await expect(row.locator('[data-slot="settings-row-description"]')).toHaveText("How far back to count")
const select = row.locator('[data-slot="settings-row-control"] [data-component="select-v2"]')
// The trigger is named by the label and the selected choice.
await expect(select).toHaveAccessibleName("Period Day")
await select.click()
await page.getByRole("option", { name: "Month" }).click()
await expect(select).toHaveAccessibleName("Period Month")
// Menu rows render inside the host's menu as its own rows: keyboard items, a group, and a submenu.
await root.getByRole("button", { name: "Project menu" }).click()
const project = page.getByRole("menu")
await expect(project.getByRole("separator")).toHaveCount(1)
const usage = project.getByRole("menuitem", { name: "Usage" })
await expect(usage).toHaveAttribute("data-component", "menu-v2-item")
await expect(usage.locator('use[href="#opencode-v2-icon-status"]')).toHaveCount(1)
await expect(usage.locator('[data-component="keybind-v2"]')).toHaveText("U")
await usage.click()
await expect(project).toHaveCount(0)
await root.getByRole("button", { name: "Project menu" }).click()
await expect(page.getByRole("menu").getByText("Fixture menu group")).toBeVisible()
await page.getByRole("menuitem", { name: "Open in" }).hover()
await page.getByRole("menu", { name: "Open in" }).getByRole("menuitem", { name: "Editor" }).click()
await expect(page.getByRole("menu")).toHaveCount(0)
await expect(page.locator("body")).toHaveAttribute(
"data-ui",
/^button;menu;submit acme;select month;usage;sub true;editor;/,
)
})
story(

View file

@ -8,6 +8,7 @@ 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 { Select } from "@opencode/ui/select"
import { Switch } from "@opencode/ui/switch"
import { TextInput } from "@opencode/ui/text-input"
import { Tooltip } from "@opencode/ui/tooltip"
@ -100,17 +101,38 @@ export const ui: Ui = {
return (
<Menu.Group>
<Menu.GroupLabel>{entry.label}</Menu.GroupLabel>
<For each={entry.items}>{(item) => <MenuRow item={item} />}</For>
<For each={entry.items}>{(item) => <MenuItem {...item} />}</For>
</Menu.Group>
)
return <MenuRow item={entry} />
return <MenuItem {...entry} />
}}
</For>
</Menu.Content>
</Menu.Portal>
</Menu>
),
MenuItem,
MenuSeparator: () => <Menu.Separator />,
MenuGroup: (props) => (
<Menu.Group>
<Menu.GroupLabel>{props.label}</Menu.GroupLabel>
{props.children}
</Menu.Group>
),
MenuSub: (props) => (
<Menu.Sub gutter={4} overflowPadding={16} overlap onOpenChange={(open) => props.onOpenChange?.(open)}>
<Menu.SubTrigger disabled={props.disabled} class="[&_[data-slot=menu-v2-item-chevron]]:text-v2-icon-icon-muted">
<Show when={props.icon}>{(icon) => <Icon name={icon()} class="text-v2-icon-icon-muted" />}</Show>
<span class="min-w-0 truncate">{props.label}</span>
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="w-max min-w-[172px] max-w-[calc(100vw-32px)]" aria-label={props.label}>
{props.children}
</Menu.SubContent>
</Menu.Portal>
</Menu.Sub>
),
Loader: (props) => (
<Loader
width={loaderSize[props.size ?? "normal"]}
@ -171,6 +193,22 @@ export const ui: Ui = {
}}
/>
),
Select: (props) => (
<Select
options={[...props.options]}
current={props.options.find((option) => option.value === props.value)}
value={(option) => option.value}
label={(option) => option.label}
placeholder={props.placeholder}
aria-label={props.label}
disabled={props.disabled}
invalid={props.invalid}
placement={props.placement ?? "bottom-end"}
gutter={6}
class={props.class}
onSelect={(option) => option && props.onChange(option.value)}
/>
),
Keybind: (props) => (
<Show when={props.keys.length > 0}>
<Keybind keys={[...props.keys]} variant="neutral" class={props.class} />
@ -179,17 +217,60 @@ export const ui: Ui = {
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} />,
// The settings screen's markup. Its stylesheet loads with that screen, where every `SettingsPage` renders, so the
// startup chunk does not carry it.
SettingsLayout: (props) => (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="settings-tab-title">{props.title}</h2>
<Show when={props.description}>
<span class="text-11-regular text-v2-text-text-muted">{props.description}</span>
</Show>
</div>
<div class="settings-tab-header-actions">{props.actions}</div>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">{props.children}</div>
</>
),
SettingsSection: (props) => (
<section class="settings-section" aria-label={props.title}>
<Show when={props.title}>
<h3 class="settings-section-title">{props.title}</h3>
</Show>
<div data-component="settings-list">{props.children}</div>
{props.footer}
</section>
),
SettingsRow: (props) => {
const description = children(() => props.description)
// Settings search reveals the row whose `data-action` is the entry it picked.
return (
<div data-component="settings-row" data-action={props.id}>
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title">{props.title}</div>
<Show when={description()}>
<div data-slot="settings-row-description">{description()}</div>
</Show>
</div>
<div data-slot="settings-row-control">{props.children}</div>
</div>
)
},
}
function MenuRow(props: { item: UiMenuItem }) {
function MenuItem(props: 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}
disabled={props.disabled}
onSelect={() => props.onSelect()}
shortcut={props.keys?.length ? <Keybind keys={[...props.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>
<Show when={props.icon}>{(icon) => <Icon name={icon()} class="text-v2-icon-icon-muted" />}</Show>
<span class="min-w-0 truncate">{props.label}</span>
</Menu.Item>
)
}

View file

@ -236,7 +236,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. 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.
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, menu rows, dialog frame, fields, selects, settings pages and rows, Markdown and code with the other `ctx.ui` components. Built-ins use them too where they need nothing more, such as the settings rows of pairing, updates and review.
### Registries
@ -257,7 +257,8 @@ SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the
`session.location.menu` contributes items after the project menu's location details. It receives the owning `screen`,
the live `session`, and `shown`; keep data subscriptions active only while `shown` is true. Loading headers without an
owning screen show the host's location details without this slot.
owning screen show the host's location details without this slot. Render its rows with `ctx.ui.MenuItem`,
`ctx.ui.MenuSub`, `ctx.ui.MenuGroup` and `ctx.ui.MenuSeparator`, so they navigate and close like the host's rows.
`session.location.branch.menu` (built-ins only, through `BuiltinSlot`) contributes items after Copy branch name in
the branch submenu, with the same `screen`, live `session`, and project-menu `shown` context. It renders only when a
@ -291,7 +292,7 @@ to the displayed repository or worktree instead of adding a second menu item.
| `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.ui` | `Ui` | Icons, buttons, menu rows, dialog frame, fields, settings rows |
| `ctx.app` | `App` | Version, channel, platform, packaged |
| `ctx.locale` | `Locale` | Locale and writing direction |
| `ctx.appearance` | `Appearance` | The user's mono font, and the light or dark scheme |

View file

@ -1,9 +1,8 @@
import { Button } from "@opencode/ui/button"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Loader } from "@opencode/ui/loader"
import { Switch } from "@opencode/ui/switch"
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createMemo, createSignal, onCleanup, Show, type Accessor, type JSX } from "solid-js"
import { createMemo, createSignal, onCleanup, Show, type Accessor } from "solid-js"
import { renderSVG } from "@opencode/util/qr"
import { usePlugin, type Live, type IpcClient } from "@opencode/plugin/gui"
import type { Pairing } from "./contract"
@ -33,6 +32,7 @@ export default function PairingPage(props: {
pairing: Accessor<Live<Client>>
}) {
const ctx = usePlugin<typeof definition>()
const ui = ctx.ui
// The plugin's dialogs close with it, so disabling pairing also stops the dialog's code polling.
const dialogs = ctx.dialogs
const queryClient = useQueryClient()
@ -140,74 +140,67 @@ export default function PairingPage(props: {
}))
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{ctx.t("title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{ctx.t("description")}</span>
</div>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section" aria-label={ctx.t("title")}>
<div data-component="settings-list">
<Row title={ctx.t("pair.title")} description={ctx.t("pair.description")}>
<Button
variant="neutral"
disabled={!main() || !props.server() || (change.isPending && change.variables?.enabled === false)}
onClick={pair}
>
{ctx.t("pair.open")}
</Button>
</Row>
<Show when={main()}>
<div data-action="settings-remote-access" class="border-b-[0.5px] border-v2-border-border-base">
<Row title={ctx.t("remote.title")} description={ctx.t("remote.description")}>
<Switch
hideLabel
checked={change.isPending ? change.variables?.enabled === true : remote.data === true}
disabled={!remote.isSuccess || change.isPending}
onChange={(enabled) => change.mutate({ enabled })}
>
{ctx.t("remote.title")}
</Switch>
</Row>
</div>
<div data-action="settings-keep-screen-active">
<Row title={ctx.t("screenActive.title")} description={ctx.t("screenActive.description")}>
<Switch
hideLabel
checked={screenActive.isSuccess && screenActive.data}
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
onChange={(enabled) => screenActivity.mutate(enabled)}
>
{ctx.t("screenActive.title")}
</Switch>
</Row>
</div>
<ui.SettingsLayout title={ctx.t("title")} description={ctx.t("description")}>
<ui.SettingsSection
footer={
<>
<Show when={main() && (remote.error || change.error)}>
<p class="text-text-danger-base" role="alert">
{ctx.t("remote.error")}
</p>
</Show>
</div>
<Show when={main() && (remote.error || change.error)}>
<p class="text-text-danger-base" role="alert">
{ctx.t("remote.error")}
</p>
</Show>
<Show when={main() && (screenActive.error || screenActivity.error)}>
<p class="text-text-danger-base" role="alert">
{ctx.t("screenActive.error")}
</p>
</Show>
{/* Pending main is still starting; anything else that leaves it inactive keeps pairing from turning on. */}
<Show when={props.pairing().status === "inactive" || !props.server()}>
<p class="text-text-danger-base" role="alert">
{ctx.t("pair.unavailable")}
</p>
</Show>
</section>
</div>
</>
<Show when={main() && (screenActive.error || screenActivity.error)}>
<p class="text-text-danger-base" role="alert">
{ctx.t("screenActive.error")}
</p>
</Show>
{/* Pending main is still starting; anything else that leaves it inactive keeps pairing from turning on. */}
<Show when={props.pairing().status === "inactive" || !props.server()}>
<p class="text-text-danger-base" role="alert">
{ctx.t("pair.unavailable")}
</p>
</Show>
</>
}
>
<ui.SettingsRow title={ctx.t("pair.title")} description={ctx.t("pair.description")}>
<ui.Button
disabled={!main() || !props.server() || (change.isPending && change.variables?.enabled === false)}
onClick={pair}
>
{ctx.t("pair.open")}
</ui.Button>
</ui.SettingsRow>
<Show when={main()}>
<ui.SettingsRow
id="settings-remote-access"
title={ctx.t("remote.title")}
description={ctx.t("remote.description")}
>
<ui.Switch
label={ctx.t("remote.title")}
hideLabel
checked={change.isPending ? change.variables?.enabled === true : remote.data === true}
disabled={!remote.isSuccess || change.isPending}
onChange={(enabled) => change.mutate({ enabled })}
/>
</ui.SettingsRow>
<ui.SettingsRow
id="settings-keep-screen-active"
title={ctx.t("screenActive.title")}
description={ctx.t("screenActive.description")}
>
<ui.Switch
label={ctx.t("screenActive.title")}
hideLabel
checked={screenActive.isSuccess && screenActive.data}
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
onChange={(enabled) => screenActivity.mutate(enabled)}
/>
</ui.SettingsRow>
</Show>
</ui.SettingsSection>
</ui.SettingsLayout>
)
}
@ -401,16 +394,3 @@ function DialogPairing(props: {
function countdown(seconds: number) {
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`
}
// The host's settings row markup; its stylesheet is loaded with the settings screen.
function Row(props: { title: string; description: string; children: JSX.Element }) {
return (
<div data-component="settings-row">
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title">{props.title}</div>
<div data-slot="settings-row-description">{props.description}</div>
</div>
<div data-slot="settings-row-control">{props.children}</div>
</div>
)
}

View file

@ -1,34 +1,28 @@
import { Switch } from "@opencode/ui/switch"
import { usePlugin } from "@opencode/plugin/gui"
import type Review from "./index"
/** The narrow-screen diff wrap row of the host's General settings section, in the host's settings row markup. */
/** The narrow-screen diff wrap row of the host's General settings section. */
export default function WrapLinesRow() {
const ctx = usePlugin<typeof Review>()
const ui = ctx.ui
const mobileDiff = ctx.stores.mobileDiff
return (
<div data-component="settings-row">
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title">{ctx.t("settings.wrapLines.title")}</div>
<div data-slot="settings-row-description">{ctx.t("settings.wrapLines.description")}</div>
</div>
<div data-slot="settings-row-control">
<div data-action="settings-mobile-diff-wrap">
<Switch
aria-label={ctx.t("settings.wrapLines.title")}
checked={mobileDiff.value.wrap}
onChange={(checked) =>
mobileDiff.update((draft) => {
draft.wrap = checked
})
}
hideLabel
>
{ctx.t("settings.wrapLines.title")}
</Switch>
</div>
</div>
</div>
<ui.SettingsRow
id="settings-mobile-diff-wrap"
title={ctx.t("settings.wrapLines.title")}
description={ctx.t("settings.wrapLines.description")}
>
<ui.Switch
label={ctx.t("settings.wrapLines.title")}
hideLabel
checked={mobileDiff.value.wrap}
onChange={(checked) =>
mobileDiff.update((draft) => {
draft.wrap = checked
})
}
/>
</ui.SettingsRow>
)
}

View file

@ -1,6 +1,4 @@
import { createMemo, type JSX } from "solid-js"
import { Button } from "@opencode/ui/button"
import { Switch } from "@opencode/ui/switch"
import { createMemo } from "solid-js"
import { usePlugin } from "@opencode/plugin/gui"
import { updaterAction } from "./action"
import type { UpdaterState } from "@opencode/plugin/gui/updater"
@ -8,52 +6,34 @@ import type definition from "./index"
export default function UpdatesSection(props: { state: () => UpdaterState | undefined; run: () => void }) {
const ctx = usePlugin<typeof definition>()
const ui = ctx.ui
const releaseNotes = ctx.stores.releaseNotes
const action = createMemo(() => updaterAction(props.state()))
return (
<div class="settings-section">
<h3 class="settings-section-title">{ctx.t("section.title")}</h3>
<ui.SettingsSection title={ctx.t("section.title")}>
<ui.SettingsRow
id="settings-release-notes"
title={ctx.t("releaseNotes.title")}
description={ctx.t("releaseNotes.description")}
>
<ui.Switch
label={ctx.t("releaseNotes.title")}
hideLabel
checked={releaseNotes.value.enabled}
onChange={(checked) =>
releaseNotes.update((draft) => {
draft.enabled = checked
})
}
/>
</ui.SettingsRow>
<div data-component="settings-list">
<Row title={ctx.t("releaseNotes.title")} description={ctx.t("releaseNotes.description")}>
<div data-action="settings-release-notes">
<Switch
checked={releaseNotes.value.enabled}
onChange={(checked) =>
releaseNotes.update((draft) => {
draft.enabled = checked
})
}
/>
</div>
</Row>
<Row title={ctx.t("check.title")} description={ctx.t("check.description")}>
<Button
data-action="settings-check-updates"
size="normal"
variant="neutral"
disabled={!action().run}
onClick={() => props.run()}
>
{ctx.t(action().label)}
</Button>
</Row>
</div>
</div>
)
}
// The host's settings row markup; its stylesheet is loaded with the settings screen.
function Row(props: { title: string; description: string; children: JSX.Element }) {
return (
<div data-component="settings-row">
<div data-slot="settings-row-copy">
<div data-slot="settings-row-title">{props.title}</div>
<div data-slot="settings-row-description">{props.description}</div>
</div>
<div data-slot="settings-row-control">{props.children}</div>
</div>
<ui.SettingsRow id="settings-check-updates" title={ctx.t("check.title")} description={ctx.t("check.description")}>
<ui.Button disabled={!action().run} onClick={() => props.run()}>
{ctx.t(action().label)}
</ui.Button>
</ui.SettingsRow>
</ui.SettingsSection>
)
}

View file

@ -214,8 +214,8 @@ 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.
* The app's own components: icons, buttons, tooltips, menus and menu rows, the loading indicator, the dialog frame,
* switches, text fields, selects, keybind chips, Markdown, highlighted code, and settings pages, sections and rows.
*
* @example
* ```tsx

View file

@ -1780,7 +1780,7 @@ export interface UiTooltipProps {
readonly children: JSX.Element
}
/** An action in `UiMenuProps.items`. */
/** An action in `UiMenuProps.items`, and the props of `Ui.MenuItem`. */
export interface UiMenuItem {
/** Marks the entry as an action. Optional: an entry without `type` is an action. */
readonly type?: "item"
@ -1835,6 +1835,28 @@ export interface UiMenuProps {
readonly disabled?: boolean
}
/** Props of `Ui.MenuGroup`. */
export interface UiMenuGroupProps {
/** The group's heading. Pass translated copy. */
readonly label: string
/** The group's rows, such as `Ui.MenuItem` and `Ui.MenuSub`. */
readonly children: JSX.Element
}
/** Props of `Ui.MenuSub`. */
export interface UiMenuSubProps {
/** The text of the row that opens the submenu, which also names the submenu for screen readers. */
readonly label: string
/** An icon before the text. Omit it for none. */
readonly icon?: IconName
/** The row cannot open the submenu. Defaults to false. */
readonly disabled?: boolean
/** Runs with true when the submenu opens and false when it closes, for example to load its rows only while open. */
readonly onOpenChange?: (open: boolean) => void
/** The submenu's rows, such as `Ui.MenuItem`, `Ui.MenuSeparator` and `Ui.MenuGroup`. */
readonly children: JSX.Element
}
/** Props of `Ui.Loader`. */
export interface UiLoaderProps {
/**
@ -1955,6 +1977,79 @@ export interface UiCodeProps {
readonly class?: string
}
/** One choice in `UiSelectProps.options`. */
export interface UiSelectOption<T extends string = string> {
/** The value `onChange` receives. Unique among the options. */
readonly value: T
/** The choice's text, in the list and on the closed select. Pass translated copy. */
readonly label: string
}
/** Props of `Ui.Select`. */
export interface UiSelectProps<T extends string = string> {
/** The choices, in order. */
readonly options: readonly UiSelectOption<T>[]
/** The selected choice's value. Undefined, or a value no option has, shows `placeholder`. */
readonly value: T | undefined
/**
* Runs with the value the user picks. Pass it on to `value` to keep the select in step. Picking the selected choice
* again does not call it.
*/
readonly onChange: (value: T) => void
/** The select's accessible name, which screen readers announce. Pass translated copy; it is not shown. */
readonly label: string
/** Text the select shows while no choice is selected. Omit it for none. */
readonly placeholder?: string
/** The select cannot be opened. Defaults to false. */
readonly disabled?: boolean
/** Shows the select as invalid. Defaults to false. */
readonly invalid?: boolean
/**
* Where the list opens from the select. Defaults to `bottom-end`, as the app's settings rows open theirs; it flips
* when there is no room.
*/
readonly placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end"
/** Classes of the select, for layout. The app's styles give it its look. */
readonly class?: string
}
/** Props of `Ui.SettingsLayout`. */
export interface UiSettingsLayoutProps {
/** The page's title, at the top of the page. Pass translated copy, usually the `SettingsPage.title`. */
readonly title: string
/** Text under the title. Omit it for none. */
readonly description?: string
/** Controls at the end of the title row, such as a `Ui.Button` that opens a website. Omit it for none. */
readonly actions?: JSX.Element
/** The page's sections, usually `Ui.SettingsSection`s. */
readonly children: JSX.Element
}
/** Props of `Ui.SettingsSection`. */
export interface UiSettingsSectionProps {
/** The section's heading, which also names it for screen readers. Omit it for a section without a heading. */
readonly title?: string
/** The section's rows, usually `Ui.SettingsRow`s, in the app's bordered list. */
readonly children: JSX.Element
/** Content under the list, outside its border, such as an error that concerns the rows. Omit it for none. */
readonly footer?: JSX.Element
}
/** Props of `Ui.SettingsRow`. */
export interface UiSettingsRowProps {
/** The setting's name. Pass translated copy. */
readonly title: JSX.Element
/** Text under the title that says what the setting does. Omit it for none. */
readonly description?: JSX.Element
/**
* The `SettingEntry.id` that reveals this row: when the user picks the entry in settings search, the host scrolls
* to the row and highlights it. Omit it for a row that search does not list.
*/
readonly id?: string
/** The row's control at its end, such as a `Ui.Switch`, `Ui.Select` or `Ui.Button`. Omit it for none. */
readonly children?: JSX.Element
}
/**
* 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
@ -2018,6 +2113,64 @@ export interface Ui {
* ```
*/
Menu(props: UiMenuProps): JSX.Element
/**
* One action row of a menu the host renders, such as the `session.location.menu` slot, so your rows look and
* behave like the host's: keyboard navigation, the muted icon, shortcut chips, and the menu closing on a pick. It
* also renders inside `Ui.MenuGroup` and `Ui.MenuSub`. It takes the fields of an entry of `UiMenuProps.items`.
* Outside a menu it throws, so the host records the error and the contribution renders nothing.
*
* @param props - The row's text, icon, keys and action.
*
* @example
* ```tsx
* ctx.add(Slot, {
* at: "session.location.menu",
* render: (input) => <ctx.ui.MenuItem label={ctx.t("usage")} icon="status" onSelect={() => open(input.session)} />,
* })
* ```
*/
MenuItem(props: UiMenuItem): JSX.Element
/**
* A line between rows of a menu the host renders, or of a `Ui.MenuSub`, with the menu's spacing and color.
*
* @example
* ```tsx
* <>
* <ctx.ui.MenuSeparator />
* <ctx.ui.MenuItem label={ctx.t("usage")} onSelect={open} />
* </>
* ```
*/
MenuSeparator(): JSX.Element
/**
* Rows of a menu the host renders under a heading, as `UiMenuGroup` groups the entries of `Ui.Menu`. Outside a menu
* it throws, as `Ui.MenuItem` does.
*
* @param props - The heading and its rows.
*
* @example
* ```tsx
* <ctx.ui.MenuGroup label={ctx.t("usage")}>
* <ctx.ui.MenuItem label={ctx.t("today")} onSelect={() => open("today")} />
* <ctx.ui.MenuItem label={ctx.t("month")} onSelect={() => open("month")} />
* </ctx.ui.MenuGroup>
* ```
*/
MenuGroup(props: UiMenuGroupProps): JSX.Element
/**
* A row of a menu the host renders that opens a submenu beside it, with the host's chevron, as the location menu's
* branch and folder rows do. Outside a menu it throws, as `Ui.MenuItem` does.
*
* @param props - The row's text and icon, and the submenu's rows.
*
* @example
* ```tsx
* <ctx.ui.MenuSub label={ctx.t("openIn")} icon="outline-share">
* <For each={apps()}>{(app) => <ctx.ui.MenuItem label={app.name} onSelect={() => launch(app)} />}</For>
* </ctx.ui.MenuSub>
* ```
*/
MenuSub(props: UiMenuSubProps): JSX.Element
/**
* The app's loading indicator, for anything that loads, connects or runs that is not the AI working.
*
@ -2067,6 +2220,26 @@ export interface Ui {
* ```
*/
TextInput(props: UiTextInputProps): JSX.Element
/**
* A select that opens a list of choices, as the app's settings rows pick a language or a width. The value is a
* string; `T` narrows it to your choices' values.
*
* @param props - The choices, the selected value and its handler.
*
* @example
* ```tsx
* <ctx.ui.Select
* label={ctx.t("period")}
* options={[
* { value: "day", label: ctx.t("period.day") },
* { value: "month", label: ctx.t("period.month") },
* ]}
* value={period()}
* onChange={setPeriod}
* />
* ```
*/
Select<T extends string>(props: UiSelectProps<T>): JSX.Element
/**
* Keys shown as chips, as menus and tooltips show shortcuts.
*
@ -2103,4 +2276,58 @@ export interface Ui {
* ```
*/
Code(props: UiCodeProps): JSX.Element
/**
* The frame of a settings page of your own (a `SettingsPage` without `page`): the app's page header with the title,
* then your sections, spaced as the app's pages are. Search that reveals the page itself highlights its header. The
* settings components take their look from the Settings screen's styles, so render them from a `SettingsPage`.
*
* @param props - The page's title, header actions and sections.
*
* @example
* ```tsx
* ctx.add(SettingsPage, {
* id: "acme",
* get title() {
* return ctx.t("title")
* },
* render: () => (
* <ctx.ui.SettingsLayout title={ctx.t("title")} description={ctx.t("description")}>
* <ctx.ui.SettingsSection title={ctx.t("sync")}>{rows()}</ctx.ui.SettingsSection>
* </ctx.ui.SettingsLayout>
* ),
* })
* ```
*/
SettingsLayout(props: UiSettingsLayoutProps): JSX.Element
/**
* A section of settings rows: an optional heading over the app's bordered list. Render it in `SettingsLayout`, or on
* its own as a section on a host page (`SettingsPage.page`).
*
* @param props - The heading and rows.
*
* @example
* ```tsx
* <ctx.ui.SettingsSection title={ctx.t("sync")}>
* <ctx.ui.SettingsRow id="acme-sync" title={ctx.t("sync")} description={ctx.t("sync.description")}>
* <ctx.ui.Switch label={ctx.t("sync")} hideLabel checked={sync()} onChange={setSync} />
* </ctx.ui.SettingsRow>
* </ctx.ui.SettingsSection>
* ```
*/
SettingsSection(props: UiSettingsSectionProps): JSX.Element
/**
* One setting, as the app's own settings rows show it: its title and description, and its control at the end. Render
* it in a `SettingsSection`, or on its own as rows in a host section (`SettingsPage.section`), where it lines up
* with the host's rows.
*
* @param props - The setting's text, search id and control.
*
* @example
* ```tsx
* <ctx.ui.SettingsRow id="acme-sync" title={ctx.t("sync")} description={ctx.t("sync.description")}>
* <ctx.ui.Switch label={ctx.t("sync")} hideLabel checked={sync()} onChange={setSync} />
* </ctx.ui.SettingsRow>
* ```
*/
SettingsRow(props: UiSettingsRowProps): JSX.Element
}

View file

@ -413,7 +413,8 @@ export interface SettingsPage {
/** Search metadata, indexed without mounting the page. */
readonly entries?: readonly SettingEntry[]
/**
* Renders the page, section or rows.
* Renders the page, section or rows: `Ui.SettingsLayout` frames a page, `Ui.SettingsSection` a section, and
* `Ui.SettingsRow` is one row, each as the app's own settings render them.
*
* @param input - `target` is the entry search is revealing.
*/
@ -799,7 +800,10 @@ export interface SlotMap {
*/
readonly compact: boolean
}
/** Items in the project/location menu, after its location details. Omitted on loading headers without a screen. */
/**
* Items in the project/location menu, after its location details. Omitted on loading headers without a screen.
* Render rows with `Ui.MenuItem`, `Ui.MenuSub`, `Ui.MenuGroup` and `Ui.MenuSeparator`, so they match the host's.
*/
readonly "session.location.menu": {
/** The menu's owning session screen; always present. */
readonly screen: SessionScreen

View file

@ -951,6 +951,28 @@ ctx.add(SettingsPage, {
id as `target`.
- A settings page has a `render`, so the host keeps it stable. Read its labels and entries from getters.
Render the page with the app's settings components, so it looks like the app's own pages and search can reveal its
rows:
```tsx
const ui = ctx.ui
const AcmeSettings = () => (
<ui.SettingsLayout title={ctx.t("title")} description={ctx.t("description")}>
<ui.SettingsSection title={ctx.t("settings.section")}>
<ui.SettingsRow id="sync" title={ctx.t("settings.sync")} description={ctx.t("settings.sync.description")}>
<ui.Switch label={ctx.t("settings.sync")} hideLabel checked={sync()} onChange={setSync} />
</ui.SettingsRow>
</ui.SettingsSection>
</ui.SettingsLayout>
)
```
- A page of your own renders `SettingsLayout`, a section on a host page (`page`) renders `SettingsSection`, and rows
in a host section (`section`) render `SettingsRow`s.
- A row's `id` is the `SettingEntry.id` that reveals it: search scrolls to the row and highlights it.
- See [Settings components](#settings-components) for their props.
#### Reference
```ts
@ -1003,6 +1025,16 @@ ctx.add(Slot, {
})
```
The project menu is a host menu: render its rows with `ctx.ui.MenuItem`, `MenuSub`, `MenuGroup`, and `MenuSeparator`,
so they navigate with the keyboard and close the menu like the host's rows. See [Menu rows](#menu-rows).
```tsx
ctx.add(Slot, {
at: "session.location.menu",
render: (input) => <ctx.ui.MenuItem label={ctx.t("usage")} icon="status" onSelect={() => openUsage(input.session)} />,
})
```
#### Reference
```ts
@ -2049,6 +2081,86 @@ ctx.dialogs.open((dialog) => (
sizes.
- Escape and a click outside close the dialog. `closable: false` hides the header's close button.
#### Menu rows
`MenuItem`, `MenuSub`, `MenuGroup`, and `MenuSeparator` are rows for a menu the host renders, such as the
`session.location.menu` slot. They navigate with the keyboard, show the host's muted icons and shortcut chips, and close
the menu on a pick.
```tsx
import { For } from "solid-js"
const ui = ctx.ui
ctx.add(Slot, {
at: "session.location.menu",
render: (input) => (
<>
<ui.MenuItem
label={ctx.t("usage")}
icon="status"
keys={["Ctrl", "U"]}
onSelect={() => openUsage(input.session)}
/>
<ui.MenuSub label={ctx.t("openIn")} icon="outline-share">
<For each={apps()}>{(app) => <ui.MenuItem label={app.name} onSelect={() => launch(app, input.session)} />}</For>
</ui.MenuSub>
</>
),
})
```
- `MenuItem` takes the same fields as an entry of `Menu`'s `items`.
- `MenuSub` opens its children in a submenu beside the row. `onOpenChange` tells you when, so you can load its rows
only while it is open.
- `MenuGroup` puts rows under a heading, and `MenuSeparator` draws a line between rows.
- `MenuItem`, `MenuSub`, and `MenuGroup` throw outside a menu: the host records the error and renders nothing of that
contribution.
#### Selects
`Select` picks one of a list of choices, as the app's settings rows pick a language. Its value is a string; the type of
`options` narrows it.
```tsx
const [period, setPeriod] = createSignal<"day" | "month">("day")
<ui.Select
label={ctx.t("period")}
options={[
{ value: "day", label: ctx.t("period.day") },
{ value: "month", label: ctx.t("period.month") },
]}
value={period()}
onChange={setPeriod}
/>
```
- `label` is the select's accessible name; the selected option's `label` is what it shows.
- A `value` that no option has shows `placeholder`. Picking the selected choice again does not call `onChange`.
#### Settings components
`SettingsLayout`, `SettingsSection`, and `SettingsRow` render a [settings page](#settings-pages) the way the app renders
its own: the page header, sections with headings over bordered lists, and rows with a title, a description, and a
control at the end.
```tsx
<ui.SettingsLayout title={ctx.t("title")} description={ctx.t("description")} actions={<HelpButton />}>
<ui.SettingsSection title={ctx.t("section.display")} footer={<Show when={error()}>{ctx.t("error")}</Show>}>
<ui.SettingsRow id="acme-period" title={ctx.t("period")} description={ctx.t("period.description")}>
<ui.Select label={ctx.t("period")} options={periods()} value={period()} onChange={setPeriod} />
</ui.SettingsRow>
</ui.SettingsSection>
</ui.SettingsLayout>
```
- Use `SettingsLayout` for a page of your own, `SettingsSection` for a section on a host page, and `SettingsRow` alone
for rows in a host section. Rows line up with the host's rows in every case.
- `id` on a row is the `SettingEntry.id` that reveals it. Search that reveals the page's own id highlights the header.
- `actions` sits at the end of the title row. A section's `footer` sits under its list, outside the border.
- They take their look from the Settings screen's styles, so render them from a `SettingsPage`.
#### Reference
```ts
@ -2058,13 +2170,21 @@ interface Ui {
IconButton(props: UiIconButtonProps): JSX.Element
Tooltip(props: UiTooltipProps): JSX.Element
Menu(props: UiMenuProps): JSX.Element
MenuItem(props: UiMenuItem): JSX.Element
MenuSeparator(): JSX.Element
MenuGroup(props: UiMenuGroupProps): JSX.Element
MenuSub(props: UiMenuSubProps): JSX.Element
Loader(props: UiLoaderProps): JSX.Element
Dialog(props: UiDialogProps): JSX.Element
Switch(props: UiSwitchProps): JSX.Element
TextInput(props: UiTextInputProps): JSX.Element
Select<T extends string>(props: UiSelectProps<T>): JSX.Element
Keybind(props: UiKeybindProps): JSX.Element
Markdown(props: UiMarkdownProps): JSX.Element
Code(props: UiCodeProps): JSX.Element
SettingsLayout(props: UiSettingsLayoutProps): JSX.Element
SettingsSection(props: UiSettingsSectionProps): JSX.Element
SettingsRow(props: UiSettingsRowProps): JSX.Element
}
interface UiIconProps {
@ -2135,6 +2255,19 @@ interface UiMenuGroup {
items: UiMenuItem[]
}
interface UiMenuGroupProps {
label: string
children: JSX.Element
}
interface UiMenuSubProps {
label: string
icon?: IconName
disabled?: boolean
onOpenChange?: (open: boolean) => void
children: JSX.Element
}
interface UiLoaderProps {
size?: "small" | "normal" | "large"
class?: string
@ -2187,6 +2320,43 @@ interface UiCodeProps {
language?: string
class?: string
}
interface UiSelectProps<T extends string> {
options: UiSelectOption<T>[]
value: T | undefined
onChange: (value: T) => void
label: string
placeholder?: string
disabled?: boolean
invalid?: boolean
placement?: "bottom-start" | "bottom-end" | "top-start" | "top-end"
class?: string
}
interface UiSelectOption<T extends string> {
value: T
label: string
}
interface UiSettingsLayoutProps {
title: string
description?: string
actions?: JSX.Element
children: JSX.Element
}
interface UiSettingsSectionProps {
title?: string
children: JSX.Element
footer?: JSX.Element
}
interface UiSettingsRowProps {
title: JSX.Element
description?: JSX.Element
id?: string
children?: JSX.Element
}
```
### Toasts