feat(tui): add v2 plugin runtime

This commit is contained in:
Dax Raad 2026-07-14 12:43:53 -04:00
commit 4a93972a78
63 changed files with 1722 additions and 1701 deletions

View file

@ -1,356 +0,0 @@
import type { TuiDialogSelectOption, TuiPluginApi, TuiSlotProps } from "@opencode-ai/plugin/tui"
import type { Config } from "../config"
import type { useEvent } from "../context/event"
import type { useRoute } from "../context/route"
import type { useClient } from "../context/client"
import type { useData } from "../context/data"
import type { useProject } from "../context/project"
import type { useTheme } from "../context/theme"
import { Dialog as DialogUI, type useDialog } from "../ui/dialog"
import type { useOpencodeKeymap } from "../keymap"
import { DialogAlert } from "../ui/dialog-alert"
import { DialogConfirm } from "../ui/dialog-confirm"
import { DialogPrompt } from "../ui/dialog-prompt"
import { DialogSelect, type DialogSelectOption as SelectOption } from "../ui/dialog-select"
import { Prompt } from "../component/prompt"
import type { useToast } from "../ui/toast"
import * as Keymap from "../keymap"
import { createCommandShim } from "./command-shim"
import type { PluginRoutes } from "./api"
export type { RouteMap } from "./api"
export { createPluginRoutes, createTuiApi } from "./api"
type Input = {
version: string
tuiConfig: Config.Resolved
dialog: ReturnType<typeof useDialog>
keymap: ReturnType<typeof useOpencodeKeymap>
route: ReturnType<typeof useRoute>
routes: PluginRoutes
event: ReturnType<typeof useEvent>
client: ReturnType<typeof useClient>
project: ReturnType<typeof useProject>
data: ReturnType<typeof useData>
theme: ReturnType<typeof useTheme>
toast: ReturnType<typeof useToast>
renderer: TuiPluginApi["renderer"]
attention: TuiPluginApi["attention"]
Slot: TuiPluginApi["ui"]["Slot"]
}
function routeNavigate(route: ReturnType<typeof useRoute>, name: string, params?: Record<string, unknown>) {
if (name === "home") {
route.navigate({ type: "home" })
return
}
if (name === "session") {
const sessionID = params?.sessionID
if (typeof sessionID !== "string") return
route.navigate({ type: "session", sessionID })
return
}
route.navigate({ type: "plugin", id: name, data: params })
}
function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]["current"] {
if (route.data.type === "home") return { name: "home" }
if (route.data.type === "session") {
return {
name: "session",
params: {
sessionID: route.data.sessionID,
prompt: route.data.prompt,
},
}
}
return {
name: route.data.id,
params: route.data.data,
}
}
function mapOption<Value>(item: TuiDialogSelectOption<Value>): SelectOption<Value> {
return {
...item,
onSelect: () => item.onSelect?.(),
}
}
function pickOption<Value>(item: SelectOption<Value>): TuiDialogSelectOption<Value> {
return {
title: item.title,
value: item.value,
description: item.description,
footer: item.footer,
category: item.category,
disabled: item.disabled,
}
}
function mapOptionCb<Value>(cb?: (item: TuiDialogSelectOption<Value>) => void) {
if (!cb) return
return (item: SelectOption<Value>) => cb(pickOption(item))
}
function stateApi(project: ReturnType<typeof useProject>, data: ReturnType<typeof useData>): TuiPluginApi["state"] {
return {
get ready() {
return true
},
get config() {
return {}
},
get provider() {
return []
},
get path() {
return project.instance.path()
},
get vcs() {
return undefined
},
session: {
count() {
return data.session.list().length
},
get(_sessionID) {
return undefined
},
diff(_sessionID) {
return []
},
messages(_sessionID) {
return []
},
status(sessionID) {
return data.session.status(sessionID) === "running" ? { type: "busy" } : { type: "idle" }
},
permission(_sessionID) {
return []
},
question(_sessionID) {
return []
},
},
part(_messageID) {
return []
},
lsp() {
return []
},
mcp() {
return (data.location.mcp.server.list() ?? [])
.toSorted((a, b) => a.name.localeCompare(b.name))
.flatMap((item) =>
item.status.status === "pending"
? []
: [
{
name: item.name,
status: item.status.status,
error: item.status.status === "failed" ? item.status.error : undefined,
},
],
)
},
}
}
function appApi(version: string): TuiPluginApi["app"] {
return {
get version() {
return version
},
}
}
const unsupportedClient = new Proxy(
{},
{
get() {
throw new Error("The legacy plugin client is not supported in V2")
},
},
) as TuiPluginApi["client"]
export function createTuiApiAdapters(input: Input): Omit<TuiPluginApi, "lifecycle"> {
return {
app: appApi(input.version),
attention: input.attention,
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
keys: {
formatSequence(parts) {
return Keymap.formatKeySequence(parts, input.tuiConfig)
},
formatBindings(bindings) {
return Keymap.formatKeyBindings(bindings, input.tuiConfig)
},
},
keymap: input.keymap,
mode: {
current() {
return Keymap.getOpencodeModeStack(input.keymap).current()
},
push(mode) {
return Keymap.getOpencodeModeStack(input.keymap).push(mode)
},
},
route: {
register(list) {
return input.routes.register(list)
},
navigate(name, params) {
routeNavigate(input.route, name, params)
},
get current() {
return routeCurrent(input.route)
},
},
ui: {
Dialog(props) {
return (
<DialogUI size={props.size} onClose={props.onClose}>
{props.children}
</DialogUI>
)
},
DialogAlert(props) {
return <DialogAlert {...props} />
},
DialogConfirm(props) {
return <DialogConfirm {...props} />
},
DialogPrompt(props) {
return <DialogPrompt {...props} description={props.description} />
},
DialogSelect(props) {
return (
<DialogSelect
title={props.title}
placeholder={props.placeholder}
options={props.options.map(mapOption)}
flat={props.flat}
onMove={mapOptionCb(props.onMove)}
onFilter={props.onFilter}
onSelect={mapOptionCb(props.onSelect)}
skipFilter={props.skipFilter}
current={props.current}
/>
)
},
Slot<Name extends string>(props: TuiSlotProps<Name>) {
return <input.Slot {...props} />
},
Prompt(props) {
return (
<Prompt
sessionID={props.sessionID}
visible={props.visible}
disabled={props.disabled}
onSubmit={props.onSubmit}
ref={props.ref}
hint={props.hint}
right={props.right}
showPlaceholder={props.showPlaceholder}
placeholders={props.placeholders}
/>
)
},
toast(inputToast) {
input.toast.show({
title: inputToast.title,
message: inputToast.message,
variant: inputToast.variant ?? "info",
duration: inputToast.duration,
})
},
dialog: {
replace(render, onClose) {
input.dialog.replace(render, onClose)
},
clear() {
input.dialog.clear()
},
setSize(size) {
input.dialog.setSize(size)
},
get size() {
return input.dialog.size
},
get depth() {
return input.dialog.stack.length
},
get open() {
return input.dialog.stack.length > 0
},
},
},
get tuiConfig() {
return input.tuiConfig
},
kv: {
get(_key, fallback) {
if (fallback === undefined) throw new Error("Persistent TUI KV storage is not supported")
return fallback
},
set() {},
ready: true,
},
state: stateApi(input.project, input.data),
client: unsupportedClient,
event: input.event,
renderer: input.renderer,
slots: {
register() {
throw new Error("slots.register is only available in plugin context")
},
},
plugins: {
list() {
return []
},
async activate() {
return false
},
async deactivate() {
return false
},
async add() {
return false
},
async install() {
return {
ok: false,
message: "plugins.install is only available in plugin context",
}
},
},
theme: {
get current() {
return input.theme.theme
},
get selected() {
return input.theme.selected
},
has(name) {
return input.theme.has(name)
},
set(name) {
return input.theme.set(name)
},
async install(_jsonPath) {
throw new Error("theme.install is only available in plugin context")
},
mode() {
return input.theme.mode()
},
get ready() {
return input.theme.ready
},
},
}
}

