feat(tui): restore plugin manager dialog

This commit is contained in:
Dax Raad 2026-07-28 21:18:40 -04:00
commit 06290907a9
18 changed files with 148 additions and 1451 deletions

View file

@ -1,40 +0,0 @@
import type { TuiRouteDefinition } from "@opencode-ai/plugin/v1/tui"
import { createSignal } from "solid-js"
type RouteEntry = {
key: symbol
render: TuiRouteDefinition["render"]
}
export type RouteMap = Map<string, RouteEntry[]>
export function createPluginRoutes() {
const routes: RouteMap = new Map()
const [revision, setRevision] = createSignal(0)
return {
register(list: TuiRouteDefinition[]) {
const key = Symbol()
list.forEach((item) => routes.set(item.name, [...(routes.get(item.name) ?? []), { key, render: item.render }]))
setRevision((value) => value + 1)
return () => {
list.forEach((item) => {
const next = routes.get(item.name)?.filter((entry) => entry.key !== key) ?? []
if (next.length) {
routes.set(item.name, next)
return
}
routes.delete(item.name)
})
setRevision((value) => value + 1)
}
},
get(name: string) {
revision()
return routes.get(name)?.at(-1)?.render
},
}
}
export type PluginRoutes = ReturnType<typeof createPluginRoutes>

View file

