Skip to content

Commit 2d74252

Browse files
committed
feat(plugin): plugin tabs in the session's composer panel
ui.composerTab({ id, title, hints, render }) adds a tab to the composer panel (the Subagents panel, opened by Down on an empty prompt), after the host's Subagents, Shell and Terminals tabs. Its content mounts only while it is the active tab of an open panel, so a keymap layer it creates in mode "composer" acts only then; the panel keeps Left/Right and Escape. Why the fork: the panel's tabs are host components registered through a context only the host's own tabs reach. A plugin cannot add one through the slots (the panel has none) and hiding the panel to draw its own would duplicate the Subagents and Shell tabs. This is the smallest generic hook, in the shape of ui.autocomplete; the dotfiles' wakes and monitors tabs use it, and it goes once upstream offers plugin tabs.
1 parent a364581 commit 2d74252

7 files changed

Lines changed: 220 additions & 3 deletions

File tree

‎packages/plugin/src/tui/context.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,37 @@ export interface AutocompleteSource {
610610
readonly options: () => readonly AutocompleteItem[]
611611
}
612612

613+
/** A key hint in the composer panel's footer: `label shortcut`. */
614+
export interface ComposerTabHint {
615+
readonly label: string
616+
readonly shortcut: string
617+
}
618+
619+
/** What a plugin's composer tab draws for. */
620+
export interface ComposerTabInput {
621+
/** The session whose panel shows the tab. */
622+
readonly sessionID: string
623+
/** Closes the panel, as Escape does. */
624+
readonly close: () => void
625+
}
626+
627+
/**
628+
* A tab of the session's composer panel (the Subagents panel, opened by ↓ on
629+
* an empty prompt), after the host's own tabs. Its content mounts only while
630+
* the tab is the active one in an open panel, so a keymap layer it creates
631+
* with `mode: "composer"` acts only then and goes when it unmounts. The panel
632+
* keeps ←/→ (tabs) and Escape (close) for itself.
633+
*/
634+
export interface ComposerTab {
635+
/** Unique among the panel's tabs; the host's are `subagents`, `shell` and `terminals`. */
636+
readonly id: string
637+
/** The tab's name in the panel's tab row. */
638+
readonly title: string
639+
/** The footer's key hints while the tab is active. Reactive. */
640+
readonly hints?: () => readonly ComposerTabHint[]
641+
readonly render: (input: ComposerTabInput) => JSX.Element
642+
}
643+
613644
/** Host markdown, styled with the current theme's syntax and every registered code-block renderer. */
614645
export type MarkdownProps = {
615646
readonly content: string
@@ -700,6 +731,11 @@ export interface UI {
700731
* trigger, the last-enabled one wins.
701732
*/
702733
readonly autocomplete: (source: AutocompleteSource) => () => void
734+
/**
735+
* Adds a tab to the session's composer panel until the returned cleanup
736+
* runs; see ComposerTab. Tabs follow the host's, in enable order.
737+
*/
738+
readonly composerTab: (tab: ComposerTab) => () => void
703739
/** The host's markdown renderer, for text that should read like the transcript. */
704740
readonly Markdown: (props: MarkdownProps) => JSX.Element
705741
/** The host's code renderer, for source that should read like the transcript's. */

‎packages/tui/src/plugin/api.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
SlotPath,
1212
Toast,
1313
AutocompleteSource,
14+
ComposerTab,
1415
} from "@opencode/plugin/tui/context"
1516
import type { Placement, PlacementKind } from "./structure"
1617
import { infoStringToFiletype, type MarkdownCodeBlockRenderer } from "@opentui/core"
@@ -50,10 +51,13 @@ export type RegisteredSlot = {
5051

5152
const placements = ["prepend", "append", "before", "after", "replace"] as const satisfies readonly PlacementKind[]
5253

54+
/** The composer panel's own tabs, which a plugin's tab cannot share an id with. */
55+
export const HOST_COMPOSER_TABS: readonly string[] = ["subagents", "shell", "terminals"]
56+
5357
// The provider's registration store, narrowed to what a plugin context needs:
5458
// route/slot registration lands there, but ordering and lifecycle stay owned
5559
// by the provider.
56-
export type RegistryKind = "routes" | "slots" | "markdown" | "layout" | "autocomplete"
60+
export type RegistryKind = "routes" | "slots" | "markdown" | "layout" | "autocomplete" | "composer"
5761

5862
export type Registry = {
5963
has(kind: RegistryKind, name: string): boolean
@@ -62,6 +66,7 @@ export type Registry = {
6266
set(kind: "markdown", name: string, render: MarkdownCodeBlockRenderer): void
6367
set(kind: "layout", name: string, layout: Layout): void
6468
set(kind: "autocomplete", name: string, source: AutocompleteSource): void
69+
set(kind: "composer", name: string, tab: ComposerTab): void
6570
remove(kind: RegistryKind, name: string): void
6671
active(): boolean
6772
}
@@ -335,6 +340,17 @@ export function createPluginContext(input: {
335340
input.registry.set("autocomplete", source.trigger, source)
336341
return registration("autocomplete", source.trigger)
337342
},
343+
composerTab(tab) {
344+
if (!/^[\w.-]+$/.test(tab.id))
345+
throw new Error(`Composer tab id must be letters, digits, ".", "_" or "-": ${tab.id}`)
346+
if (HOST_COMPOSER_TABS.includes(tab.id)) throw new Error(`Composer tab id is the host's own: ${tab.id}`)
347+
if (input.registry.has("composer", tab.id)) throw new Error(`Composer tab already registered: ${tab.id}`)
348+
input.registry.set("composer", tab.id, {
349+
...tab,
350+
render: (tabInput) => provide(() => tab.render(tabInput)),
351+
})
352+
return registration("composer", tab.id)
353+
},
338354
Markdown: PluginMarkdown,
339355
Code: PluginCode,
340356
},

‎packages/tui/src/plugin/context.tsx‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
import path from "path"
1717
import { stat } from "fs/promises"
1818
import { fileURLToPath } from "url"
19-
import type { AutocompleteSource, Layout, Page } from "@opencode/plugin/tui/context"
19+
import type { AutocompleteSource, ComposerTab, Layout, Page } from "@opencode/plugin/tui/context"
2020
import { Host } from "@opencode/plugin/host"
2121
import { resolveSlots, type Claim } from "./structure"
2222
import { createStore, produce, reconcile as reconcileStore, unwrap } from "solid-js/store"
@@ -73,6 +73,8 @@ type Value = {
7373
readonly layout: () => Layout
7474
/** Composer popups by trigger character; the last-enabled active plugin that claimed one wins. */
7575
readonly autocomplete: () => ReadonlyMap<string, AutocompleteSource>
76+
/** The composer panel's plugin tabs of active plugins, in enable order; an id taken twice keeps the first. */
77+
readonly composerTabs: () => readonly ComposerTab[]
7678
readonly activate: (id: string) => Promise<boolean>
7779
readonly deactivate: (id: string) => Promise<boolean>
7880
}
@@ -89,6 +91,7 @@ type Registration = {
8991
markdown: Record<string, MarkdownCodeBlockRenderer>
9092
layout: Record<string, Layout>
9193
autocomplete: Record<string, AutocompleteSource>
94+
composer: Record<string, ComposerTab>
9295
cleanups: Dispose[]
9396
}
9497

@@ -160,6 +163,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
160163
setStore("registrations", id, "markdown", reconcileStore({}))
161164
setStore("registrations", id, "layout", reconcileStore({}))
162165
setStore("registrations", id, "autocomplete", reconcileStore({}))
166+
setStore("registrations", id, "composer", reconcileStore({}))
163167
}
164168
// Registration-store key order is enable order, so later items win.
165169
const layout = createMemo<Layout>(() =>
@@ -183,6 +187,14 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
183187
}
184188
return result
185189
})
190+
const composerTabs = createMemo(() => {
191+
const result = new Map<string, ComposerTab>()
192+
for (const registration of Object.values(store.registrations)) {
193+
if (!registration.active) continue
194+
for (const tab of Object.values(registration.composer)) if (!result.has(tab.id)) result.set(tab.id, tab)
195+
}
196+
return [...result.values()]
197+
})
186198

187199
const activate = async (id: string) => {
188200
const item = store.registrations[id]
@@ -203,7 +215,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
203215
set: (
204216
kind: RegistryKind,
205217
name: string,
206-
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer | Layout | AutocompleteSource,
218+
value: Page | RegisteredSlot | MarkdownCodeBlockRenderer | Layout | AutocompleteSource | ComposerTab,
207219
) => setStore("registrations", id, kind, name, () => value),
208220
remove: (kind, name) =>
209221
setStore(
@@ -647,6 +659,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir
647659
markdown,
648660
layout,
649661
autocomplete,
662+
composerTabs,
650663
// Manual dialog toggles join the same chain as reconciles so a
651664
// toggle mid-reload cannot mix registrations across generations.
652665
activate: (id) => enqueue(() => activate(id)),
@@ -749,6 +762,7 @@ function toRegistration(item: Desired): Registration {
749762
markdown: {},
750763
layout: {},
751764
autocomplete: {},
765+
composer: {},
752766
cleanups: [],
753767
}
754768
}

‎packages/tui/src/routes/session/composer/index.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { Keymap } from "../../../context/keymap"
77
import { SubagentsTab } from "./subagents-tab"
88
import { ShellTab } from "./shell-tab"
99
import { TerminalsTab } from "./terminals-tab"
10+
import { PluginTab } from "./plugin-tab"
11+
import { usePlugin } from "../../../plugin/context"
1012
import { ComposerContext, type ComposerTab } from "./context"
1113

1214
export type ComposerProps = {
@@ -24,6 +26,7 @@ export type ComposerProps = {
2426

2527
export function Composer(props: ComposerProps) {
2628
const theme = useTheme()
29+
const plugins = usePlugin()
2730

2831
const [store, setStore] = createStore({
2932
tabs: {} as Record<string, ComposerTab>,
@@ -140,6 +143,7 @@ export function Composer(props: ComposerProps) {
140143
<Show when={props.terminals}>
141144
<TerminalsTab sessionID={props.sessionID} visibleTerminalID={props.visibleTerminalID} />
142145
</Show>
146+
<For each={plugins.composerTabs()}>{(tab) => <PluginTab tab={tab} sessionID={props.sessionID} />}</For>
143147
<box flexDirection="row" gap={2} paddingLeft={1} flexShrink={0}>
144148
<For each={footerHints()}>
145149
{(hint) => (
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { onCleanup, onMount, Show } from "solid-js"
2+
import type { ComposerTab } from "@opencode/plugin/tui/context"
3+
import { useComposerTab } from "./context"
4+
5+
/** A plugin's tab (`ui.composerTab`): registered while mounted, its content mounted only while it is the active tab. */
6+
export function PluginTab(props: { tab: ComposerTab; sessionID: string }) {
7+
const composer = useComposerTab()
8+
9+
onMount(() => {
10+
const cleanup = composer.register({
11+
id: props.tab.id,
12+
label: props.tab.title,
13+
hints: () => [...(props.tab.hints?.() ?? [])],
14+
})
15+
onCleanup(cleanup)
16+
})
17+
18+
return (
19+
<Show when={composer.active(props.tab.id)}>
20+
{props.tab.render({ sessionID: props.sessionID, close: composer.close })}
21+
</Show>
22+
)
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { Plugin } from "@opencode/plugin/tui"
2+
import { createSignal } from "solid-js"
3+
4+
// Adds a composer panel tab whose content counts ctrl+d presses while it is the active tab.
5+
export default Plugin.define({
6+
id: "fixture.composer-tab",
7+
setup(context) {
8+
const [count, setCount] = createSignal(0)
9+
context.ui.composerTab({
10+
id: "probe",
11+
title: "Probe",
12+
hints: () => [{ label: "count", shortcut: "ctrl+d" }],
13+
render: (input) => {
14+
context.keymap.layer(() => ({
15+
mode: "composer",
16+
priority: 1,
17+
commands: [{ title: "Count", bind: "ctrl+d", run: () => void setCount((value) => value + 1) }],
18+
}))
19+
return <text>{`Probe for ${input.sessionID}: ${count()}`}</text>
20+
},
21+
})
22+
},
23+
})
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { expect, test } from "bun:test"
2+
import type { ComposerTab } from "@opencode/plugin/tui/context"
3+
import { createPluginContext, type Registry, type RegistryKind, type usePluginHost } from "../src/plugin/api"
4+
import { createAppFixture } from "./fixture/app"
5+
import { copyPlugin } from "./fixture/fixture"
6+
import { directory, json } from "./fixture/tui-client"
7+
8+
type Host = ReturnType<typeof usePluginHost>
9+
10+
function setup() {
11+
const entries = new Map<string, unknown>()
12+
const registry: Registry = {
13+
has: (kind: RegistryKind, name: string) => entries.has(`${kind}:${name}`),
14+
set: (kind: RegistryKind, name: string, value: unknown) => void entries.set(`${kind}:${name}`, value),
15+
remove: (kind: RegistryKind, name: string) => void entries.delete(`${kind}:${name}`),
16+
active: () => true,
17+
}
18+
const host = {
19+
app: { version: "test", channel: "test" },
20+
client: { api: {} },
21+
keymap: {},
22+
shortcuts: {},
23+
keymapState: {},
24+
sessionTabs: {},
25+
} as unknown as Host
26+
const context = createPluginContext({ host, id: "test", options: undefined, owned: [], registry })
27+
return { entries, composerTab: context.ui.composerTab }
28+
}
29+
30+
const tab = (id: string): ComposerTab => ({ id, title: "Probe", render: () => null })
31+
32+
test("registers a tab under its id until the cleanup runs", () => {
33+
const harness = setup()
34+
const dispose = harness.composerTab(tab("probe"))
35+
expect(harness.entries.get("composer:probe")).toMatchObject({ id: "probe", title: "Probe" })
36+
dispose()
37+
expect(harness.entries.has("composer:probe")).toBe(false)
38+
})
39+
40+
test.each(["subagents", "shell", "terminals"])("refuses the host's own tab id %j", (id) => {
41+
expect(() => setup().composerTab(tab(id))).toThrow("Composer tab id is the host's own")
42+
})
43+
44+
test.each(["", "a b", "x/y"])("refuses the id %j", (id) => {
45+
expect(() => setup().composerTab(tab(id))).toThrow("Composer tab id must be")
46+
})
47+
48+
test("refuses a second tab with one id from the same plugin", () => {
49+
const harness = setup()
50+
harness.composerTab(tab("probe"))
51+
expect(() => harness.composerTab(tab("probe"))).toThrow("Composer tab already registered: probe")
52+
})
53+
54+
const session = {
55+
id: "ses_composer_tab",
56+
title: "Composer tab",
57+
projectID: "proj_test",
58+
location: { directory },
59+
cost: 0,
60+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
61+
time: { created: 0, updated: 0 },
62+
}
63+
const messages = [{ type: "user", id: "msg_ask", text: "Fix the parser", time: { created: 1 } }]
64+
65+
test("a plugin's tab follows the host's in the panel ↓ opens, its keys act while it is active, and it unmounts when the panel closes", async () => {
66+
const fixture = await copyPlugin("composer-tab")
67+
try {
68+
await using app = await createAppFixture({
69+
width: 100,
70+
height: 30,
71+
config: { animations: false, tabs: { enabled: false }, plugins: [fixture.directory] },
72+
args: { sessionID: session.id },
73+
fetch: (url) => {
74+
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
75+
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
76+
if (url.pathname === `/api/session/${session.id}/message`) return json({ data: messages, cursor: {} })
77+
if (/^\/api\/session\/[^/]+\/(inbox|permission)$/.test(url.pathname)) return json({ data: [] })
78+
return undefined
79+
},
80+
})
81+
await app.ready
82+
await app.waitForFrame((frame) => frame.includes("Fix the parser"))
83+
app.mockInput.pressArrow("down")
84+
const panel = await app.waitForFrame((frame) => frame.includes("Subagents") && frame.includes("Probe"))
85+
const tabs = panel.split("\n").find((line) => line.includes("Subagents"))!
86+
expect(tabs.indexOf("Probe")).toBeGreaterThan(tabs.indexOf("Shell"))
87+
// Its content mounts only while it is the active tab.
88+
expect(panel).not.toContain("Probe for")
89+
for (let step = 0; step < 4 && !app.captureCharFrame().includes("Probe for"); step++) {
90+
app.mockInput.pressArrow("right")
91+
await Bun.sleep(50)
92+
}
93+
await app.waitForFrame((frame) => frame.includes(`Probe for ${session.id}: 0`) && frame.includes("count ctrl+d"))
94+
app.mockInput.pressKey("d", { ctrl: true })
95+
await app.waitForFrame((frame) => frame.includes(`Probe for ${session.id}: 1`))
96+
app.mockInput.pressEscape()
97+
await app.waitForFrame((frame) => !frame.includes("Probe for") && !frame.includes("Subagents"))
98+
} finally {
99+
await fixture[Symbol.asyncDispose]()
100+
}
101+
})

0 commit comments

Comments
 (0)