View file

@ -1,4 +1,4 @@
import type { TuiPluginApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import type { TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import { createSignal } from "solid-js"
type RouteEntry = {
@ -38,15 +38,3 @@ export function createPluginRoutes() {
}
export type PluginRoutes = ReturnType<typeof createPluginRoutes>
export function createTuiApi(input: Omit<TuiPluginApi, "lifecycle">): TuiPluginApi {
return {
...input,
lifecycle: {
signal: new AbortController().signal,
onDispose() {
return () => {}
},
},
}
}

View file

@ -0,0 +1,9 @@
import HomeFooter from "../feature-plugins/home/footer"
import HomeTips from "../feature-plugins/home/tips"
import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarFooter from "../feature-plugins/sidebar/footer"
import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
import Scrap from "../feature-plugins/system/scrap"
export const builtins = [HomeFooter, HomeTips, SidebarContext, SidebarMcp, SidebarLsp, SidebarFooter, Scrap]

View file

@ -56,8 +56,7 @@ function toCommand(item: TuiCommand, dialog: LegacyDialog) {
suggested: item.suggested,
hidden: item.hidden,
enabled: item.enabled,
slashName: item.slash?.name,
slashAliases: item.slash?.aliases,
slash: item.slash,
run() {
return item.onSelect?.(dialog)
},

View file

@ -0,0 +1,383 @@
import type { Plugin } from "@opencode-ai/plugin/v2/tui"
import {
batch,
createContext,
createMemo,
For,
onCleanup,
onMount,
useContext,
type JSX,
type ParentProps,
} from "solid-js"
import path from "path"
import { stat } from "fs/promises"
import { fileURLToPath, pathToFileURL } from "url"
import type { Context, Page, Slot } from "@opencode-ai/plugin/v2/tui/context"
import { createStore, produce, reconcile as reconcileStore } from "solid-js/store"
import { useConfig } from "../config"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { useRoute } from "../context/route"
import { useTuiLifecycle } from "../context/runtime"
import { builtins } from "./builtins"
export interface PackageResolver {
readonly resolve: (spec: string) => Promise<string | undefined>
}
type State =
| { readonly target: string; readonly status: "loading" }
| { readonly target: string; readonly id: string; readonly status: "active" | "inactive" }
| { readonly target: string; readonly status: "unsupported" }
| { readonly target: string; readonly status: "failed"; readonly error: string }
type Value = {
readonly ready: () => boolean
readonly list: () => ReadonlyArray<State>
readonly route: (id: string, name: string) => Page["render"] | undefined
readonly slot: (name: string) => ReadonlyArray<Slot>
readonly activate: (id: string) => Promise<boolean>
readonly deactivate: (id: string) => Promise<boolean>
}
type Dispose = () => Promise<void>
type Registration = {
target: string
plugin: Plugin.Definition
options?: Readonly<Record<string, any>>
active: boolean
routes: Record<string, Page>
slots: Record<string, Slot>
cleanups: Dispose[]
}
const PluginContext = createContext<Value>()
export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>) {
const client = useClient()
const data = useData()
const route = useRoute()
const config = useConfig()
const lifecycle = useTuiLifecycle()
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
ready: false,
states: [] as ReadonlyArray<State>,
registrations: {} as Record<string, Registration>,
})
const activate = async (id: string) => {
const item = store.registrations[id]
if (!item) return false
await deactivate(id)
batch(() => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
setStore("registrations", id, "cleanups", [])
})
const owned: Dispose[] = []
const context: Context = {
options: item.options ?? {},
client: client.api,
data,
ui: {
router: {
register(page) {
if (store.registrations[item.plugin.id]?.routes[page.name])
throw new Error(`Route already registered: ${page.name}`)
setStore("registrations", item.plugin.id, "routes", page.name, page)
let registered = true
const unregister = () => {
if (!registered) return
registered = false
if (!store.registrations[item.plugin.id]?.active) return
setStore(
"registrations",
produce((registrations) => {
if (!registrations[item.plugin.id]) return
delete registrations[item.plugin.id].routes[page.name]
}),
)
}
owned.push(async () => unregister())
return unregister
},
navigate(destination) {
if (destination.type === "plugin") {
route.navigate({ ...destination, id: "id" in destination ? destination.id : item.plugin.id })
return
}
route.navigate(destination)
},
current() {
return route.data
},
},
slot(name, render) {
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
setStore("registrations", item.plugin.id, "slots", name, () => render)
let registered = true
const unregister = () => {
if (!registered) return
registered = false
if (!store.registrations[item.plugin.id]?.active) return
setStore(
"registrations",
produce((registrations) => {
if (!registrations[item.plugin.id]) return
delete registrations[item.plugin.id].slots[name]
}),
)
}
owned.push(async () => unregister())
return unregister
},
},
}
const cleanup = await setup(item.plugin, context, owned).catch((error) => {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
throw error
})
if (cleanup) owned.push(async () => cleanup())
batch(() => {
setStore("registrations", id, "cleanups", owned)
setStore("registrations", id, "active", true)
setStore("states", (items) =>
items.map((state) =>
"id" in state && state.id === id ? { target: state.target, id, status: "active" } : state,
),
)
})
return true
}
const deactivate = async (id: string) => {
const item = store.registrations[id]
if (!item?.active) return false
const cleanups = [...item.cleanups]
batch(() => {
setStore("registrations", id, "active", false)
setStore("registrations", id, "cleanups", [])
})
await disposeAll(cleanups).finally(() =>
batch(() => {
if (store.registrations[id]) {
setStore("registrations", id, "routes", reconcileStore({}))
setStore("registrations", id, "slots", reconcileStore({}))
}
setStore("states", (items) =>
items.map((state) =>
"id" in state && state.id === id ? { target: state.target, id, status: "inactive" } : state,
),
)
}),
)
return true
}
const reconcile = async () => {
await Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
)
const entries = config.data.plugins ?? []
batch(() => {
setStore("registrations", reconcileStore({}))
setStore("states", [])
})
for (const plugin of builtins) {
setStore("registrations", plugin.id, {
target: plugin.id,
plugin,
active: false,
routes: {},
slots: {},
cleanups: [],
})
await activate(plugin.id)
}
for (const entry of entries) {
const target = typeof entry === "string" ? entry : entry.package
if (target.startsWith("-")) {
for (const id of Object.keys(store.registrations).filter((id) => matches(target.slice(1), id)))
await deactivate(id)
continue
}
const selected = Object.keys(store.registrations).filter((id) => matches(target, id))
if (selected.length || target === "*" || target.endsWith(".*") || target.startsWith("opencode.")) {
for (const id of selected) await activate(id)
continue
}
const options = typeof entry === "string" ? undefined : entry.options
setStore("states", (items) => [...items, { target, status: "loading" }])
const plugin = await loadPlugin(target, directory, props.packages).catch((error) => {
setStore("states", (items) =>
items.map((state) =>
state.target === target
? { target, status: "failed", error: error instanceof Error ? error.message : String(error) }
: state,
),
)
return undefined
})
if (!plugin) {
setStore("states", (items) =>
items.map((state) =>
state.target === target && state.status !== "failed" ? { target, status: "unsupported" } : state,
),
)
continue
}
const item = { target, plugin, options }
setStore("registrations", item.plugin.id, {
...item,
active: false,
routes: {},
slots: {},
cleanups: [],
})
const error = await activate(item.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)),
error
? { target: item.target, status: "failed", error }
: { target: item.target, id: item.plugin.id, status: "active" },
])
}
}
onMount(() => {
const loading = reconcile()
let disposing: Promise<void> | undefined
const dispose = () => {
if (disposing) return disposing
disposing = loading
.catch(() => undefined)
.then(() =>
Promise.all(
Object.entries(store.registrations)
.filter(([, registration]) => registration.active)
.map(([id]) => deactivate(id)),
),
)
.then(() => setStore("registrations", reconcileStore({})))
return disposing
}
const unregister = lifecycle.add(dispose)
onCleanup(() => {
unregister()
void dispose()
})
void loading.finally(() => setStore("ready", true))
})
return (
<PluginContext.Provider
value={{
ready: () => store.ready,
list: () => store.states,
route: (id, name) => store.registrations[id]?.routes[name]?.render,
slot: (name) =>
Object.values(store.registrations).flatMap((registration) =>
registration.active && registration.slots[name] ? [registration.slots[name]] : [],
),
activate,
deactivate,
}}
>
{props.children}
</PluginContext.Provider>
)
}
async function disposeAll(cleanups: Dispose[]) {
const failures: unknown[] = []
for (const cleanup of cleanups.splice(0).reverse()) await cleanup().catch((error) => failures.push(error))
if (failures.length) throw failures[0]
}
async function setup(plugin: Plugin.Definition, context: Plugin.Context, owned: Dispose[]) {
try {
return await plugin.setup(context)
} catch (error) {
await disposeAll(owned).catch(() => undefined)
throw error
}
}
function matches(selector: string, id: string) {
return selector === "*" || selector === id || (selector.endsWith(".*") && id.startsWith(selector.slice(0, -1)))
}
async function loadPlugin(spec: string, directory: string, packages: PackageResolver) {
const local = spec.startsWith("file://")
? new URL(spec)
: spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)
? pathToFileURL(path.resolve(directory, spec))
: undefined
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
if (!entrypoint) return
const mod: { readonly default?: unknown } = await import(entrypoint)
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return mod.default
}
async function resolveLocal(url: URL) {
const info = await stat(url)
if (info.isFile()) return url.href
if (!info.isDirectory()) return
return resolve(pathToFileURL(path.join(fileURLToPath(url), "tui")).href)
}
function resolve(specifier: string) {
try {
return import.meta.resolve(specifier)
} catch {
return undefined
}
}
function isPlugin(value: unknown): value is Plugin.Definition {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof value.id === "string" &&
value.id.length > 0 &&
"setup" in value &&
typeof value.setup === "function"
)
}
export function usePlugin() {
const value = useContext(PluginContext)
if (!value) throw new Error("PluginProvider is missing")
return value
}
export function PluginRoute(props: { readonly fallback: (id: string, name: string) => JSX.Element }) {
const plugins = usePlugin()
const route = useRoute()
const content = createMemo(() => {
if (route.data.type !== "plugin") return
const render = plugins.route(route.data.id, route.data.name)
if (!render) return props.fallback(route.data.id, route.data.name)
return render({ data: route.data.data })
})
return <>{content()}</>
}
export function PluginSlot(props: { readonly name: string; readonly input?: Record<string, any> }) {
const plugins = usePlugin()
return <For each={plugins.slot(props.name)}>{(render) => render(props.input ?? {})}</For>
}

View file

@ -23,7 +23,7 @@ function isHostSlotPlugin(value: unknown): value is HostSlotPlugin<Record<string
}
export function createSlots() {
const empty: SlotView = () => null
const empty: SlotView = (props) => props.children ?? null
const [view, setView] = createSignal<SlotView>(empty)
const Slot: SlotView = (props) => view()(props)