Skip to content

feat(tui): hierarchical slot tree for plugin UI placement - #41189

Merged
kitlangton merged 5 commits into
v2from
tui-slot-structure
Aug 11, 2026
Merged

kitlangton merged 5 commits into
v2from
tui-slot-structure

Conversation

@kitlangton

@kitlangton kitlangton commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

What

Plugin UI placement becomes one recursive slot tree. There are no separate regions, parts, or structures: every extensible boundary in the host UI is a slot with an absolute dot-separated path, and a path contains every path it prefixes.

app
home.footer
prompt.footer
├── prompt.footer.status
└── prompt.footer.file
session.composer.top
sidebar.content
sidebar.footer

Plugins claim a place with one verb and one object; the placement is an exclusive sum of five operations that work uniformly at every node:

// siblings around a boundary
context.ui.slot({ after: "prompt.footer.status", render: () => <text>#{pr.number}</text> })

// contributions inside a boundary
context.ui.slot({ append: "prompt.footer", render: () => <Clock /> })

// takeover of a boundary
context.ui.slot({ replace: "prompt.footer", render: () => <Powerline /> })

prepend / append land inside the target's boundary; before / after are siblings outside it; replace takes the boundary over. ?: never fields make a claim with two placement keys a compile error, pinned by type canaries.

Policy, all host-side and recorded (never silently dropped):

  • Replace preserves the named boundary, replaces everything it contains. Siblings anchored before/after a replaced slot still compose; the original content and every claim inside the boundary are suppressed.
  • Hierarchy beats timeline: an ancestor takeover wins over a later descendant claim; at the same target, last-enabled wins. Plugin enable order is the only precedence mechanism — no priorities, weights, or conflict callbacks.
  • Missing targets degrade asymmetrically: additive claims append to the nearest surviving ancestor (a host path rename can't cost a plugin its render); replacements are suppressed (a whole-boundary takeover must never quietly become an appended widget).

Slot inputs. Each path publishes an explicit input — reactive props passed to every claim render targeting it — with a strict admission rule: only instance identity and client-local state the SDK cannot answer (prompt.footer publishes { sessionID, mode }; the composer's shell/normal mode is per-instance component state with no other escape hatch). Anything derivable through ctx.client/ctx.data stays out, so most paths publish {}. No inheritance: the absolute path determines both placement and input type.

context.ui.slot({
  after: "prompt.footer.status",
  render: (input) => <VoiceControl sessionID={input.sessionID} />,
})

How

  • packages/plugin/src/tui/context.ts — public SlotMap (absolute path → input type), SlotPath, and SlotClaim as a distributed union so an inline claim literal types its render input from the targeted path. ui.slot(claim) is the single-object signature; the region/part vocabulary is deleted.
  • packages/tui/src/plugin/structure.ts — the policy engine as one pure function, resolveSlots({ paths, claims }) → { slotted, suppressed, degraded }, over the set of mounted paths plus claims in enable order. Two passes: replacement boundaries (last-enabled per target, then shallow-first ancestor domination), then additive claims (boundary suppression, else bucket, else degrade). No solid, no I/O — every rule is unit-tested as a data transform.
  • packages/tui/src/plugin/render.tsx — Region becomes the recursive <Slot path input> primitive. Host content is ordinary JSX children; each mounted instance registers its path (reference-counted, so several composers can mount prompt.footer simultaneously under session tabs) and renders only its own buckets: before + (replace | prepend + children + append) + after. Per-claim PluginBoundary crash containment and component semantics are unchanged; the bucket memo is elementwise-equal so a claim change elsewhere in the tree is a no-op here. Paths are declared, not inferred — so each slot provides its path via context and a nested slot asserts at mount that its path extends its parent's, turning a wrong-parent copy-paste into a loud error instead of a silently mislocated public path.
  • packages/tui/src/plugin/context.tsx — the provider owns the mounted-path store and one global resolveSlots memo; claim objects are cached per render function so rows stay reference-stable across other plugins' reloads.
  • Host call sites (component/prompt/index.tsx, routes/home.tsx, routes/session/index.tsx, routes/session/sidebar.tsx, app.tsx) — declare the tree with nested <Slot>s; the prompt footer mounts prompt.footer with status and file children.

All eight builtin feature-plugins are migrated and dogfood the new claims — the prompt footer and sidebar builtins consume per-path inputs, so the input contract is exercised in-tree before any external plugin touches it. The home and sidebar footer builtins contribute via append rather than replace, keeping those paths open to additive plugin claims while an external replace still takes the boundary over.

A post-implementation review pass (reuse, quality, efficiency reviewers + a final correctness audit) tightened the internals: input is conditionally required on <Slot> (omitting it on sidebar.content is now a type error), placements normalize once to { kind, target }, a dead resolver branch was removed, ui.slot throws on claims carrying more than one placement key, and the mount-order/refcount subtleties are pinned by comments.

Scope

Deliberately not in this PR, all layerable without changing the call site:

  • Plugins-dialog surfacing of the suppressed/degraded records (the data is computed and tested)
  • User-config slot hiding, width-pressure policy, size variants
  • More host furniture as named paths — prompt.footer.status/file is the starter vocabulary; paths are permanent API, so nothing ships speculatively

Testing

  • packages/tui: bun test — 641 pass, 0 fail. Resolver suite rewritten against resolveSlots with a fold helper mirroring <Slot> composition, so assertions still read as whole layouts: placement edges, anchoring, degradation to surviving ancestors, missing-replacement suppression, boundary takeover, same-target last-wins, hierarchy-beats-timeline, container spans.
  • bun typecheck clean in packages/tui and packages/plugin; @ts-expect-error canaries pin placement exclusivity, absolute-path targets, and per-path input typing.
  • Plugin hot-reload e2e suite green on the new ui.slot shape.

Flow

flowchart LR
  C["plugin claims<br/>(enable order)"] --> R["resolveSlots<br/>(pure)"]
  M["mounted slot paths<br/>(ref-counted per &lt;Slot&gt;)"] --> R
  R --> B["per-path buckets<br/>before / prepend / append / after / replace"]
  B --> S["each &lt;Slot&gt; renders its own buckets<br/>PluginBoundary per claim"]
  R -.-> D["suppressed + degraded<br/>(recorded; dialog follow-up)"]
Loading

@kitlangton kitlangton changed the title feat(tui): region structure for plugin slot placement feat(tui): hierarchical slot tree for plugin UI placement Aug 11, 2026
@kitlangton
kitlangton enabled auto-merge (squash) August 11, 2026 21:39
@kitlangton
kitlangton merged commit b00d8d6 into v2 Aug 11, 2026
13 of 14 checks passed
@kitlangton
kitlangton deleted the tui-slot-structure branch August 11, 2026 22:00
jinhuang712 pushed a commit to jinhuang712/opencode that referenced this pull request Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant