Skip to content

Commit 72bdbed

Browse files
feat(plugin): ui components api
1 parent b474f65 commit 72bdbed

File tree

10 files changed

+926
-16
lines changed

10 files changed

+926
-16
lines changed

packages/opencode/src/cli/cmd/tui/app.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list"
1818
import { DialogHelp } from "./ui/dialog-help"
1919
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
2020
import { DialogAgent } from "@tui/component/dialog-agent"
21+
import { pluginModalRequest, setPluginModalRequest, PluginUIRenderer } from "@tui/plugin-ui"
2122
import { DialogSessionList } from "@tui/component/dialog-session-list"
2223
import { KeybindProvider } from "@tui/context/keybind"
2324
import { ThemeProvider, useTheme } from "@tui/context/theme"
@@ -33,6 +34,7 @@ import { KVProvider, useKV } from "./context/kv"
3334
import { Provider } from "@/provider/provider"
3435
import { ArgsProvider, useArgs, type Args } from "./context/args"
3536
import open from "open"
37+
import { PluginRegistry } from "./plugin-ui.tsx"
3638
import { PromptRefProvider, usePromptRef } from "./context/prompt"
3739

3840
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
@@ -99,6 +101,8 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
99101
// promise to prevent immediate exit
100102
return new Promise<void>(async (resolve) => {
101103
const mode = await getTerminalBackgroundColor()
104+
await PluginRegistry.load(input.url)
105+
102106
const onExit = async () => {
103107
await input.onExit?.()
104108
resolve()
@@ -205,6 +209,19 @@ function App() {
205209
})
206210

207211
const args = useArgs()
212+
213+
createEffect(() => {
214+
const request = pluginModalRequest()
215+
if (request) {
216+
dialog.replace(
217+
<box flexDirection="column" paddingLeft={2} paddingRight={2} paddingBottom={1}>
218+
<PluginUIRenderer node={request.node} metadata={request.metadata} />
219+
</box>,
220+
)
221+
setPluginModalRequest(null)
222+
}
223+
})
224+
208225
onMount(() => {
209226
batch(() => {
210227
if (args.agent) local.agent.set(args.agent)
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import { createSignal, ErrorBoundary, For, Show } from "solid-js"
2+
import { Dynamic } from "solid-js/web"
3+
import { useTheme } from "@tui/context/theme"
4+
import { useKeyboard } from "@opentui/solid"
5+
import type {
6+
PluginUINode,
7+
PluginUIComponent,
8+
TextNode,
9+
BoxNode,
10+
ChecklistNode,
11+
ButtonNode,
12+
CollapsibleNode,
13+
} from "@opencode-ai/plugin"
14+
import { Log } from "@/util/log"
15+
16+
const log = Log.create({ service: "plugin-ui" })
17+
18+
// Signal to trigger re-renders when dismissed state changes
19+
const [dismissed, setDismissed] = createSignal(new Set<string>())
20+
21+
// Global signal for plugin modals (used when DialogProvider context isn't available)
22+
export const [pluginModalRequest, setPluginModalRequest] = createSignal<{
23+
node: PluginUINode
24+
metadata: Record<string, any>
25+
} | null>(null)
26+
27+
// Plugin UI Registry - stores templates fetched from plugins
28+
export const PluginRegistry = {
29+
templates: new Map<string, { node: PluginUINode; replaceInput: boolean }>(),
30+
baseUrl: "",
31+
register(name: string, template: PluginUINode, replaceInput?: boolean) {
32+
this.templates.set(name, { node: template, replaceInput: replaceInput ?? false })
33+
},
34+
get(name: string) {
35+
const entry = this.templates.get(name)
36+
if (!entry) log.warn("template not found", { name })
37+
return entry?.node
38+
},
39+
shouldReplaceInput(name: string) {
40+
return this.templates.get(name)?.replaceInput ?? false
41+
},
42+
dismiss(componentKey: string) {
43+
setDismissed((prev) => {
44+
const next = new Set(prev)
45+
next.add(componentKey)
46+
return next
47+
})
48+
},
49+
isDismissed(componentKey: string) {
50+
return dismissed().has(componentKey)
51+
},
52+
async emit(component: string, event: string, data: Record<string, any>) {
53+
try {
54+
await fetch(`${this.baseUrl}/plugins/ui/event`, {
55+
method: "POST",
56+
headers: { "Content-Type": "application/json" },
57+
body: JSON.stringify({ component, event, data }),
58+
})
59+
} catch (e) {
60+
log.error("emit failed", { error: e })
61+
}
62+
},
63+
64+
async load(baseUrl: string) {
65+
this.baseUrl = baseUrl
66+
try {
67+
const res = await fetch(`${baseUrl}/plugins/ui`)
68+
if (!res.ok) throw new Error(`status ${res.status}`)
69+
const components: PluginUIComponent[] = await res.json()
70+
log.info("loaded", { count: components.length })
71+
for (const c of components) this.register(c.name, c.template, c.replaceInput)
72+
} catch (e) {
73+
log.error("load failed", { error: e })
74+
}
75+
},
76+
}
77+
78+
// Interpolate {{key}} placeholders with metadata values
79+
function interpolate(text: string, metadata: Record<string, any>): string {
80+
return text.replace(/\{\{(\w+)\}\}/g, (_, key) => String(metadata[key] ?? ""))
81+
}
82+
83+
// Helper to resolve theme colors - returns both getColor and theme
84+
function useColors() {
85+
const { theme } = useTheme()
86+
const getColor = (color?: string) => {
87+
if (!color) return undefined
88+
return (theme as any)[color] ?? color
89+
}
90+
return { getColor, theme }
91+
}
92+
93+
type RendererProps<T> = { node: T; metadata: Record<string, any> }
94+
95+
function PluginText(props: RendererProps<TextNode>) {
96+
const { getColor, theme } = useColors()
97+
const content = () => interpolate(props.node.content, props.metadata)
98+
return (
99+
<text fg={getColor(props.node.fg) ?? theme.text}>{props.node.bold ? <strong>{content()}</strong> : content()}</text>
100+
)
101+
}
102+
103+
function PluginBox(props: RendererProps<BoxNode>) {
104+
const { getColor, theme } = useColors()
105+
const n = props.node
106+
const hasBorder = n.border !== undefined
107+
return (
108+
<box
109+
flexDirection={n.direction ?? "row"}
110+
gap={n.gap ?? 0}
111+
backgroundColor={getColor(n.bg)}
112+
border={n.border}
113+
borderStyle={hasBorder ? n.borderStyle : undefined}
114+
borderColor={hasBorder ? (getColor(n.borderColor) ?? theme.border) : undefined}
115+
marginTop={n.marginTop ?? n.marginY}
116+
marginBottom={n.marginBottom ?? n.marginY}
117+
marginLeft={n.marginLeft ?? n.marginX}
118+
marginRight={n.marginRight ?? n.marginX}
119+
paddingTop={n.paddingTop ?? n.paddingY}
120+
paddingBottom={n.paddingBottom ?? n.paddingY}
121+
paddingLeft={n.paddingLeft ?? n.paddingX}
122+
paddingRight={n.paddingRight ?? n.paddingX}
123+
justifyContent={n.justifyContent}
124+
alignSelf={n.alignSelf}
125+
minWidth={n.minWidth}
126+
>
127+
<For each={n.children}>{(child) => <PluginUIRenderer node={child} metadata={props.metadata} />}</For>
128+
</box>
129+
)
130+
}
131+
132+
function PluginChecklist(props: RendererProps<ChecklistNode>) {
133+
const { getColor, theme } = useColors()
134+
135+
const rawItems = () =>
136+
typeof props.node.items === "string"
137+
? ((props.metadata[props.node.items] as Array<{ id: string; label: string; checked?: boolean }>) ?? [])
138+
: (props.node.items as Array<{ id: string; label: string; checked?: boolean }>)
139+
140+
const [items, setItems] = createSignal(rawItems().map((i) => ({ ...i, checked: i.checked ?? false })))
141+
const [focusedIndex, setFocusedIndex] = createSignal(0)
142+
143+
const toggleItem = (index: number) => {
144+
const item = items()[index]
145+
if (!item) return
146+
const updatedItems = items().map((i, idx) => (idx === index ? { ...i, checked: !i.checked } : i))
147+
setItems(updatedItems)
148+
if (props.node.onToggle && props.metadata._component) {
149+
PluginRegistry.emit(props.metadata._component, props.node.onToggle, {
150+
id: item.id,
151+
checked: !item.checked,
152+
items: updatedItems,
153+
})
154+
}
155+
}
156+
157+
useKeyboard((evt) => {
158+
if (evt.name === "up" || evt.name === "k") {
159+
setFocusedIndex((i) => Math.max(0, i - 1))
160+
return true
161+
}
162+
if (evt.name === "down" || evt.name === "j") {
163+
setFocusedIndex((i) => Math.min(items().length - 1, i + 1))
164+
return true
165+
}
166+
if (evt.name === "space") {
167+
toggleItem(focusedIndex())
168+
return true
169+
}
170+
return false
171+
})
172+
173+
return (
174+
<box flexDirection="column" gap={0}>
175+
<For each={items()}>
176+
{(item, index) => (
177+
<box
178+
flexDirection="row"
179+
justifyContent="space-between"
180+
backgroundColor={item.checked ? (getColor(props.node.bgChecked) ?? theme.backgroundElement) : undefined}
181+
paddingLeft={1}
182+
paddingRight={1}
183+
onMouseOver={() => setFocusedIndex(index())}
184+
onMouseDown={() => toggleItem(index())}
185+
>
186+
<box flexDirection="row">
187+
<text
188+
content={item.checked ? "● " : "○ "}
189+
fg={getColor(props.node.borderColorChecked) ?? theme.warning}
190+
/>
191+
<text
192+
content={item.label}
193+
fg={
194+
item.checked
195+
? (getColor(props.node.fgChecked) ?? theme.text)
196+
: (getColor(props.node.fg) ?? theme.textMuted)
197+
}
198+
/>
199+
</box>
200+
<text content={focusedIndex() === index() ? "◀" : ""} fg={theme.warning} />
201+
</box>
202+
)}
203+
</For>
204+
</box>
205+
)
206+
}
207+
208+
function PluginButton(props: RendererProps<ButtonNode>) {
209+
const { getColor, theme } = useColors()
210+
const [hovered, setHovered] = createSignal(false)
211+
const [clicked, setClicked] = createSignal(false)
212+
213+
const handleActivate = () => {
214+
if (clicked()) return
215+
setClicked(true)
216+
if (props.node.onModal) {
217+
setPluginModalRequest({ node: props.node.onModal, metadata: props.metadata })
218+
}
219+
if (props.node.onPress && props.metadata._component) {
220+
PluginRegistry.emit(props.metadata._component, props.node.onPress, {})
221+
if (props.metadata._partId) {
222+
setTimeout(() => PluginRegistry.dismiss(props.metadata._partId!), 0)
223+
}
224+
}
225+
}
226+
227+
useKeyboard((evt) => {
228+
if (clicked()) return false
229+
if (props.node.shortcut && evt.name === props.node.shortcut.toLowerCase()) {
230+
handleActivate()
231+
return true
232+
}
233+
return false
234+
})
235+
236+
const isActive = () => clicked() || hovered()
237+
const hasBackground = props.node.bg !== undefined
238+
239+
return (
240+
<box flexDirection="row">
241+
<Show when={hasBackground}>
242+
<box minWidth={1}>
243+
<text content={isActive() ? "┃" : " "} fg={getColor(props.node.borderColorHover) ?? theme.warning} />
244+
</box>
245+
</Show>
246+
<box
247+
backgroundColor={hasBackground ? (getColor(props.node.bg) ?? theme.accent) : undefined}
248+
paddingLeft={hasBackground ? 1 : 0}
249+
paddingRight={hasBackground ? 2 : 0}
250+
onMouseOver={() => setHovered(true)}
251+
onMouseOut={() => setHovered(false)}
252+
onMouseDown={handleActivate}
253+
>
254+
<text
255+
content={props.node.label}
256+
fg={
257+
isActive()
258+
? (getColor(props.node.fgHover) ?? theme.warning)
259+
: (getColor(props.node.fg) ?? (hasBackground ? theme.background : theme.text))
260+
}
261+
/>
262+
</box>
263+
</box>
264+
)
265+
}
266+
267+
function PluginCollapsible(props: RendererProps<CollapsibleNode>) {
268+
const { getColor, theme } = useColors()
269+
const [expanded, setExpanded] = createSignal(props.node.expanded ?? false)
270+
const title = () => interpolate(props.node.title, props.metadata)
271+
const fg = () =>
272+
expanded() ? (getColor(props.node.fgExpanded) ?? theme.text) : (getColor(props.node.fg) ?? theme.textMuted)
273+
274+
return (
275+
<box flexDirection="column" gap={0}>
276+
<box flexDirection="row" gap={1} onMouseDown={() => setExpanded((e) => !e)}>
277+
<text content={expanded() ? (props.node.iconExpanded ?? "▼") : (props.node.icon ?? "▶")} fg={fg()} />
278+
<text content={title()} fg={fg()} />
279+
</box>
280+
<Show when={expanded()}>
281+
<box flexDirection="column" paddingLeft={2}>
282+
<For each={props.node.children}>{(child) => <PluginUIRenderer node={child} metadata={props.metadata} />}</For>
283+
</box>
284+
</Show>
285+
</box>
286+
)
287+
}
288+
289+
// Component map for dynamic rendering
290+
const RENDERERS: Record<string, (props: RendererProps<any>) => any> = {
291+
text: PluginText,
292+
box: PluginBox,
293+
checklist: PluginChecklist,
294+
button: PluginButton,
295+
collapsible: PluginCollapsible,
296+
}
297+
298+
// Render a plugin UI template
299+
export function PluginUIRenderer(props: { node: PluginUINode; metadata: Record<string, any> }) {
300+
const { theme } = useColors()
301+
const Renderer = RENDERERS[props.node.type]
302+
return (
303+
<ErrorBoundary fallback={<text fg={theme.error}>[plugin render error]</text>}>
304+
<Show when={Renderer} fallback={<text fg={theme.error}>[unknown: {props.node.type}]</text>}>
305+
<Dynamic component={Renderer} node={props.node} metadata={props.metadata} />
306+
</Show>
307+
</ErrorBoundary>
308+
)
309+
}

0 commit comments

Comments
 (0)