@ -5,6 +5,7 @@ import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
import DiffViewer from "../feature-plugins/system/diff-viewer"
import Notifications from "../feature-plugins/system/notifications"
import Plugins from "../feature-plugins/system/plugins"
import Scrap from "../feature-plugins/system/scrap"
export const builtins = [
@ -14,6 +15,7 @@ export const builtins = [
SidebarLsp,
SidebarFooter,
Notifications,
Plugins,
Scrap,
DiffViewer,
]

View file

@ -1,108 +0,0 @@
// Legacy `api.command` bridge for v1 plugins; remove in v2.
import type { TuiCommand, TuiPluginApi } from "@opencode-ai/plugin/v1/tui"
import { TuiKeybind } from "../config/keybind"
import type { DialogContext } from "../ui/dialog"
const COMMAND_PALETTE_SHOW = "command.palette.show"
const warned = new Set<string>()
type Warn = (api: string, replacement: string) => void
type LegacyDialog = TuiPluginApi["ui"]["dialog"]
type CommandShimDialog = DialogContext | LegacyDialog
type LegacyKeybinds = TuiPluginApi["tuiConfig"]["keybinds"]
function warnCommandShim(api: string, replacement: string) {
// Warn v1 plugins about deprecated `api.command`; remove this shim path in v2.
console.warn("[tui.plugin] deprecated TUI plugin API", { api, replacement })
}
function createCommandShimDialog(dialog: CommandShimDialog): LegacyDialog {
if (!("stack" in dialog)) return dialog
return {
replace(render, onClose) {
dialog.replace(render, onClose)
},
clear() {
dialog.clear()
},
setSize(size) {
dialog.setSize(size)
},
get size() {
return dialog.size
},
get depth() {
return dialog.stack.length
},
get open() {
return dialog.stack.length > 0
},
}
}
function warnOnce(api: string, replacement: string, warn: Warn) {
if (warned.has(api)) return
warned.add(api)
warn(api, replacement)
}
function toCommand(item: TuiCommand, dialog: LegacyDialog) {
return {
namespace: "palette",
name: item.value,
title: item.title,
desc: item.description,
category: item.category,
suggested: item.suggested,
hidden: item.hidden,
enabled: item.enabled,
slash: item.slash,
run() {
return item.onSelect?.(dialog)
},
}
}
function toBindings(commands: TuiCommand[], keybinds: LegacyKeybinds) {
return commands.flatMap((item) =>
item.keybind
? keybinds.has(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
? keybinds
.get(TuiKeybind.CommandMap[item.keybind as keyof typeof TuiKeybind.CommandMap] ?? item.keybind)
.map((binding) => ({ ...binding, cmd: item.value, desc: binding.desc ?? item.title }))
: [
{
key: item.keybind,
cmd: item.value,
desc: item.title,
},
]
: [],
)
}
export function createCommandShim(
keymap: TuiPluginApi["keymap"],
dialog: CommandShimDialog,
keybinds: LegacyKeybinds,
): TuiPluginApi["command"] {
const shimDialog = createCommandShimDialog(dialog)
return {
register(cb) {
warnOnce("api.command.register", "api.keymap.registerLayer({ commands, bindings })", warnCommandShim)
const commands = cb()
return keymap.registerLayer({
commands: commands.map((item) => toCommand(item, shimDialog)),
bindings: toBindings(commands, keybinds),
})
},
trigger(value) {
warnOnce("api.command.trigger", "api.keymap.dispatchCommand(name)", warnCommandShim)
keymap.dispatchCommand(value)
},
show() {
warnOnce("api.command.show", `api.keymap.dispatchCommand("${COMMAND_PALETTE_SHOW}")`, warnCommandShim)
keymap.dispatchCommand(COMMAND_PALETTE_SHOW)
},
}
}

View file

@ -48,9 +48,16 @@ type State =
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly status: "failed"; readonly error: string }
type RegisteredPlugin = {
readonly id: string
readonly source: "builtin" | "external"
readonly active: boolean
}
type Value = {
readonly ready: () => boolean
readonly list: () => ReadonlyArray<State>
readonly registered: () => ReadonlyArray<RegisteredPlugin>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: <Name extends SlotName>(name: Name) => ReadonlyArray<Slot<Name>>
readonly activate: (id: string) => Promise<boolean>
@ -59,8 +66,8 @@ type Value = {
type Dispose = () => Promise<void>
type Registration = {
target: string
plugin: Plugin.Definition
source: RegisteredPlugin["source"]
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
@ -107,28 +114,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
})
const owned: Dispose[] = []
let context: Context
let activeDialog: symbol | undefined
const dialogApi: Dialog = {
show(render, onClose) {
const token = Symbol()
let closed = false
activeDialog = token
dialog.replace(
() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>,
() => {
if (closed) return
closed = true
if (activeDialog === token) activeDialog = undefined
onClose?.()
},
)
return () => {
if (closed || activeDialog !== token) return
dialog.clear()
}
dialog.replace(() => <PluginContextProvider value={context}>{render()}</PluginContextProvider>, onClose)
},
set(options) {
if (!activeDialog) return
dialog.setSize(options.size ?? "medium")
dialog.setCentered(options.centered ?? false)
},
@ -224,7 +214,6 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
toast.show({ ...options, variant: options.variant ?? "info" })
},
}
owned.push(async () => dialogApi.clear())
context = {
options: item.options ?? {},
get location() {
@ -365,8 +354,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
for (const plugin of builtins) {
setStore("registrations", plugin.id, {
target: plugin.id,
plugin,
source: "builtin",
active: false,
routes: {},
slots: {},
@ -410,23 +399,24 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
continue
}
const item = { target, plugin, options }
setStore("registrations", item.plugin.id, {
...item,
setStore("registrations", plugin.id, {
plugin,
source: "external",
options,
active: false,
routes: {},
slots: {},
cleanups: [],
})
const error = await activate(item.plugin.id).then(
const error = await activate(plugin.id).then(
() => undefined,
(error) => (error instanceof Error ? error.message : String(error)),
)
setStore("states", (items) => [
...items.filter((state) => state.target !== item.target && (!("id" in state) || state.id !== item.plugin.id)),
...items.filter((state) => state.target !== target && (!("id" in state) || state.id !== plugin.id)),
error
? { target: item.target, status: "failed", error }
: { target: item.target, id: item.plugin.id, status: "active" },
? { target, status: "failed", error }
: { target, id: plugin.id, status: "active" },
])
}
}
@ -460,6 +450,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
value={{
ready: () => store.ready,
list: () => store.states,
registered: () =>
Object.entries(store.registrations).map(([id, plugin]) => ({ id, source: plugin.source, active: plugin.active })),
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slot: (name) =>
Object.values(store.registrations).flatMap((registration) =>

View file

@ -1,79 +0,0 @@
import type {
TuiPluginApi,
TuiPluginInstallOptions,
TuiPluginInstallResult,
TuiPluginStatus,
} from "@opencode-ai/plugin/v1/tui"
import { createContext, createSignal, useContext, type JSX, type ParentProps } from "solid-js"
import { createPluginRoutes } from "./api"
import { createSlots, type HostSlots } from "./slots"
export function createPluginRuntime() {
const [commands, setCommands] = createSignal<PluginRuntimeCommands>(emptyCommands)
const [status, setStatus] = createSignal<ReadonlyArray<TuiPluginStatus>>([])
const slots = createSlots()
return {
Slot: slots.Slot,
routes: createPluginRoutes(),
commands,
status,
update(input: { commands?: PluginRuntimeCommands; status?: ReadonlyArray<TuiPluginStatus> }) {
if (input.commands) setCommands(input.commands)
if (input.status) setStatus(input.status)
},
clear() {
setCommands(emptyCommands)
setStatus([])
slots.clear()
},
setupSlots(api: TuiPluginApi): HostSlots {
return slots.setup(api)
},
}
}
export type PluginRuntimeCommands = {
activate: (id: string) => Promise<boolean>
deactivate: (id: string) => Promise<boolean>
add: (spec: string) => Promise<boolean>
install: (spec: string, options?: TuiPluginInstallOptions) => Promise<TuiPluginInstallResult>
}
const emptyCommands: PluginRuntimeCommands = {
async activate() {
return false
},
async deactivate() {
return false
},
async add() {
return false
},
async install() {
return { ok: false, message: "Plugin runtime is not available." }
},
}
export type PluginRuntime = ReturnType<typeof createPluginRuntime>
export type TuiPluginHost = {
start(input: {
api: TuiPluginApi
runtime: PluginRuntime
dispose?: () => void
}): Promise<void>
dispose(): Promise<void>
}
const Context = createContext<PluginRuntime>()
export function PluginRuntimeProvider(props: ParentProps<{ value: PluginRuntime }>): JSX.Element {
return <Context.Provider value={props.value}>{props.children}</Context.Provider>
}
export function usePluginRuntime() {
const runtime = useContext(Context)
if (!runtime) throw new Error("usePluginRuntime must be used within PluginRuntimeProvider")
return runtime
}

View file

@ -1,65 +0,0 @@
import type { TuiPluginApi, TuiSlotContext, TuiSlotMap, TuiSlotProps } from "@opencode-ai/plugin/v1/tui"
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
import { createSignal } from "solid-js"
import { isRecord } from "../util/record"
type RuntimeSlotMap = TuiSlotMap<Record<string, object>>
type SlotView = <Name extends string>(props: TuiSlotProps<Name>) => JSX.Element | null
export type HostSlotPlugin<Slots extends Record<string, object> = {}> = SolidPlugin<TuiSlotMap<Slots>, TuiSlotContext>
export type HostPluginApi = TuiPluginApi
export type HostSlots = {
register: {
(plugin: HostSlotPlugin): () => void
<Slots extends Record<string, object>>(plugin: HostSlotPlugin<Slots>): () => void
}
dispose: () => void
}
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string, object>> {
if (!isRecord(value)) return false
if (typeof value.id !== "string") return false
return isRecord(value.slots)
}
export function createSlots() {
const empty: SlotView = (props) => props.children ?? null
const [view, setView] = createSignal<SlotView>(empty)
const Slot: SlotView = (props) => view()(props)
return {
Slot,
setup(api: HostPluginApi): HostSlots {
const registry = createSolidSlotRegistry<RuntimeSlotMap, TuiSlotContext>(
api.renderer,
{ theme: api.theme },
{
onPluginError(event) {
console.error("[tui.slot] plugin error", {
plugin: event.pluginId,
slot: event.slot,
phase: event.phase,
source: event.source,
message: event.error.message,
})
},
},
)
const slot = createSlot<RuntimeSlotMap, TuiSlotContext>(registry)
setView(() => (props: TuiSlotProps<string>) => slot(props))
return {
register(plugin: HostSlotPlugin) {
if (!isHostSlotPlugin(plugin)) return () => {}
return registry.register(plugin)
},
dispose() {
setView(() => empty)
},
}
},
clear() {
setView(() => empty)
},
}
}