|
| 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