feat(tui): hierarchical slot tree for plugin UI placement (#41189)
This commit is contained in:
@@ -150,24 +150,89 @@ export interface Page {
|
||||
readonly render: (input: { readonly data?: Record<string, any> }) => JSX.Element
|
||||
}
|
||||
|
||||
type PromptFooterInput = { readonly sessionID?: string; readonly mode: "normal" | "shell" }
|
||||
|
||||
/**
|
||||
* The host UI's slot tree. Every path is one slot: a named boundary a plugin
|
||||
* may render around, inside, or take over. Paths are absolute and
|
||||
* dot-separated, and a path contains every path it prefixes — replacing
|
||||
* `prompt.footer` owns everything under `prompt.footer.*`.
|
||||
*
|
||||
* Each slot publishes an input: reactive props passed to every claim render
|
||||
* targeting it. Inputs carry only what the SDK cannot answer — instance
|
||||
* identity and client-local state. Paths and their inputs are documented
|
||||
* API: coarse, few, and kept stable across host refactors.
|
||||
*/
|
||||
export interface SlotMap {
|
||||
readonly app: Readonly<Record<string, never>>
|
||||
readonly "home.footer": Readonly<Record<string, never>>
|
||||
readonly "prompt.footer.end": {
|
||||
readonly sessionID?: string
|
||||
readonly mode: "normal" | "shell"
|
||||
}
|
||||
readonly "session.composer.top": {
|
||||
readonly sessionID: string
|
||||
}
|
||||
readonly "sidebar.content": {
|
||||
readonly sessionID: string
|
||||
}
|
||||
readonly "prompt.footer": PromptFooterInput
|
||||
readonly "prompt.footer.status": PromptFooterInput
|
||||
readonly "prompt.footer.file": PromptFooterInput
|
||||
readonly "session.composer.top": { readonly sessionID: string }
|
||||
readonly "sidebar.content": { readonly sessionID: string }
|
||||
readonly "sidebar.footer": Readonly<Record<string, never>>
|
||||
}
|
||||
export type SlotPath = keyof SlotMap
|
||||
|
||||
export type SlotName = keyof SlotMap
|
||||
export type Slot<Name extends SlotName = SlotName> = (props: SlotMap[Name]) => JSX.Element
|
||||
/**
|
||||
* One contribution to the slot tree. Exactly one placement key names an
|
||||
* absolute target path:
|
||||
* - `prepend` / `append`: first/last inside the target's boundary
|
||||
* - `before` / `after`: siblings adjacent to the target, outside its boundary
|
||||
* - `replace`: take over the target. The boundary itself survives — siblings
|
||||
* anchored `before`/`after` it still compose — but the original content and
|
||||
* every claim inside the boundary are suppressed and recorded, never
|
||||
* silently dropped. At the same target the last-enabled claim wins; an
|
||||
* ancestor replacement beats a descendant one regardless of enable order.
|
||||
*
|
||||
* A claim aimed at a path the host no longer publishes degrades: additive
|
||||
* claims append to the nearest surviving ancestor, replacements are
|
||||
* suppressed. Several claims at one anchor coexist in plugin enable order.
|
||||
*
|
||||
* `render` receives the target slot's input, reactively. The `?: never`
|
||||
* fields make the variants mutually exclusive: a claim with two placement
|
||||
* keys is a type error, not a silent priority pick.
|
||||
*/
|
||||
export type SlotClaim<Path extends SlotPath = SlotPath> = Path extends SlotPath
|
||||
? { readonly render: (input: SlotMap[Path]) => JSX.Element } & (
|
||||
| {
|
||||
readonly prepend: Path
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly append: Path
|
||||
readonly prepend?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly before: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly after?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly after: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly replace?: never
|
||||
}
|
||||
| {
|
||||
readonly replace: Path
|
||||
readonly prepend?: never
|
||||
readonly append?: never
|
||||
readonly before?: never
|
||||
readonly after?: never
|
||||
}
|
||||
)
|
||||
: never
|
||||
|
||||
export interface App {
|
||||
readonly version: string
|
||||
@@ -394,7 +459,8 @@ export interface UI {
|
||||
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
|
||||
close(sessionID?: string): boolean
|
||||
}
|
||||
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
|
||||
/** Claims a place in the slot tree; see SlotClaim. */
|
||||
readonly slot: (claim: SlotClaim) => () => void
|
||||
}
|
||||
|
||||
export interface Context {
|
||||
|
||||
@@ -87,7 +87,7 @@ import { PromptRefProvider, usePromptRef } from "./context/prompt"
|
||||
import { Config, ConfigProvider, useConfig } from "./config"
|
||||
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
|
||||
import { tuiPluginDirectories } from "./plugin/discovery"
|
||||
import { PluginRoute, PluginSlot } from "./plugin/render"
|
||||
import { PluginRoute, Slot } from "./plugin/render"
|
||||
import { CommandPaletteDialog } from "./component/command-palette"
|
||||
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
|
||||
|
||||
@@ -1240,7 +1240,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<PluginSlot name="app" input={{}} mode="all" />
|
||||
<Slot path="app" />
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
|
||||
@@ -58,7 +58,7 @@ import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import type { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import {
|
||||
deduplicatePromptImages,
|
||||
@@ -1469,6 +1469,7 @@ export function Prompt(props: PromptProps) {
|
||||
animationsEnabled,
|
||||
)
|
||||
const borderHighlight = createMemo(() => tint(theme.border.default, highlight(), agentMetaAlpha()))
|
||||
const footerInput = () => ({ sessionID: props.sessionID, mode: store.mode })
|
||||
|
||||
const placeholderText = createMemo(() => {
|
||||
if (props.showPlaceholder === false) return undefined
|
||||
@@ -1778,77 +1779,78 @@ export function Prompt(props: PromptProps) {
|
||||
/>
|
||||
</box>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-between" gap={2}>
|
||||
<box flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<Switch>
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
<Slot path="prompt.footer" input={footerInput()}>
|
||||
<Slot path="prompt.footer.status" input={footerInput()}>
|
||||
<box flexGrow={1} flexShrink={1} minWidth={0}>
|
||||
<Switch>
|
||||
<Match when={status() === "running"}>
|
||||
<box flexDirection="row" gap={1} flexGrow={1} justifyContent="flex-start">
|
||||
<box marginLeft={1}>
|
||||
<Show when={config.animations ?? true} fallback={<text fg={theme.text.subdued}>[⋯]</text>}>
|
||||
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
|
||||
</Show>
|
||||
</box>
|
||||
<text
|
||||
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
>
|
||||
esc{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<Spinner color={theme.hue.accent[500]}>
|
||||
{progress()}
|
||||
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||
</Spinner>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.pendingNew()}>
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
|
||||
(new working copy)
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
</Slot>
|
||||
<Slot path="prompt.footer.file" input={footerInput()}>
|
||||
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
|
||||
{(file) => (
|
||||
<text
|
||||
fg={store.interrupt > 0 ? theme.background.action.primary.default : theme.text.default}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
|
||||
>
|
||||
esc{" "}
|
||||
<span
|
||||
style={{
|
||||
fg: store.interrupt > 0 ? theme.background.action.primary.default : theme.text.subdued,
|
||||
}}
|
||||
>
|
||||
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
|
||||
</span>
|
||||
{file()}
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={move.progress()}>
|
||||
{(progress) => (
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<Spinner color={theme.hue.accent[500]}>
|
||||
{progress()}
|
||||
<span style={{ fg: theme.text.subdued }}>{".".repeat(move.creatingDots())}</span>
|
||||
</Spinner>
|
||||
</box>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={move.pendingNew()}>
|
||||
<box paddingLeft={3} height={1} minHeight={0} flexShrink={1}>
|
||||
<text fg={theme.hue.accent[500]} wrapMode="none" truncate>
|
||||
(new working copy)
|
||||
</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<Show when={!props.hint && locationLabel()} fallback={props.hint ?? <text />}>
|
||||
{(location) => (
|
||||
<text fg={theme.text.subdued} wrapMode="none" truncate flexGrow={1} flexShrink={1}>
|
||||
{location()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={editorContextLabelState() !== "none" ? editorFileLabelDisplay() : undefined}>
|
||||
{(file) => (
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
flexShrink={1}
|
||||
fg={editorContextLabelState() === "pending" ? theme.hue.accent[500] : theme.text.subdued}
|
||||
>
|
||||
{file()}
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<PluginSlot
|
||||
name="prompt.footer.end"
|
||||
input={{ sessionID: props.sessionID, mode: store.mode }}
|
||||
mode="replace"
|
||||
/>
|
||||
</Show>
|
||||
</Slot>
|
||||
</Slot>
|
||||
</box>
|
||||
</box>
|
||||
<Autocomplete
|
||||
|
||||
@@ -62,6 +62,10 @@ function View(props: { context: Plugin.Context }) {
|
||||
export default Plugin.define({
|
||||
id: "opencode.home-footer",
|
||||
setup(context) {
|
||||
context.ui.slot("home.footer", () => <View context={context} />)
|
||||
// Root takeover: an external plugin replacing home.footer wins (last-
|
||||
// enabled) and this builtin shows as suppressed, not silently gone.
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
context.ui.slot({ append: "home.footer", render: () => <View context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,8 +85,9 @@ export function PromptFooter(props: { context: Plugin.Context; sessionID?: strin
|
||||
export default Plugin.define({
|
||||
id: "opencode.prompt-footer",
|
||||
setup(context) {
|
||||
context.ui.slot("prompt.footer.end", (props) => (
|
||||
<PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />
|
||||
))
|
||||
context.ui.slot({
|
||||
append: "prompt.footer",
|
||||
render: (props) => <PromptFooter context={context} sessionID={props.sessionID} mode={props.mode} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -44,6 +44,9 @@ export function SidebarContext(props: { context: Plugin.Context; sessionID: stri
|
||||
export default Plugin.define({
|
||||
id: "internal:sidebar-context",
|
||||
setup(context) {
|
||||
context.ui.slot("sidebar.content", (props) => <SidebarContext context={context} sessionID={props.sessionID} />)
|
||||
context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
render: (props) => <SidebarContext context={context} sessionID={props.sessionID} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -19,6 +19,8 @@ function View(props: { context: Plugin.Context }) {
|
||||
export default Plugin.define({
|
||||
id: "opencode.sidebar-footer",
|
||||
setup(context) {
|
||||
context.ui.slot("sidebar.footer", () => <View context={context} />)
|
||||
// Append keeps the path open to additive plugin claims; an external
|
||||
// replace still takes the boundary over.
|
||||
context.ui.slot({ append: "sidebar.footer", render: () => <View context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -73,6 +73,9 @@ function View(props: { context: Plugin.Context; sessionID: string }) {
|
||||
export default Plugin.define({
|
||||
id: "internal:sidebar-mcp",
|
||||
setup(context) {
|
||||
context.ui.slot("sidebar.content", (props) => <View context={context} sessionID={props.sessionID} />)
|
||||
context.ui.slot({
|
||||
append: "sidebar.content",
|
||||
render: (props) => <View context={context} sessionID={props.sessionID} />,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1090,6 +1090,6 @@ export default Plugin.define({
|
||||
name: ROUTE,
|
||||
render: () => <DiffViewer context={context} />,
|
||||
})
|
||||
context.ui.slot("app", () => <Commands context={context} />)
|
||||
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -85,6 +85,6 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
export default Plugin.define({
|
||||
id,
|
||||
setup(context) {
|
||||
context.ui.slot("app", () => <Commands context={context} />)
|
||||
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -137,6 +137,6 @@ export default Plugin.define({
|
||||
return <StorybookIndex context={context} />
|
||||
},
|
||||
})
|
||||
context.ui.slot("app", () => <Commands context={context} />)
|
||||
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PluginContextProvider } from "@opencode-ai/plugin/tui"
|
||||
import type { JSX } from "solid-js"
|
||||
import type { Context, Dialog, Page, Slot, SlotMap, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import type { Context, Dialog, Page, SlotClaim, SlotMap, SlotPath, Toast } from "@opencode-ai/plugin/tui/context"
|
||||
import type { Placement, PlacementKind } from "./structure"
|
||||
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { useClient } from "../context/client"
|
||||
@@ -23,13 +24,25 @@ import { abbreviateHome } from "../util/path-format"
|
||||
|
||||
export type Dispose = () => Promise<void>
|
||||
|
||||
// Slot inputs erased to their union: the registry stores one render shape
|
||||
// regardless of which path a claim targets.
|
||||
export type SlotRender = (input: SlotMap[SlotPath]) => JSX.Element
|
||||
|
||||
// A registered claim as stored by the plugin provider's registry.
|
||||
export type RegisteredSlot = {
|
||||
readonly placement: Placement
|
||||
readonly render: SlotRender
|
||||
}
|
||||
|
||||
const placements = ["prepend", "append", "before", "after", "replace"] as const satisfies readonly PlacementKind[]
|
||||
|
||||
// The provider's registration store, narrowed to what a plugin context needs:
|
||||
// route/slot registration lands there, but ordering and lifecycle stay owned
|
||||
// by the provider.
|
||||
export type Registry = {
|
||||
has(kind: "routes" | "slots" | "markdown", name: string): boolean
|
||||
set(kind: "routes", name: string, page: Page): void
|
||||
set(kind: "slots", name: string, slot: Slot): void
|
||||
set(kind: "slots", name: string, claim: RegisteredSlot): void
|
||||
set(kind: "markdown", name: string, render: MarkdownCodeBlockRenderer): void
|
||||
remove(kind: "routes" | "slots" | "markdown", name: string): void
|
||||
active(): boolean
|
||||
@@ -70,6 +83,7 @@ export function createPluginContext(input: {
|
||||
}): Context {
|
||||
const host = input.host
|
||||
let context: Context
|
||||
let claims = 0
|
||||
// Every dialog and registered render is wrapped so plugin components can
|
||||
// reach their own context through usePlugin().
|
||||
const provide = (render: () => JSX.Element) => (
|
||||
@@ -184,12 +198,20 @@ export function createPluginContext(input: {
|
||||
return true
|
||||
},
|
||||
},
|
||||
slot(name, render) {
|
||||
if (input.registry.has("slots", name)) throw new Error(`Slot already registered: ${name}`)
|
||||
// The registration map erases the slot-specific input type.
|
||||
input.registry.set("slots", name, ((slotInput: SlotMap[typeof name]) =>
|
||||
provide(() => render(slotInput))) as Slot)
|
||||
return registration("slots", name)
|
||||
slot(value: SlotClaim) {
|
||||
// Keys are counter-suffixed so one plugin may claim several places;
|
||||
// order within the plugin is registration order.
|
||||
const key = `slot#${claims++}`
|
||||
// Exactly one placement kind, enforced at runtime for untyped plugins.
|
||||
const kinds = placements.filter((item) => value[item] !== undefined)
|
||||
if (kinds.length !== 1) throw new Error("Slot claim requires exactly one placement key")
|
||||
const kind = kinds[0]
|
||||
input.registry.set("slots", key, {
|
||||
placement: { kind, target: value[kind] as string },
|
||||
// The registration map erases the path-specific input type.
|
||||
render: (slotInput) => provide(() => (value.render as SlotRender)(slotInput)),
|
||||
})
|
||||
return registration("slots", key)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -14,15 +14,16 @@ import {
|
||||
import path from "path"
|
||||
import { stat } from "fs/promises"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import type { Page, Slot, SlotName } from "@opencode-ai/plugin/tui/context"
|
||||
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
|
||||
import type { Page } from "@opencode-ai/plugin/tui/context"
|
||||
import { resolveSlots, type Claim } from "./structure"
|
||||
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
|
||||
import { isDeepEqual } from "remeda"
|
||||
import "#runtime-plugin-support"
|
||||
import { useConfig } from "../config"
|
||||
import { useTuiLifecycle } from "../context/runtime"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose } from "./api"
|
||||
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
|
||||
import { createSourceWatcher } from "./watch"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
|
||||
|
||||
@@ -46,9 +47,11 @@ type Value = {
|
||||
readonly list: () => ReadonlyArray<State>
|
||||
readonly registered: () => ReadonlyArray<RegisteredPlugin>
|
||||
readonly route: (id: string, name: string) => Page["render"] | undefined
|
||||
readonly slot: <Name extends SlotName>(
|
||||
name: Name,
|
||||
) => ReadonlyArray<{ readonly id: string; readonly render: Slot<Name> }>
|
||||
readonly slots: {
|
||||
// A mounted <Slot> instance registers its path; the disposer unregisters.
|
||||
readonly register: (path: string) => () => void
|
||||
readonly resolved: () => ReturnType<typeof resolveSlots<SlotRender>>
|
||||
}
|
||||
readonly markdown: () => MarkdownOptions["renderNode"]
|
||||
readonly activate: (id: string) => Promise<boolean>
|
||||
readonly deactivate: (id: string) => Promise<boolean>
|
||||
@@ -62,7 +65,7 @@ type Registration = {
|
||||
options?: Readonly<Record<string, any>>
|
||||
active: boolean
|
||||
routes: Record<string, Page>
|
||||
slots: Record<string, Slot>
|
||||
slots: Record<string, RegisteredSlot>
|
||||
markdown: Record<string, MarkdownCodeBlockRenderer>
|
||||
cleanups: Dispose[]
|
||||
}
|
||||
@@ -119,8 +122,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
owned,
|
||||
registry: {
|
||||
has: (kind, name) => Boolean(store.registrations[id]?.[kind][name]),
|
||||
set: (kind: "routes" | "slots" | "markdown", name: string, value: Page | Slot | MarkdownCodeBlockRenderer) =>
|
||||
setStore("registrations", id, kind, name, () => value),
|
||||
set: (
|
||||
kind: "routes" | "slots" | "markdown",
|
||||
name: string,
|
||||
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer,
|
||||
) => setStore("registrations", id, kind, name, () => value),
|
||||
remove: (kind, name) =>
|
||||
setStore(
|
||||
"registrations",
|
||||
@@ -387,7 +393,44 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` })
|
||||
setStore("states", reconcileStore(states))
|
||||
}
|
||||
const slotItems = new WeakMap<Slot, { readonly id: string; readonly render: Slot }>()
|
||||
const slotItems = new WeakMap<SlotRender, Claim<SlotRender>>()
|
||||
// The mounted slot tree: path -> live <Slot> instance count. Reference
|
||||
// counted because the same path can be mounted several times (one composer
|
||||
// footer per session tab); a path exists while any instance is mounted.
|
||||
const [mounted, setMounted] = createStore<Record<string, number>>({})
|
||||
const registerSlot = (slotPath: string) => {
|
||||
setMounted(slotPath, (count) => (count ?? 0) + 1)
|
||||
return () =>
|
||||
setMounted(
|
||||
produce((counts) => {
|
||||
const count = counts[slotPath]
|
||||
if (count && count > 1) counts[slotPath] = count - 1
|
||||
else delete counts[slotPath]
|
||||
}),
|
||||
)
|
||||
}
|
||||
// Claims come back in enable order: registration-store key order across
|
||||
// plugins (generations preserve key positions in place), then registration
|
||||
// order within one plugin. The resolver's last-wins rules depend on it.
|
||||
const claims = createMemo(() =>
|
||||
Object.entries(store.registrations).flatMap(([id, registration]) =>
|
||||
Object.entries(registration.active ? registration.slots : {}).map(([key, slot]) => {
|
||||
// Rows downstream diff by reference; a stable claim per render
|
||||
// function keeps untouched plugins' slot rows (and their state)
|
||||
// alive across other plugins' reloads.
|
||||
const cached = slotItems.get(slot.render)
|
||||
if (cached) return cached
|
||||
// Placements are immutable once registered; unwrap the store proxy
|
||||
// so resolver reads don't subscribe tracked scopes.
|
||||
const item = { key: `${id}/${key}`, plugin: id, placement: unwrap(slot.placement), render: slot.render }
|
||||
slotItems.set(slot.render, item)
|
||||
return item
|
||||
}),
|
||||
),
|
||||
)
|
||||
// Object.keys tracks the store's keys node only: refcount changes on an
|
||||
// already-mounted path (a second tab's composer) skip re-resolution.
|
||||
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify(config.data.plugins ?? []),
|
||||
@@ -436,19 +479,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
active: plugin.active,
|
||||
})),
|
||||
route: (id, name) => store.registrations[id]?.routes[name]?.render,
|
||||
slot: (name) =>
|
||||
Object.entries(store.registrations).flatMap(([id, registration]) => {
|
||||
const render = registration.active ? registration.slots[name] : undefined
|
||||
if (!render) return []
|
||||
// <For> diffs rows by reference; a stable wrapper per render
|
||||
// function keeps untouched plugins' slot rows (and their state)
|
||||
// alive across other plugins' reloads.
|
||||
const cached = slotItems.get(render)
|
||||
if (cached) return [cached]
|
||||
const item = { id, render }
|
||||
slotItems.set(render, item)
|
||||
return [item]
|
||||
}),
|
||||
slots: { register: registerSlot, resolved },
|
||||
markdown,
|
||||
// Manual dialog toggles join the same chain as reconciles so a
|
||||
// toggle mid-reload cannot mix registrations across generations.
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import {
|
||||
createComponent,
|
||||
createContext,
|
||||
createMemo,
|
||||
ErrorBoundary,
|
||||
For,
|
||||
mergeProps,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
useContext,
|
||||
type JSX,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import type { SlotMap, SlotName } from "@opencode-ai/plugin/tui/context"
|
||||
import { isShallowEqual } from "remeda"
|
||||
import type { SlotMap, SlotPath } from "@opencode-ai/plugin/tui/context"
|
||||
import type { SlotRender } from "./api"
|
||||
import { contains, emptySlotted, type Claim } from "./structure"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
@@ -64,31 +70,75 @@ export function PluginRoute(props: { readonly fallback: (id: string, name: strin
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginSlot<Name extends SlotName>(props: {
|
||||
readonly name: Name
|
||||
readonly input: SlotMap[Name]
|
||||
readonly mode: "all" | "replace"
|
||||
}) {
|
||||
// The nearest enclosing slot's path. Root slots mount outside any provider.
|
||||
const SlotParent = createContext<string>()
|
||||
|
||||
// `input` is required exactly when the path publishes a non-empty input.
|
||||
type SlotProps<Path extends SlotPath> = ParentProps<{ readonly path: Path }> &
|
||||
({} extends SlotMap[Path] ? { readonly input?: SlotMap[Path] } : { readonly input: SlotMap[Path] })
|
||||
|
||||
// One named boundary of the host UI's slot tree. The host's own content are
|
||||
// the children; every active plugin claim targeting this path resolves into
|
||||
// siblings around it, contributions inside it, or one takeover of it.
|
||||
// Placement policy lives in resolveSlots; this component only renders its
|
||||
// own path's buckets.
|
||||
export function Slot<Path extends SlotPath>(props: SlotProps<Path>) {
|
||||
const plugins = usePlugin()
|
||||
const renderers = createMemo(() => {
|
||||
const items = plugins.slot(props.name)
|
||||
if (props.mode === "replace") return items.slice(-1)
|
||||
return items
|
||||
})
|
||||
// A slot's path is its identity for the whole mount; instances are
|
||||
// reference-counted so the same path may be mounted several times (one
|
||||
// composer footer per session tab).
|
||||
const path = props.path
|
||||
// Paths are declared, not inferred: nesting under the wrong parent would
|
||||
// silently publish a mislocated public path, so containment fails loudly
|
||||
// at mount. Only host code can trip this — plugins cannot mount slots.
|
||||
const parent = useContext(SlotParent)
|
||||
if (parent !== undefined && !contains(parent, path)) {
|
||||
throw new Error(`Slot "${path}" is mounted inside "${parent}" but its path does not extend it`)
|
||||
}
|
||||
onCleanup(plugins.slots.register(path))
|
||||
const input = () => (props as { readonly input?: SlotMap[Path] }).input ?? ({} as SlotMap[Path])
|
||||
const slotted = createMemo(
|
||||
() => plugins.slots.resolved().slotted.get(path) ?? emptySlotted<SlotRender>(),
|
||||
emptySlotted<SlotRender>(),
|
||||
// Claim objects are reference-stable across resolutions, so a bucketwise
|
||||
// comparison makes a claim change elsewhere in the tree a no-op here.
|
||||
{
|
||||
equals: (a, b) =>
|
||||
isShallowEqual(a.before, b.before) &&
|
||||
isShallowEqual(a.prepend, b.prepend) &&
|
||||
isShallowEqual(a.append, b.append) &&
|
||||
isShallowEqual(a.after, b.after) &&
|
||||
a.replace === b.replace,
|
||||
},
|
||||
)
|
||||
// Component semantics: the render body runs once and untracked, so state
|
||||
// created inside is stable while the slot input stays reactive through the
|
||||
// merged getter.
|
||||
const contribution = (claim: Claim<SlotRender>) => (
|
||||
<PluginBoundary id={claim.plugin} where={`slot ${path}`}>
|
||||
{createComponent(claim.render, mergeProps(input))}
|
||||
</PluginBoundary>
|
||||
)
|
||||
return (
|
||||
<For each={renderers()}>
|
||||
{(item) => (
|
||||
<PluginBoundary id={item.id} where={`slot ${props.name}`}>
|
||||
{
|
||||
// Component semantics: the render body runs once and untracked, so
|
||||
// signals and intervals created inside are stable, while props stay
|
||||
// reactive through the merged getter. A bare item.render(props.input)
|
||||
// call would run inside the host's tracked scope and re-execute the
|
||||
// whole body (resetting plugin state) on every tracked read.
|
||||
createComponent(item.render, mergeProps(() => props.input) as SlotMap[Name])
|
||||
<>
|
||||
<For each={slotted().before}>{contribution}</For>
|
||||
{/* before/after are siblings outside the boundary, so outside the provider. */}
|
||||
<SlotParent.Provider value={path}>
|
||||
<Show
|
||||
keyed
|
||||
when={slotted().replace}
|
||||
fallback={
|
||||
<>
|
||||
<For each={slotted().prepend}>{contribution}</For>
|
||||
{props.children}
|
||||
<For each={slotted().append}>{contribution}</For>
|
||||
</>
|
||||
}
|
||||
</PluginBoundary>
|
||||
)}
|
||||
</For>
|
||||
>
|
||||
{contribution}
|
||||
</Show>
|
||||
</SlotParent.Provider>
|
||||
<For each={slotted().after}>{contribution}</For>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// Pure resolution of the slot tree: the mounted slot paths plus plugin claims
|
||||
// in, per-path placement buckets plus diagnostics out. No solid, no I/O —
|
||||
// every policy rule (replacement takeover, hierarchy-beats-timeline,
|
||||
// last-enabled-wins, missing-target degradation) is testable as a data
|
||||
// transform.
|
||||
|
||||
export type PlacementKind = "prepend" | "append" | "before" | "after" | "replace"
|
||||
|
||||
// Normalized from the public SlotClaim shape by the plugin API: exactly one
|
||||
// placement kind, the target path erased to a string so the resolver stays
|
||||
// independent of the slot map.
|
||||
export type Placement = { readonly kind: PlacementKind; readonly target: string }
|
||||
|
||||
// One plugin's registered slot claim, in enable order within the claims array.
|
||||
export type Claim<Render> = {
|
||||
readonly key: string
|
||||
readonly plugin: string
|
||||
readonly placement: Placement
|
||||
readonly render: Render
|
||||
}
|
||||
|
||||
// Everything one mounted slot renders besides its own children: siblings
|
||||
// around the boundary, contributions inside it, and at most one takeover.
|
||||
export type Slotted<Render> = {
|
||||
readonly before: ReadonlyArray<Claim<Render>>
|
||||
readonly prepend: ReadonlyArray<Claim<Render>>
|
||||
readonly append: ReadonlyArray<Claim<Render>>
|
||||
readonly after: ReadonlyArray<Claim<Render>>
|
||||
readonly replace?: Claim<Render>
|
||||
}
|
||||
|
||||
export type Suppressed<Render> = {
|
||||
readonly claim: Claim<Render>
|
||||
// The winning claim for a conflict or boundary suppression; absent when a
|
||||
// replacement's target no longer exists (missing replacements never degrade).
|
||||
readonly by?: Claim<Render>
|
||||
}
|
||||
|
||||
export type Degraded<Render> = {
|
||||
readonly claim: Claim<Render>
|
||||
// The surviving ancestor path the claim was appended to.
|
||||
readonly to: string
|
||||
}
|
||||
|
||||
const EMPTY: Slotted<never> = { before: [], prepend: [], append: [], after: [] }
|
||||
|
||||
export function emptySlotted<Render>(): Slotted<Render> {
|
||||
return EMPTY
|
||||
}
|
||||
|
||||
// The tree's one containment rule: a path contains every path it prefixes.
|
||||
// Shared with the <Slot> mount assertion so the spellings cannot drift.
|
||||
export function contains(ancestor: string, path: string) {
|
||||
return path.startsWith(ancestor + ".")
|
||||
}
|
||||
|
||||
// `paths` is the set of currently mounted slot paths; `claims` is every
|
||||
// active claim in plugin enable order. The result maps each targeted path to
|
||||
// its placement buckets — untargeted paths are absent and render as empty.
|
||||
export function resolveSlots<Render>(input: {
|
||||
readonly paths: ReadonlySet<string>
|
||||
readonly claims: ReadonlyArray<Claim<Render>>
|
||||
}): {
|
||||
readonly slotted: ReadonlyMap<string, Slotted<Render>>
|
||||
readonly suppressed: ReadonlyArray<Suppressed<Render>>
|
||||
readonly degraded: ReadonlyArray<Degraded<Render>>
|
||||
} {
|
||||
const suppressed: Suppressed<Render>[] = []
|
||||
const degraded: Degraded<Render>[] = []
|
||||
|
||||
// Pass 1 — replacement boundaries. Per target the last-enabled claim wins;
|
||||
// then an accepted boundary swallows every replacement strictly inside it,
|
||||
// regardless of enable order (hierarchy beats timeline).
|
||||
const winners = new Map<string, Claim<Render>>()
|
||||
for (const claim of input.claims) {
|
||||
if (claim.placement.kind !== "replace") continue
|
||||
if (!input.paths.has(claim.placement.target)) {
|
||||
suppressed.push({ claim })
|
||||
continue
|
||||
}
|
||||
const prior = winners.get(claim.placement.target)
|
||||
if (prior) suppressed.push({ claim: prior, by: claim })
|
||||
winners.set(claim.placement.target, claim)
|
||||
}
|
||||
const boundaries = new Map<string, Claim<Render>>()
|
||||
const containing = (path: string) => {
|
||||
for (const [boundary, winner] of boundaries) if (contains(boundary, path)) return winner
|
||||
return undefined
|
||||
}
|
||||
// Shallow boundaries first, so a nested replacement meets its container.
|
||||
for (const [path, winner] of [...winners].sort((a, b) => depth(a[0]) - depth(b[0]))) {
|
||||
const outer = containing(path)
|
||||
if (outer) {
|
||||
suppressed.push({ claim: winner, by: outer })
|
||||
continue
|
||||
}
|
||||
boundaries.set(path, winner)
|
||||
}
|
||||
|
||||
// Pass 2 — additive claims, in enable order. A claim whose target sits
|
||||
// inside a replaced boundary is suppressed; a claim whose target is gone
|
||||
// degrades to appending on the nearest surviving ancestor.
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{ before: Claim<Render>[]; prepend: Claim<Render>[]; append: Claim<Render>[]; after: Claim<Render>[] }
|
||||
>()
|
||||
const bucket = (path: string) => {
|
||||
const existing = buckets.get(path)
|
||||
if (existing) return existing
|
||||
const fresh = { before: [], prepend: [], append: [], after: [] }
|
||||
buckets.set(path, fresh)
|
||||
return fresh
|
||||
}
|
||||
for (const claim of input.claims) {
|
||||
const kind = claim.placement.kind
|
||||
if (kind === "replace") continue
|
||||
const target = claim.placement.target
|
||||
// Inside placements targeting a replaced boundary are part of its
|
||||
// contents; sibling placements on the boundary itself stay outside it.
|
||||
const inside = kind === "prepend" || kind === "append" ? boundaries.get(target) : undefined
|
||||
const outer = inside ?? containing(target)
|
||||
if (outer) {
|
||||
suppressed.push({ claim, by: outer })
|
||||
continue
|
||||
}
|
||||
if (input.paths.has(target)) {
|
||||
bucket(target)[kind].push(claim)
|
||||
continue
|
||||
}
|
||||
const ancestor = survivingAncestor(target, input.paths)
|
||||
if (ancestor === undefined) {
|
||||
suppressed.push({ claim })
|
||||
continue
|
||||
}
|
||||
// The ancestor is never a replaced boundary (containing() caught that).
|
||||
// Appending is load-bearing: a parent slot registers before its children
|
||||
// and <Slot> renders append after them, so the transient degradation
|
||||
// during a nested mount never instantiates anything.
|
||||
degraded.push({ claim, to: ancestor })
|
||||
bucket(ancestor).append.push(claim)
|
||||
}
|
||||
|
||||
const slotted = new Map<string, Slotted<Render>>()
|
||||
for (const [path, lists] of buckets) slotted.set(path, lists)
|
||||
for (const [path, winner] of boundaries) slotted.set(path, { ...(buckets.get(path) ?? EMPTY), replace: winner })
|
||||
return { slotted, suppressed, degraded }
|
||||
}
|
||||
|
||||
function depth(path: string) {
|
||||
return path.split(".").length
|
||||
}
|
||||
|
||||
function survivingAncestor(path: string, paths: ReadonlySet<string>) {
|
||||
for (let index = path.lastIndexOf("."); index !== -1; index = path.lastIndexOf(".", index - 1)) {
|
||||
const ancestor = path.slice(0, index)
|
||||
if (paths.has(ancestor)) return ancestor
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { useEditorContext } from "../context/editor"
|
||||
import { useData } from "../context/data"
|
||||
import { useLocation } from "../context/location"
|
||||
import { FormPrompt } from "./session/form"
|
||||
import { PluginSlot } from "../plugin/render"
|
||||
import { Slot } from "../plugin/render"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
|
||||
let once = false
|
||||
@@ -91,7 +91,7 @@ export function Home() {
|
||||
<box flexGrow={1} minHeight={0} />
|
||||
</box>
|
||||
<box width="100%" flexShrink={0}>
|
||||
<PluginSlot name="home.footer" input={{}} mode="replace" />
|
||||
<Slot path="home.footer" />
|
||||
</box>
|
||||
<Show when={forms()[0]?.id} keyed>
|
||||
{(_) => {
|
||||
|
||||
@@ -86,7 +86,7 @@ import { collapseToolOutput } from "../../util/collapse-tool-output"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { usePathFormatter } from "../../context/path-format"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import {
|
||||
cacheReuseDrop,
|
||||
@@ -1088,7 +1088,7 @@ export function Session() {
|
||||
<Show when={!composer.open && !disabled() && queuedPrompts().length > 0}>
|
||||
<QueuedPromptDock prompts={queuedPrompts()} onOpen={openQueuedPrompts} />
|
||||
</Show>
|
||||
<PluginSlot name="session.composer.top" input={{ sessionID: route.sessionID }} mode="all" />
|
||||
<Slot path="session.composer.top" input={{ sessionID: route.sessionID }} />
|
||||
<Composer
|
||||
sessionID={route.sessionID}
|
||||
open={composer.open || (!!session()?.parentID && forms().length === 0)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useData } from "../../context/data"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { useConfig } from "../../config"
|
||||
import { PluginSlot } from "../../plugin/render"
|
||||
import { Slot } from "../../plugin/render"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
|
||||
import { getScrollAcceleration } from "../../util/scroll"
|
||||
@@ -52,12 +52,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<text fg={theme.text.subdued}>{session()!.location.workspaceID}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<PluginSlot name="sidebar.content" input={{ sessionID: props.sessionID }} mode="all" />
|
||||
<Slot path="sidebar.content" input={{ sessionID: props.sessionID }} />
|
||||
</box>
|
||||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<PluginSlot name="sidebar.footer" input={{}} mode="replace" />
|
||||
<Slot path="sidebar.footer" />
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
KeymapCommand,
|
||||
KeymapLayer,
|
||||
Page,
|
||||
SlotClaim,
|
||||
Route,
|
||||
Slot,
|
||||
} from "@opencode-ai/plugin/tui/context"
|
||||
import { ThemeProvider, useThemes } from "../../../src/context/theme"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
@@ -143,7 +143,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
const commands = new Map<string, KeymapCommand>()
|
||||
let current = initialRoute ?? startRoute
|
||||
let renderDiff: Page["render"] | undefined
|
||||
let renderCommands: Slot | undefined
|
||||
let renderCommands: SlotClaim<"app">["render"] | undefined
|
||||
let vcsDiffInput: unknown
|
||||
const config = createTuiResolvedConfig()
|
||||
const transport = createFetch((url) => {
|
||||
@@ -200,8 +200,8 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
|
||||
},
|
||||
current: () => current,
|
||||
},
|
||||
slot(_name: string, render: Slot) {
|
||||
renderCommands = render
|
||||
slot(claim: SlotClaim<"app">) {
|
||||
renderCommands = claim.render
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -140,8 +140,11 @@ import { appendFile } from "node:fs/promises"
|
||||
export default {
|
||||
id: "test.crash",
|
||||
setup: async (context: any) => {
|
||||
context.ui.slot("home.footer", () => {
|
||||
throw new Error("boom")
|
||||
context.ui.slot({
|
||||
replace: "home.footer",
|
||||
render: () => {
|
||||
throw new Error("boom")
|
||||
},
|
||||
})
|
||||
await appendFile(${JSON.stringify(markerCrash)}, "setup\\n")
|
||||
},
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SlotClaim } from "@opencode-ai/plugin/tui/context"
|
||||
import { resolveSlots, type Claim, type PlacementKind } from "../src/plugin/structure"
|
||||
|
||||
// Type-level canaries, checked by `bun typecheck`: exactly one placement key,
|
||||
// absolute paths only, and the render input follows the targeted path.
|
||||
export const canaries = () => {
|
||||
const claims: SlotClaim[] = []
|
||||
claims.push({ append: "prompt.footer", render: (input) => (input.mode === "shell" ? null : null) })
|
||||
claims.push({ after: "prompt.footer.status", render: () => null })
|
||||
// @ts-expect-error two placement keys cannot coexist
|
||||
claims.push({ append: "prompt.footer", before: "prompt.footer.status", render: () => null })
|
||||
// @ts-expect-error replace does not combine with an anchor
|
||||
claims.push({ replace: "prompt.footer.status", after: "prompt.footer.file", render: () => null })
|
||||
// @ts-expect-error targets must be absolute published paths
|
||||
claims.push({ after: "status", render: () => null })
|
||||
// @ts-expect-error the render input is the targeted slot's input
|
||||
claims.push({ append: "prompt.footer", render: (input: { mode: number }) => null })
|
||||
return claims
|
||||
}
|
||||
|
||||
// The resolver is generic over render values; strings make layout assertions
|
||||
// read as layouts. Placements are written in the public claim shape and
|
||||
// normalized here, like the plugin API does.
|
||||
function claim(plugin: string, placement: Partial<Record<PlacementKind, string>>, render: string): Claim<string> {
|
||||
const kind = (["prepend", "append", "before", "after", "replace"] as const).find((item) => placement[item])!
|
||||
return { key: `${plugin}/${render}`, plugin, placement: { kind, target: placement[kind]! }, render }
|
||||
}
|
||||
|
||||
// A host slot tree for tests: a node's children are its child slots, a leaf's
|
||||
// content is its own name. Mirrors how nested <Slot> components mount.
|
||||
type Node = { readonly path: string; readonly children?: ReadonlyArray<Node> }
|
||||
|
||||
function paths(nodes: ReadonlyArray<Node>, into = new Set<string>()): Set<string> {
|
||||
for (const node of nodes) {
|
||||
into.add(node.path)
|
||||
paths(node.children ?? [], into)
|
||||
}
|
||||
return into
|
||||
}
|
||||
|
||||
// Fold the tree with a resolution into the flat render order, mirroring the
|
||||
// <Slot> component: before + (replace | prepend + own content + append) + after.
|
||||
function layout(nodes: ReadonlyArray<Node>, resolved: ReturnType<typeof resolveSlots<string>>): ReadonlyArray<string> {
|
||||
return nodes.flatMap((node) => {
|
||||
const slotted = resolved.slotted.get(node.path)
|
||||
const own = node.children ? layout(node.children, resolved) : [leafName(node.path)]
|
||||
const inside = slotted?.replace
|
||||
? [slotted.replace.render]
|
||||
: [
|
||||
...(slotted?.prepend ?? []).map((item) => item.render),
|
||||
...own,
|
||||
...(slotted?.append ?? []).map((item) => item.render),
|
||||
]
|
||||
return [
|
||||
...(slotted?.before ?? []).map((item) => item.render),
|
||||
...inside,
|
||||
...(slotted?.after ?? []).map((item) => item.render),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function leafName(path: string) {
|
||||
return path.slice(path.lastIndexOf(".") + 1)
|
||||
}
|
||||
|
||||
function resolve(tree: ReadonlyArray<Node>, claims: ReadonlyArray<Claim<string>>) {
|
||||
return resolveSlots({ paths: paths(tree), claims })
|
||||
}
|
||||
|
||||
const footer: Node[] = [
|
||||
{
|
||||
path: "prompt.footer",
|
||||
children: [{ path: "prompt.footer.status" }, { path: "prompt.footer.file" }],
|
||||
},
|
||||
]
|
||||
|
||||
const tree: Node[] = [
|
||||
{
|
||||
path: "prompt.footer",
|
||||
children: [
|
||||
{ path: "prompt.footer.left", children: [{ path: "prompt.footer.left.mode" }] },
|
||||
{
|
||||
path: "prompt.footer.right",
|
||||
children: [
|
||||
{ path: "prompt.footer.right.directory" },
|
||||
{ path: "prompt.footer.right.model" },
|
||||
{ path: "prompt.footer.right.tokens" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
test("no claims renders the host tree in order", () => {
|
||||
const result = resolve(footer, [])
|
||||
expect(layout(footer, result)).toEqual(["status", "file"])
|
||||
expect(result.suppressed).toEqual([])
|
||||
expect(result.degraded).toEqual([])
|
||||
})
|
||||
|
||||
test("prepend and append land inside a boundary's edges, several in enable order", () => {
|
||||
const result = resolve(footer, [
|
||||
claim("a", { append: "prompt.footer" }, "a1"),
|
||||
claim("b", { prepend: "prompt.footer" }, "b1"),
|
||||
claim("a", { append: "prompt.footer" }, "a2"),
|
||||
])
|
||||
expect(layout(footer, result)).toEqual(["b1", "status", "file", "a1", "a2"])
|
||||
})
|
||||
|
||||
test("before and after anchor to a slot, wherever the host keeps it", () => {
|
||||
const result = resolve(footer, [
|
||||
claim("a", { after: "prompt.footer.status" }, "chip"),
|
||||
claim("b", { before: "prompt.footer.status" }, "vim"),
|
||||
])
|
||||
expect(layout(footer, result)).toEqual(["vim", "status", "chip", "file"])
|
||||
})
|
||||
|
||||
test("a missing anchor degrades to the nearest surviving ancestor's end", () => {
|
||||
const result = resolve(footer, [claim("a", { after: "prompt.footer.tokens" }, "chip")])
|
||||
expect(layout(footer, result)).toEqual(["status", "file", "chip"])
|
||||
expect(result.degraded).toEqual([
|
||||
{ claim: claim("a", { after: "prompt.footer.tokens" }, "chip"), to: "prompt.footer" },
|
||||
])
|
||||
})
|
||||
|
||||
test("an additive claim with no surviving ancestor is suppressed", () => {
|
||||
const result = resolve(footer, [claim("a", { append: "session.composer.top" }, "chip")])
|
||||
expect(layout(footer, result)).toEqual(["status", "file"])
|
||||
expect(result.suppressed).toEqual([{ claim: claim("a", { append: "session.composer.top" }, "chip") }])
|
||||
})
|
||||
|
||||
test("a missing replacement is suppressed, never degraded into a widget", () => {
|
||||
const result = resolve(footer, [claim("a", { replace: "prompt.footer.tokens" }, "cost")])
|
||||
expect(layout(footer, result)).toEqual(["status", "file"])
|
||||
expect(result.suppressed).toEqual([{ claim: claim("a", { replace: "prompt.footer.tokens" }, "cost") }])
|
||||
expect(result.degraded).toEqual([])
|
||||
})
|
||||
|
||||
test("replacing a slot swaps content but keeps the boundary and its outside anchors", () => {
|
||||
const fancy = claim("a", { replace: "prompt.footer.status" }, "fancy-status")
|
||||
const result = resolve(footer, [fancy, claim("b", { after: "prompt.footer.status" }, "chip")])
|
||||
expect(layout(footer, result)).toEqual(["fancy-status", "chip", "file"])
|
||||
expect(result.suppressed).toEqual([])
|
||||
})
|
||||
|
||||
test("inside contributions to a replaced boundary are suppressed", () => {
|
||||
const takeover = claim("a", { replace: "prompt.footer" }, "powerline")
|
||||
const badge = claim("b", { append: "prompt.footer" }, "badge")
|
||||
const result = resolve(footer, [badge, takeover])
|
||||
expect(layout(footer, result)).toEqual(["powerline"])
|
||||
expect(result.suppressed).toEqual([{ claim: badge, by: takeover }])
|
||||
})
|
||||
|
||||
test("same target: the last-enabled replacement wins and the loser is recorded", () => {
|
||||
const first = claim("a", { replace: "prompt.footer.status" }, "first")
|
||||
const second = claim("b", { replace: "prompt.footer.status" }, "second")
|
||||
const result = resolve(footer, [first, second])
|
||||
expect(layout(footer, result)).toEqual(["second", "file"])
|
||||
expect(result.suppressed).toEqual([{ claim: first, by: second }])
|
||||
})
|
||||
|
||||
test("container takeover suppresses everything anchored in the subtree", () => {
|
||||
const takeover = claim("theme", { replace: "prompt.footer.right" }, "my-right")
|
||||
const chip = claim("pr", { after: "prompt.footer.right.model" }, "chip")
|
||||
const inner = claim("x", { replace: "prompt.footer.right.tokens" }, "cost")
|
||||
const result = resolve(tree, [takeover, chip, inner])
|
||||
expect(layout(tree, result)).toEqual(["mode", "my-right"])
|
||||
expect(result.suppressed).toEqual([
|
||||
{ claim: inner, by: takeover },
|
||||
{ claim: chip, by: takeover },
|
||||
])
|
||||
})
|
||||
|
||||
test("hierarchy beats timeline: an ancestor takeover wins over a later descendant claim", () => {
|
||||
// The descendant replace was enabled after the container takeover; the
|
||||
// container still wins because its path contains the descendant's.
|
||||
const inner = claim("x", { replace: "prompt.footer.right.model" }, "swap-model")
|
||||
const outer = claim("theme", { replace: "prompt.footer.right" }, "my-right")
|
||||
const result = resolve(tree, [outer, inner])
|
||||
expect(layout(tree, result)).toEqual(["mode", "my-right"])
|
||||
expect(result.suppressed).toEqual([{ claim: inner, by: outer }])
|
||||
})
|
||||
|
||||
test("root takeover: nothing original survives, all inside claims suppressed", () => {
|
||||
const theme = claim("powerline", { replace: "prompt.footer" }, "powerline")
|
||||
const chip = claim("pr", { append: "prompt.footer" }, "chip")
|
||||
const result = resolve(tree, [chip, theme])
|
||||
expect(layout(tree, result)).toEqual(["powerline"])
|
||||
expect(result.suppressed).toEqual([{ claim: chip, by: theme }])
|
||||
})
|
||||
|
||||
test("a degraded claim landing inside a replaced boundary is suppressed, not shown", () => {
|
||||
const takeover = claim("theme", { replace: "prompt.footer.right" }, "my-right")
|
||||
const stray = claim("pr", { after: "prompt.footer.right.gone" }, "chip")
|
||||
const result = resolve(tree, [takeover, stray])
|
||||
expect(layout(tree, result)).toEqual(["mode", "my-right"])
|
||||
expect(result.suppressed).toEqual([{ claim: stray, by: takeover }])
|
||||
expect(result.degraded).toEqual([])
|
||||
})
|
||||
|
||||
test("anchors on a container wrap its whole span", () => {
|
||||
const result = resolve(tree, [
|
||||
claim("a", { before: "prompt.footer.right" }, "divider"),
|
||||
claim("b", { after: "prompt.footer.right" }, "clock"),
|
||||
])
|
||||
expect(layout(tree, result)).toEqual(["mode", "divider", "directory", "model", "tokens", "clock"])
|
||||
})
|
||||
Reference in New Issue
Block a user