diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index 0ae3e1afa5..e1ba963131 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -150,24 +150,89 @@ export interface Page { readonly render: (input: { readonly data?: Record }) => 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> readonly "home.footer": Readonly> - 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> } +export type SlotPath = keyof SlotMap -export type SlotName = keyof SlotMap -export type Slot = (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 + ? { 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: Name, render: Slot) => () => void + /** Claims a place in the slot tree; see SlotClaim. */ + readonly slot: (claim: SlotClaim) => () => void } export interface Context { diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index b5713bf344..13528e3211 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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 }) { - + diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0252cfc91b..6d02023bf3 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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) { /> - - - - - - [⋯]}> - + + + + + + + + [⋯]}> + + + + 0 ? theme.background.action.primary.default : theme.text.default} + wrapMode="none" + truncate + flexShrink={1} + > + esc{" "} + 0 ? theme.background.action.primary.default : theme.text.subdued, + }} + > + {store.interrupt > 0 ? "again to interrupt" : "interrupt"} + + + + + + {(progress) => ( + + + {progress()} + {".".repeat(move.creatingDots())} + + + )} + + + + + (new working copy) + + + + + }> + {(location) => ( + + {location()} + + )} - + + + + + + + {(file) => ( 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{" "} - 0 ? theme.background.action.primary.default : theme.text.subdued, - }} - > - {store.interrupt > 0 ? "again to interrupt" : "interrupt"} - + {file()} - - - - {(progress) => ( - - - {progress()} - {".".repeat(move.creatingDots())} - - )} - - - - - (new working copy) - - - - - }> - {(location) => ( - - {location()} - - )} - - - - - - {(file) => ( - - {file()} - - )} - - + + + ) + // 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: () => }) }, }) diff --git a/packages/tui/src/feature-plugins/prompt/footer.tsx b/packages/tui/src/feature-plugins/prompt/footer.tsx index f0aeb5175b..4ce95e3d8e 100644 --- a/packages/tui/src/feature-plugins/prompt/footer.tsx +++ b/packages/tui/src/feature-plugins/prompt/footer.tsx @@ -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) => ( - - )) + context.ui.slot({ + append: "prompt.footer", + render: (props) => , + }) }, }) diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index a94a89a188..3779e435ee 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -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) => ) + context.ui.slot({ + append: "sidebar.content", + render: (props) => , + }) }, }) diff --git a/packages/tui/src/feature-plugins/sidebar/footer.tsx b/packages/tui/src/feature-plugins/sidebar/footer.tsx index 0df32ff9b5..e1a913f864 100644 --- a/packages/tui/src/feature-plugins/sidebar/footer.tsx +++ b/packages/tui/src/feature-plugins/sidebar/footer.tsx @@ -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", () => ) + // Append keeps the path open to additive plugin claims; an external + // replace still takes the boundary over. + context.ui.slot({ append: "sidebar.footer", render: () => }) }, }) diff --git a/packages/tui/src/feature-plugins/sidebar/mcp.tsx b/packages/tui/src/feature-plugins/sidebar/mcp.tsx index 92c1e1b6ca..2069c83b74 100644 --- a/packages/tui/src/feature-plugins/sidebar/mcp.tsx +++ b/packages/tui/src/feature-plugins/sidebar/mcp.tsx @@ -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) => ) + context.ui.slot({ + append: "sidebar.content", + render: (props) => , + }) }, }) diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index a3f70dc259..f5b06c4f5e 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -1090,6 +1090,6 @@ export default Plugin.define({ name: ROUTE, render: () => , }) - context.ui.slot("app", () => ) + context.ui.slot({ append: "app", render: () => }) }, }) diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index c0f3d20a47..676ee3e475 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -85,6 +85,6 @@ function Commands(props: { context: Plugin.Context }) { export default Plugin.define({ id, setup(context) { - context.ui.slot("app", () => ) + context.ui.slot({ append: "app", render: () => }) }, }) diff --git a/packages/tui/src/feature-plugins/system/storybook/index.tsx b/packages/tui/src/feature-plugins/system/storybook/index.tsx index 32403bd561..78eb3615ab 100644 --- a/packages/tui/src/feature-plugins/system/storybook/index.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/index.tsx @@ -137,6 +137,6 @@ export default Plugin.define({ return }, }) - context.ui.slot("app", () => ) + context.ui.slot({ append: "app", render: () => }) }, }) diff --git a/packages/tui/src/plugin/api.tsx b/packages/tui/src/plugin/api.tsx index 928842baba..b7e4d7b670 100644 --- a/packages/tui/src/plugin/api.tsx +++ b/packages/tui/src/plugin/api.tsx @@ -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 +// 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) }, }, } diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index cb89e1e53b..a8053f4a25 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -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 readonly registered: () => ReadonlyArray readonly route: (id: string, name: string) => Page["render"] | undefined - readonly slot: ( - name: Name, - ) => ReadonlyArray<{ readonly id: string; readonly render: Slot }> + readonly slots: { + // A mounted instance registers its path; the disposer unregisters. + readonly register: (path: string) => () => void + readonly resolved: () => ReturnType> + } readonly markdown: () => MarkdownOptions["renderNode"] readonly activate: (id: string) => Promise readonly deactivate: (id: string) => Promise @@ -62,7 +65,7 @@ type Registration = { options?: Readonly> active: boolean routes: Record - slots: Record + slots: Record markdown: Record 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() + const slotItems = new WeakMap>() + // The mounted slot tree: path -> live 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>({}) + 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 [] - // 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. diff --git a/packages/tui/src/plugin/render.tsx b/packages/tui/src/plugin/render.tsx index c6704592ec..0a97a1d494 100644 --- a/packages/tui/src/plugin/render.tsx +++ b/packages/tui/src/plugin/render.tsx @@ -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(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() + +// `input` is required exactly when the path publishes a non-empty input. +type SlotProps = 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(props: SlotProps) { 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(), + emptySlotted(), + // 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) => ( + + {createComponent(claim.render, mergeProps(input))} + + ) return ( - - {(item) => ( - - { - // 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]) + <> + {contribution} + {/* before/after are siblings outside the boundary, so outside the provider. */} + + + {contribution} + {props.children} + {contribution} + } - - )} - + > + {contribution} + + + {contribution} + ) } diff --git a/packages/tui/src/plugin/structure.ts b/packages/tui/src/plugin/structure.ts new file mode 100644 index 0000000000..1a5e861767 --- /dev/null +++ b/packages/tui/src/plugin/structure.ts @@ -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 = { + 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 = { + readonly before: ReadonlyArray> + readonly prepend: ReadonlyArray> + readonly append: ReadonlyArray> + readonly after: ReadonlyArray> + readonly replace?: Claim +} + +export type Suppressed = { + readonly claim: Claim + // 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 +} + +export type Degraded = { + readonly claim: Claim + // The surviving ancestor path the claim was appended to. + readonly to: string +} + +const EMPTY: Slotted = { before: [], prepend: [], append: [], after: [] } + +export function emptySlotted(): Slotted { + return EMPTY +} + +// The tree's one containment rule: a path contains every path it prefixes. +// Shared with the 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(input: { + readonly paths: ReadonlySet + readonly claims: ReadonlyArray> +}): { + readonly slotted: ReadonlyMap> + readonly suppressed: ReadonlyArray> + readonly degraded: ReadonlyArray> +} { + const suppressed: Suppressed[] = [] + const degraded: Degraded[] = [] + + // 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>() + 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>() + 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[]; prepend: Claim[]; append: Claim[]; after: Claim[] } + >() + 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 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>() + 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) { + 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 +} diff --git a/packages/tui/src/routes/home.tsx b/packages/tui/src/routes/home.tsx index d44e627ee8..b2326f54d6 100644 --- a/packages/tui/src/routes/home.tsx +++ b/packages/tui/src/routes/home.tsx @@ -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() { - + {(_) => { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index c4ee230737..c9eb7ed5fa 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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() { 0}> - + {session()!.location.workspaceID} - + - + diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index 20a08917b1..e9b393683e 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -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() 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 () => {} }, }, diff --git a/packages/tui/test/plugin-hot-reload.test.tsx b/packages/tui/test/plugin-hot-reload.test.tsx index a05cdcbd3c..6ae28b02e0 100644 --- a/packages/tui/test/plugin-hot-reload.test.tsx +++ b/packages/tui/test/plugin-hot-reload.test.tsx @@ -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") }, diff --git a/packages/tui/test/plugin-structure.test.ts b/packages/tui/test/plugin-structure.test.ts new file mode 100644 index 0000000000..6e80f548d4 --- /dev/null +++ b/packages/tui/test/plugin-structure.test.ts @@ -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>, render: string): Claim { + 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 components mount. +type Node = { readonly path: string; readonly children?: ReadonlyArray } + +function paths(nodes: ReadonlyArray, into = new Set()): Set { + 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 +// component: before + (replace | prepend + own content + append) + after. +function layout(nodes: ReadonlyArray, resolved: ReturnType>): ReadonlyArray { + 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, claims: ReadonlyArray>) { + 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"]) +})