diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index f63f6cb1a8a..a8216d5d368 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -18,6 +18,7 @@ import { DialogThemeList } from "@tui/component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command" import { DialogAgent } from "@tui/component/dialog-agent" +import { pluginModalRequest, setPluginModalRequest, PluginUIRenderer } from "@tui/plugin-ui" import { DialogSessionList } from "@tui/component/dialog-session-list" import { KeybindProvider } from "@tui/context/keybind" import { ThemeProvider, useTheme } from "@tui/context/theme" @@ -33,6 +34,7 @@ import { KVProvider, useKV } from "./context/kv" import { Provider } from "@/provider/provider" import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" +import { PluginRegistry } from "./plugin-ui.tsx" import { PromptRefProvider, usePromptRef } from "./context/prompt" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { @@ -99,6 +101,8 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise(async (resolve) => { const mode = await getTerminalBackgroundColor() + await PluginRegistry.load(input.url) + const onExit = async () => { await input.onExit?.() resolve() @@ -205,6 +209,19 @@ function App() { }) const args = useArgs() + + createEffect(() => { + const request = pluginModalRequest() + if (request) { + dialog.replace( + + + , + ) + setPluginModalRequest(null) + } + }) + onMount(() => { batch(() => { if (args.agent) local.agent.set(args.agent) diff --git a/packages/opencode/src/cli/cmd/tui/plugin-ui.tsx b/packages/opencode/src/cli/cmd/tui/plugin-ui.tsx new file mode 100644 index 00000000000..06c0b2ee044 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin-ui.tsx @@ -0,0 +1,309 @@ +import { createSignal, ErrorBoundary, For, Show } from "solid-js" +import { Dynamic } from "solid-js/web" +import { useTheme } from "@tui/context/theme" +import { useKeyboard } from "@opentui/solid" +import type { + PluginUINode, + PluginUIComponent, + TextNode, + BoxNode, + ChecklistNode, + ButtonNode, + CollapsibleNode, +} from "@opencode-ai/plugin" +import { Log } from "@/util/log" + +const log = Log.create({ service: "plugin-ui" }) + +// Signal to trigger re-renders when dismissed state changes +const [dismissed, setDismissed] = createSignal(new Set()) + +// Global signal for plugin modals (used when DialogProvider context isn't available) +export const [pluginModalRequest, setPluginModalRequest] = createSignal<{ + node: PluginUINode + metadata: Record +} | null>(null) + +// Plugin UI Registry - stores templates fetched from plugins +export const PluginRegistry = { + templates: new Map(), + baseUrl: "", + register(name: string, template: PluginUINode, replaceInput?: boolean) { + this.templates.set(name, { node: template, replaceInput: replaceInput ?? false }) + }, + get(name: string) { + const entry = this.templates.get(name) + if (!entry) log.warn("template not found", { name }) + return entry?.node + }, + shouldReplaceInput(name: string) { + return this.templates.get(name)?.replaceInput ?? false + }, + dismiss(componentKey: string) { + setDismissed((prev) => { + const next = new Set(prev) + next.add(componentKey) + return next + }) + }, + isDismissed(componentKey: string) { + return dismissed().has(componentKey) + }, + async emit(component: string, event: string, data: Record) { + try { + await fetch(`${this.baseUrl}/plugins/ui/event`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ component, event, data }), + }) + } catch (e) { + log.error("emit failed", { error: e }) + } + }, + + async load(baseUrl: string) { + this.baseUrl = baseUrl + try { + const res = await fetch(`${baseUrl}/plugins/ui`) + if (!res.ok) throw new Error(`status ${res.status}`) + const components: PluginUIComponent[] = await res.json() + log.info("loaded", { count: components.length }) + for (const c of components) this.register(c.name, c.template, c.replaceInput) + } catch (e) { + log.error("load failed", { error: e }) + } + }, +} + +// Interpolate {{key}} placeholders with metadata values +function interpolate(text: string, metadata: Record): string { + return text.replace(/\{\{(\w+)\}\}/g, (_, key) => String(metadata[key] ?? "")) +} + +// Helper to resolve theme colors - returns both getColor and theme +function useColors() { + const { theme } = useTheme() + const getColor = (color?: string) => { + if (!color) return undefined + return (theme as any)[color] ?? color + } + return { getColor, theme } +} + +type RendererProps = { node: T; metadata: Record } + +function PluginText(props: RendererProps) { + const { getColor, theme } = useColors() + const content = () => interpolate(props.node.content, props.metadata) + return ( + {props.node.bold ? {content()} : content()} + ) +} + +function PluginBox(props: RendererProps) { + const { getColor, theme } = useColors() + const n = props.node + const hasBorder = n.border !== undefined + return ( + + {(child) => } + + ) +} + +function PluginChecklist(props: RendererProps) { + const { getColor, theme } = useColors() + + const rawItems = () => + typeof props.node.items === "string" + ? ((props.metadata[props.node.items] as Array<{ id: string; label: string; checked?: boolean }>) ?? []) + : (props.node.items as Array<{ id: string; label: string; checked?: boolean }>) + + const [items, setItems] = createSignal(rawItems().map((i) => ({ ...i, checked: i.checked ?? false }))) + const [focusedIndex, setFocusedIndex] = createSignal(0) + + const toggleItem = (index: number) => { + const item = items()[index] + if (!item) return + const updatedItems = items().map((i, idx) => (idx === index ? { ...i, checked: !i.checked } : i)) + setItems(updatedItems) + if (props.node.onToggle && props.metadata._component) { + PluginRegistry.emit(props.metadata._component, props.node.onToggle, { + id: item.id, + checked: !item.checked, + items: updatedItems, + }) + } + } + + useKeyboard((evt) => { + if (evt.name === "up" || evt.name === "k") { + setFocusedIndex((i) => Math.max(0, i - 1)) + return true + } + if (evt.name === "down" || evt.name === "j") { + setFocusedIndex((i) => Math.min(items().length - 1, i + 1)) + return true + } + if (evt.name === "space") { + toggleItem(focusedIndex()) + return true + } + return false + }) + + return ( + + + {(item, index) => ( + setFocusedIndex(index())} + onMouseDown={() => toggleItem(index())} + > + + + + + + + )} + + + ) +} + +function PluginButton(props: RendererProps) { + const { getColor, theme } = useColors() + const [hovered, setHovered] = createSignal(false) + const [clicked, setClicked] = createSignal(false) + + const handleActivate = () => { + if (clicked()) return + setClicked(true) + if (props.node.onModal) { + setPluginModalRequest({ node: props.node.onModal, metadata: props.metadata }) + } + if (props.node.onPress && props.metadata._component) { + PluginRegistry.emit(props.metadata._component, props.node.onPress, {}) + if (props.metadata._partId) { + setTimeout(() => PluginRegistry.dismiss(props.metadata._partId!), 0) + } + } + } + + useKeyboard((evt) => { + if (clicked()) return false + if (props.node.shortcut && evt.name === props.node.shortcut.toLowerCase()) { + handleActivate() + return true + } + return false + }) + + const isActive = () => clicked() || hovered() + const hasBackground = props.node.bg !== undefined + + return ( + + + + + + + setHovered(true)} + onMouseOut={() => setHovered(false)} + onMouseDown={handleActivate} + > + + + + ) +} + +function PluginCollapsible(props: RendererProps) { + const { getColor, theme } = useColors() + const [expanded, setExpanded] = createSignal(props.node.expanded ?? false) + const title = () => interpolate(props.node.title, props.metadata) + const fg = () => + expanded() ? (getColor(props.node.fgExpanded) ?? theme.text) : (getColor(props.node.fg) ?? theme.textMuted) + + return ( + + setExpanded((e) => !e)}> + + + + + + {(child) => } + + + + ) +} + +// Component map for dynamic rendering +const RENDERERS: Record) => any> = { + text: PluginText, + box: PluginBox, + checklist: PluginChecklist, + button: PluginButton, + collapsible: PluginCollapsible, +} + +// Render a plugin UI template +export function PluginUIRenderer(props: { node: PluginUINode; metadata: Record }) { + const { theme } = useColors() + const Renderer = RENDERERS[props.node.type] + return ( + [plugin render error]}> + [unknown: {props.node.type}]}> + + + + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 826fa2acf8e..e64e830ae2d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -68,6 +68,7 @@ import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { Filesystem } from "@/util/filesystem" import { DialogSubagent } from "./dialog-subagent.tsx" +import { PluginRegistry, PluginUIRenderer } from "../../plugin-ui" addDefaultParsers(parsers.parsers) @@ -110,6 +111,22 @@ export function Session() { const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? []) + // Find plugin component that wants to replace the input (and isn't dismissed) + const replaceInputPlugin = createMemo(() => { + for (const msg of messages()) { + const parts = sync.data.part[msg.id] ?? [] + for (const part of parts) { + if (part.type === "text" && part.plugin) { + const componentName = part.text.trim() + if (PluginRegistry.shouldReplaceInput(componentName) && !PluginRegistry.isDismissed(part.id)) { + return { component: componentName, partId: part.id, metadata: part.metadata } + } + } + } + } + return null + }) + const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id }) @@ -670,7 +687,7 @@ export function Session() { if (!parts || !Array.isArray(parts)) continue const hasValidTextPart = parts.some( - (part) => part && part.type === "text" && !part.synthetic && !part.ignored, + (part) => part && part.type === "text" && !part.synthetic && !part.ignored && !part.plugin, ) if (hasValidTextPart) { @@ -1082,17 +1099,30 @@ export function Session() { - { - prompt = r - promptRef.set(r) - }} - disabled={permissions().length > 0} - onSubmit={() => { - toBottom() + + { + prompt = r + promptRef.set(r) + }} + disabled={permissions().length > 0} + onSubmit={() => { + toBottom() + }} + sessionID={route.sessionID} + /> + + + {(plugin) => { + const template = PluginRegistry.get(plugin().component) + return template ? ( + + ) : null }} - sessionID={route.sessionID} - /> +