Repository navigation
feat(tui): hierarchical slot tree for plugin UI placement - #41189
Merged
Merged
Conversation
kitlangton
force-pushed
the
tui-slot-structure
branch
from
August 9, 2026 00:23
303cb23 to
7cc63f9
Compare
kitlangton
force-pushed
the
tui-slot-structure
branch
from
August 11, 2026 21:03
7cc63f9 to
bba9cd9
Compare
kitlangton
enabled auto-merge (squash)
August 11, 2026 21:39
jinhuang712
pushed a commit
to jinhuang712/opencode
that referenced
this pull request
Sep 26, 2026
This was referenced Oct 1, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
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:
prepend/appendland inside the target's boundary;before/afterare siblings outside it;replacetakes the boundary over.?: neverfields make a claim with two placement keys a compile error, pinned by type canaries.Policy, all host-side and recorded (never silently dropped):
before/aftera replaced slot still compose; the original content and every claim inside the boundary are suppressed.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.footerpublishes{ sessionID, mode }; the composer's shell/normal mode is per-instance component state with no other escape hatch). Anything derivable throughctx.client/ctx.datastays out, so most paths publish{}. No inheritance: the absolute path determines both placement and input type.How
packages/plugin/src/tui/context.ts— publicSlotMap(absolute path → input type),SlotPath, andSlotClaimas a distributed union so an inline claim literal types itsrenderinput 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—Regionbecomes the recursive<Slot path input>primitive. Host content is ordinary JSX children; each mounted instance registers its path (reference-counted, so several composers can mountprompt.footersimultaneously under session tabs) and renders only its own buckets:before + (replace | prepend + children + append) + after. Per-claimPluginBoundarycrash 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 globalresolveSlotsmemo; claim objects are cached per render function so rows stay reference-stable across other plugins' reloads.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 mountsprompt.footerwithstatusandfilechildren.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
appendrather thanreplace, keeping those paths open to additive plugin claims while an externalreplacestill takes the boundary over.A post-implementation review pass (reuse, quality, efficiency reviewers + a final correctness audit) tightened the internals:
inputis conditionally required on<Slot>(omitting it onsidebar.contentis now a type error), placements normalize once to{ kind, target }, a dead resolver branch was removed,ui.slotthrows 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:
suppressed/degradedrecords (the data is computed and tested)prompt.footer.status/fileis the starter vocabulary; paths are permanent API, so nothing ships speculativelyTesting
packages/tui:bun test— 641 pass, 0 fail. Resolver suite rewritten againstresolveSlotswith 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 typecheckclean inpackages/tuiandpackages/plugin;@ts-expect-errorcanaries pin placement exclusivity, absolute-path targets, and per-path input typing.ui.slotshape.Flow