diff --git a/packages/app/component-tests/plugin-host.fixture.tsx b/packages/app/component-tests/plugin-host.fixture.tsx
index 51a1e6d20c7f..89eae7e07956 100644
--- a/packages/app/component-tests/plugin-host.fixture.tsx
+++ b/packages/app/component-tests/plugin-host.fixture.tsx
@@ -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,
}}
/>
+
+ {/* Without text, so the cases that read the region's text see only the plugins'. */}
+
+
+
+
+
+
+
{
+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(
diff --git a/packages/app/src/runtime/plugin/ui.tsx b/packages/app/src/runtime/plugin/ui.tsx
index 30ff092791be..3c4790ab84d6 100644
--- a/packages/app/src/runtime/plugin/ui.tsx
+++ b/packages/app/src/runtime/plugin/ui.tsx
@@ -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 (
{entry.label}
- {(item) => }
+ {(item) => }
)
- return
+ return
}}
),
+ MenuItem,
+ MenuSeparator: () => ,
+ MenuGroup: (props) => (
+
+ {props.label}
+ {props.children}
+
+ ),
+ MenuSub: (props) => (
+ props.onOpenChange?.(open)}>
+
+
+
+ {props.children}
+
+
+
+ ),
Loader: (props) => (
),
+ Select: (props) => (
+ 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) => (
0}>
@@ -179,17 +217,60 @@ export const ui: Ui = {
Markdown: (props) => ,
// The timeline's code block: a fenced block in Markdown, highlighted by the same worker.
Code: (props) => ,
+ // 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) => (
+ <>
+
+ {props.children}
+ >
+ ),
+ SettingsSection: (props) => (
+
+
+ {props.title}
+
+ {props.children}
+ {props.footer}
+
+ ),
+ SettingsRow: (props) => {
+ const description = children(() => props.description)
+
+ // Settings search reveals the row whose `data-action` is the entry it picked.
+ return (
+
+
+
{props.title}
+
+ {description()}
+
+
+
{props.children}
+
+ )
+ },
}
-function MenuRow(props: { item: UiMenuItem }) {
+function MenuItem(props: UiMenuItem) {
return (
props.item.onSelect()}
- shortcut={props.item.keys?.length ? : undefined}
+ disabled={props.disabled}
+ onSelect={() => props.onSelect()}
+ shortcut={props.keys?.length ? : undefined}
>
- {(icon) => }
- {props.item.label}
+ {(icon) => }
+ {props.label}
)
}
diff --git a/packages/gui-plugins/README.md b/packages/gui-plugins/README.md
index 6826252fdc51..e9501bf8c56e 100644
--- a/packages/gui-plugins/README.md
+++ b/packages/gui-plugins/README.md
@@ -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 |
diff --git a/packages/gui-plugins/src/pairing/page.tsx b/packages/gui-plugins/src/pairing/page.tsx
index db9980dc251d..b6c90c846857 100644
--- a/packages/gui-plugins/src/pairing/page.tsx
+++ b/packages/gui-plugins/src/pairing/page.tsx
@@ -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>
}) {
const ctx = usePlugin()
+ 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 (
- <>
-
-
-
-
-
-
-
- {ctx.t("pair.open")}
-
-
-
-
-
- change.mutate({ enabled })}
- >
- {ctx.t("remote.title")}
-
-
-
-
-
- screenActivity.mutate(enabled)}
- >
- {ctx.t("screenActive.title")}
-
-
-
+
+
+
+
+ {ctx.t("remote.error")}
+
-
-
-
- {ctx.t("remote.error")}
-
-
-
-
- {ctx.t("screenActive.error")}
-
-
- {/* Pending main is still starting; anything else that leaves it inactive keeps pairing from turning on. */}
-
-
- {ctx.t("pair.unavailable")}
-
-
-
-
- >
+
+
+ {ctx.t("screenActive.error")}
+
+
+ {/* Pending main is still starting; anything else that leaves it inactive keeps pairing from turning on. */}
+
+
+ {ctx.t("pair.unavailable")}
+
+
+ >
+ }
+ >
+
+
+ {ctx.t("pair.open")}
+
+
+
+
+ change.mutate({ enabled })}
+ />
+
+
+ screenActivity.mutate(enabled)}
+ />
+
+
+
+
)
}
@@ -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 (
-
-
-
{props.title}
-
{props.description}
-
-
{props.children}
-
- )
-}
diff --git a/packages/gui-plugins/src/review/settings.tsx b/packages/gui-plugins/src/review/settings.tsx
index b7031845a73e..fe78efb78ad9 100644
--- a/packages/gui-plugins/src/review/settings.tsx
+++ b/packages/gui-plugins/src/review/settings.tsx
@@ -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()
+ const ui = ctx.ui
const mobileDiff = ctx.stores.mobileDiff
return (
-
-
-
{ctx.t("settings.wrapLines.title")}
-
{ctx.t("settings.wrapLines.description")}
-
-
-
-
- mobileDiff.update((draft) => {
- draft.wrap = checked
- })
- }
- hideLabel
- >
- {ctx.t("settings.wrapLines.title")}
-
-
-
-
+
+
+ mobileDiff.update((draft) => {
+ draft.wrap = checked
+ })
+ }
+ />
+
)
}
diff --git a/packages/gui-plugins/src/updater/section.tsx b/packages/gui-plugins/src/updater/section.tsx
index a64cba65d5c4..a09482a8703b 100644
--- a/packages/gui-plugins/src/updater/section.tsx
+++ b/packages/gui-plugins/src/updater/section.tsx
@@ -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()
+ const ui = ctx.ui
const releaseNotes = ctx.stores.releaseNotes
const action = createMemo(() => updaterAction(props.state()))
return (
-
-
{ctx.t("section.title")}
+
+
+
+ releaseNotes.update((draft) => {
+ draft.enabled = checked
+ })
+ }
+ />
+
-
-
-
-
- releaseNotes.update((draft) => {
- draft.enabled = checked
- })
- }
- />
-
-
-
-
- props.run()}
- >
- {ctx.t(action().label)}
-
-
-
-
- )
-}
-
-// 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 (
-
-
-
{props.title}
-
{props.description}
-
-
{props.children}
-
+
+ props.run()}>
+ {ctx.t(action().label)}
+
+
+
)
}
diff --git a/packages/plugin/src/gui/context.ts b/packages/plugin/src/gui/context.ts
index 651a0aff674d..921f64623d20 100644
--- a/packages/plugin/src/gui/context.ts
+++ b/packages/plugin/src/gui/context.ts
@@ -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
diff --git a/packages/plugin/src/gui/host-apis.ts b/packages/plugin/src/gui/host-apis.ts
index 5eb6dd81e86c..453c3d2d1659 100644
--- a/packages/plugin/src/gui/host-apis.ts
+++ b/packages/plugin/src/gui/host-apis.ts
@@ -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 {
+ /** 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 {
+ /** The choices, in order. */
+ readonly options: readonly UiSelectOption[]
+ /** 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) => 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
+ * <>
+ *
+ *
+ * >
+ * ```
+ */
+ 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
+ *
+ * open("today")} />
+ * open("month")} />
+ *
+ * ```
+ */
+ 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
+ *
+ * {(app) => launch(app)} />}
+ *
+ * ```
+ */
+ 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
+ *
+ * ```
+ */
+ Select(props: UiSelectProps): 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: () => (
+ *
+ * {rows()}
+ *
+ * ),
+ * })
+ * ```
+ */
+ 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
+ *
+ *
+ *
+ *
+ *
+ * ```
+ */
+ 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
+ *
+ *
+ *
+ * ```
+ */
+ SettingsRow(props: UiSettingsRowProps): JSX.Element
}
diff --git a/packages/plugin/src/gui/registries.ts b/packages/plugin/src/gui/registries.ts
index 3e1267bda7e8..6d173aaeb908 100644
--- a/packages/plugin/src/gui/registries.ts
+++ b/packages/plugin/src/gui/registries.ts
@@ -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
diff --git a/services/www/src/docs/content/build/plugins/gui/index.mdx b/services/www/src/docs/content/build/plugins/gui/index.mdx
index 2b40b1063c52..34f2efb128ed 100644
--- a/services/www/src/docs/content/build/plugins/gui/index.mdx
+++ b/services/www/src/docs/content/build/plugins/gui/index.mdx
@@ -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 = () => (
+
+
+
+
+
+
+
+)
+```
+
+- 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) => 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) => (
+ <>
+ openUsage(input.session)}
+ />
+
+ {(app) => launch(app, input.session)} />}
+
+ >
+ ),
+})
+```
+
+- `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")
+
+
+```
+
+- `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
+ }>
+ {ctx.t("error")} }>
+
+
+
+
+
+```
+
+- 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(props: UiSelectProps): 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 {
+ options: UiSelectOption[]
+ 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 {
+ 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