tui plugins (#19347)
This commit is contained in:
@@ -0,0 +1,406 @@
|
||||
import type { ParsedKey } from "@opentui/core"
|
||||
import type { TuiDialogSelectOption, TuiPluginApi, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||
import type { useCommandDialog } from "@tui/component/dialog-command"
|
||||
import type { useKeybind } from "@tui/context/keybind"
|
||||
import type { useRoute } from "@tui/context/route"
|
||||
import type { useSDK } from "@tui/context/sdk"
|
||||
import type { useSync } from "@tui/context/sync"
|
||||
import type { useTheme } from "@tui/context/theme"
|
||||
import { Dialog as DialogUI, type useDialog } from "@tui/ui/dialog"
|
||||
import type { TuiConfig } from "@/config/tui"
|
||||
import { createPluginKeybind } from "../context/plugin-keybinds"
|
||||
import type { useKV } from "../context/kv"
|
||||
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 type { useToast } from "../ui/toast"
|
||||
import { Installation } from "@/installation"
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
|
||||
type RouteEntry = {
|
||||
key: symbol
|
||||
render: TuiRouteDefinition["render"]
|
||||
}
|
||||
|
||||
export type RouteMap = Map<string, RouteEntry[]>
|
||||
|
||||
type Input = {
|
||||
command: ReturnType<typeof useCommandDialog>
|
||||
tuiConfig: TuiConfig.Info
|
||||
dialog: ReturnType<typeof useDialog>
|
||||
keybind: ReturnType<typeof useKeybind>
|
||||
kv: ReturnType<typeof useKV>
|
||||
route: ReturnType<typeof useRoute>
|
||||
routes: RouteMap
|
||||
bump: () => void
|
||||
sdk: ReturnType<typeof useSDK>
|
||||
sync: ReturnType<typeof useSync>
|
||||
theme: ReturnType<typeof useTheme>
|
||||
toast: ReturnType<typeof useToast>
|
||||
renderer: TuiPluginApi["renderer"]
|
||||
}
|
||||
|
||||
type TuiHostPluginApi = TuiPluginApi & {
|
||||
map: Map<string | undefined, OpencodeClient>
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
|
||||
const key = Symbol()
|
||||
for (const item of list) {
|
||||
const prev = routes.get(item.name) ?? []
|
||||
prev.push({ key, render: item.render })
|
||||
routes.set(item.name, prev)
|
||||
}
|
||||
bump()
|
||||
|
||||
return () => {
|
||||
for (const item of list) {
|
||||
const prev = routes.get(item.name)
|
||||
if (!prev) continue
|
||||
const next = prev.filter((x) => x.key !== key)
|
||||
if (!next.length) {
|
||||
routes.delete(item.name)
|
||||
continue
|
||||
}
|
||||
routes.set(item.name, next)
|
||||
}
|
||||
bump()
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
initialPrompt: route.data.initialPrompt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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(sync: ReturnType<typeof useSync>): TuiPluginApi["state"] {
|
||||
return {
|
||||
get ready() {
|
||||
return sync.ready
|
||||
},
|
||||
get config() {
|
||||
return sync.data.config
|
||||
},
|
||||
get provider() {
|
||||
return sync.data.provider
|
||||
},
|
||||
get path() {
|
||||
return sync.data.path
|
||||
},
|
||||
get vcs() {
|
||||
if (!sync.data.vcs) return
|
||||
return {
|
||||
branch: sync.data.vcs.branch,
|
||||
}
|
||||
},
|
||||
workspace: {
|
||||
list() {
|
||||
return sync.data.workspaceList
|
||||
},
|
||||
get(workspaceID) {
|
||||
return sync.workspace.get(workspaceID)
|
||||
},
|
||||
},
|
||||
session: {
|
||||
count() {
|
||||
return sync.data.session.length
|
||||
},
|
||||
diff(sessionID) {
|
||||
return sync.data.session_diff[sessionID] ?? []
|
||||
},
|
||||
todo(sessionID) {
|
||||
return sync.data.todo[sessionID] ?? []
|
||||
},
|
||||
messages(sessionID) {
|
||||
return sync.data.message[sessionID] ?? []
|
||||
},
|
||||
status(sessionID) {
|
||||
return sync.data.session_status[sessionID]
|
||||
},
|
||||
permission(sessionID) {
|
||||
return sync.data.permission[sessionID] ?? []
|
||||
},
|
||||
question(sessionID) {
|
||||
return sync.data.question[sessionID] ?? []
|
||||
},
|
||||
},
|
||||
part(messageID) {
|
||||
return sync.data.part[messageID] ?? []
|
||||
},
|
||||
lsp() {
|
||||
return sync.data.lsp.map((item) => ({ id: item.id, root: item.root, status: item.status }))
|
||||
},
|
||||
mcp() {
|
||||
return Object.entries(sync.data.mcp)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, item]) => ({
|
||||
name,
|
||||
status: item.status,
|
||||
error: item.status === "failed" ? item.error : undefined,
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function appApi(): TuiPluginApi["app"] {
|
||||
return {
|
||||
get version() {
|
||||
return Installation.VERSION
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiApi(input: Input): TuiHostPluginApi {
|
||||
const map = new Map<string | undefined, OpencodeClient>()
|
||||
const scoped: TuiPluginApi["scopedClient"] = (workspaceID) => {
|
||||
const hit = map.get(workspaceID)
|
||||
if (hit) return hit
|
||||
|
||||
const next = createOpencodeClient({
|
||||
baseUrl: input.sdk.url,
|
||||
fetch: input.sdk.fetch,
|
||||
directory: input.sync.data.path.directory || input.sdk.directory,
|
||||
experimental_workspaceID: workspaceID,
|
||||
})
|
||||
map.set(workspaceID, next)
|
||||
return next
|
||||
}
|
||||
const workspace: TuiPluginApi["workspace"] = {
|
||||
current() {
|
||||
return input.sdk.workspaceID
|
||||
},
|
||||
set(workspaceID) {
|
||||
input.sdk.setWorkspace(workspaceID)
|
||||
},
|
||||
}
|
||||
const lifecycle: TuiPluginApi["lifecycle"] = {
|
||||
signal: new AbortController().signal,
|
||||
onDispose() {
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
app: appApi(),
|
||||
command: {
|
||||
register(cb) {
|
||||
return input.command.register(() => cb())
|
||||
},
|
||||
trigger(value) {
|
||||
input.command.trigger(value)
|
||||
},
|
||||
},
|
||||
route: {
|
||||
register(list) {
|
||||
return routeRegister(input.routes, list, input.bump)
|
||||
},
|
||||
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}
|
||||
/>
|
||||
)
|
||||
},
|
||||
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
|
||||
},
|
||||
},
|
||||
},
|
||||
keybind: {
|
||||
match(key, evt: ParsedKey) {
|
||||
return input.keybind.match(key, evt)
|
||||
},
|
||||
print(key) {
|
||||
return input.keybind.print(key)
|
||||
},
|
||||
create(defaults, overrides) {
|
||||
return createPluginKeybind(input.keybind, defaults, overrides)
|
||||
},
|
||||
},
|
||||
get tuiConfig() {
|
||||
return input.tuiConfig
|
||||
},
|
||||
kv: {
|
||||
get(key, fallback) {
|
||||
return input.kv.get(key, fallback)
|
||||
},
|
||||
set(key, value) {
|
||||
input.kv.set(key, value)
|
||||
},
|
||||
get ready() {
|
||||
return input.kv.ready
|
||||
},
|
||||
},
|
||||
state: stateApi(input.sync),
|
||||
get client() {
|
||||
return input.sdk.client
|
||||
},
|
||||
scopedClient: scoped,
|
||||
workspace,
|
||||
event: input.sdk.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",
|
||||
}
|
||||
},
|
||||
},
|
||||
lifecycle,
|
||||
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
|
||||
},
|
||||
},
|
||||
map,
|
||||
dispose() {
|
||||
map.clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { TuiPluginRuntime } from "./runtime"
|
||||
export { createTuiApi } from "./api"
|
||||
export type { RouteMap } from "./api"
|
||||
@@ -0,0 +1,25 @@
|
||||
import HomeTips from "../feature-plugins/home/tips"
|
||||
import SidebarContext from "../feature-plugins/sidebar/context"
|
||||
import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
import SidebarLsp from "../feature-plugins/sidebar/lsp"
|
||||
import SidebarTodo from "../feature-plugins/sidebar/todo"
|
||||
import SidebarFiles from "../feature-plugins/sidebar/files"
|
||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||
import PluginManager from "../feature-plugins/system/plugins"
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
|
||||
export type InternalTuiPlugin = TuiPluginModule & {
|
||||
id: string
|
||||
tui: TuiPlugin
|
||||
}
|
||||
|
||||
export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
||||
HomeTips,
|
||||
SidebarContext,
|
||||
SidebarMcp,
|
||||
SidebarLsp,
|
||||
SidebarTodo,
|
||||
SidebarFiles,
|
||||
SidebarFooter,
|
||||
PluginManager,
|
||||
]
|
||||
@@ -0,0 +1,972 @@
|
||||
import "@opentui/solid/runtime-plugin-support"
|
||||
import {
|
||||
type TuiDispose,
|
||||
type TuiPlugin,
|
||||
type TuiPluginApi,
|
||||
type TuiPluginInstallResult,
|
||||
type TuiPluginModule,
|
||||
type TuiPluginMeta,
|
||||
type TuiPluginStatus,
|
||||
type TuiTheme,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
import { Config } from "@/config/config"
|
||||
import { TuiConfig } from "@/config/tui"
|
||||
import { Log } from "@/util/log"
|
||||
import { errorData, errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Instance } from "@/project/instance"
|
||||
import {
|
||||
checkPluginCompatibility,
|
||||
getDefaultPlugin,
|
||||
isDeprecatedPlugin,
|
||||
pluginSource,
|
||||
readPluginId,
|
||||
resolvePluginEntrypoint,
|
||||
resolvePluginId,
|
||||
resolvePluginTarget,
|
||||
type PluginSource,
|
||||
} from "@/plugin/shared"
|
||||
import { PluginMeta } from "@/plugin/meta"
|
||||
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
|
||||
import { addTheme, hasTheme } from "../context/theme"
|
||||
import { Global } from "@/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Installation } from "@/installation"
|
||||
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
|
||||
import { setupSlots, Slot as View } from "./slots"
|
||||
import type { HostPluginApi, HostSlots } from "./slots"
|
||||
|
||||
type PluginLoad = {
|
||||
item?: Config.PluginSpec
|
||||
spec: string
|
||||
target: string
|
||||
retry: boolean
|
||||
source: PluginSource | "internal"
|
||||
id: string
|
||||
module: TuiPluginModule
|
||||
install_theme: TuiTheme["install"]
|
||||
}
|
||||
|
||||
type Api = HostPluginApi
|
||||
|
||||
type PluginScope = {
|
||||
lifecycle: TuiPluginApi["lifecycle"]
|
||||
track: (fn: (() => void) | undefined) => () => void
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
|
||||
type PluginEntry = {
|
||||
id: string
|
||||
load: PluginLoad
|
||||
meta: TuiPluginMeta
|
||||
plugin: TuiPlugin
|
||||
options: Config.PluginOptions | undefined
|
||||
enabled: boolean
|
||||
scope?: PluginScope
|
||||
}
|
||||
|
||||
type RuntimeState = {
|
||||
directory: string
|
||||
api: Api
|
||||
slots: HostSlots
|
||||
plugins: PluginEntry[]
|
||||
plugins_by_id: Map<string, PluginEntry>
|
||||
pending: Map<
|
||||
string,
|
||||
{
|
||||
item: Config.PluginSpec
|
||||
meta: TuiConfig.PluginMeta
|
||||
}
|
||||
>
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "tui.plugin" })
|
||||
const DISPOSE_TIMEOUT_MS = 5000
|
||||
const KV_KEY = "plugin_enabled"
|
||||
|
||||
function fail(message: string, data: Record<string, unknown>) {
|
||||
if (!("error" in data)) {
|
||||
log.error(message, data)
|
||||
console.error(`[tui.plugin] ${message}`, data)
|
||||
return
|
||||
}
|
||||
|
||||
const text = `${message}: ${errorMessage(data.error)}`
|
||||
const next = { ...data, error: errorData(data.error) }
|
||||
log.error(text, next)
|
||||
console.error(`[tui.plugin] ${text}`, next)
|
||||
}
|
||||
|
||||
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
||||
|
||||
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve({ type: "timeout" })
|
||||
}, ms)
|
||||
|
||||
Promise.resolve()
|
||||
.then(fn)
|
||||
.then(
|
||||
() => {
|
||||
resolve({ type: "ok" })
|
||||
},
|
||||
(error) => {
|
||||
resolve({ type: "error", error })
|
||||
},
|
||||
)
|
||||
.finally(() => {
|
||||
clearTimeout(timer)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isTheme(value: unknown) {
|
||||
if (!isRecord(value)) return false
|
||||
if (!("theme" in value)) return false
|
||||
if (!isRecord(value.theme)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function resolveRoot(root: string) {
|
||||
if (root.startsWith("file://")) {
|
||||
const file = fileURLToPath(root)
|
||||
if (root.endsWith("/")) return file
|
||||
return path.dirname(file)
|
||||
}
|
||||
if (path.isAbsolute(root)) return root
|
||||
return path.resolve(process.cwd(), root)
|
||||
}
|
||||
|
||||
function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] {
|
||||
return async (file) => {
|
||||
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
|
||||
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
|
||||
const theme = path.basename(src, path.extname(src))
|
||||
if (hasTheme(theme)) return
|
||||
|
||||
const text = await Filesystem.readText(src).catch((error) => {
|
||||
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
|
||||
return
|
||||
})
|
||||
if (text === undefined) return
|
||||
|
||||
const fail = Symbol()
|
||||
const data = await Promise.resolve(text)
|
||||
.then((x) => JSON.parse(x))
|
||||
.catch((error) => {
|
||||
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
|
||||
return fail
|
||||
})
|
||||
if (data === fail) return
|
||||
|
||||
if (!isTheme(data)) {
|
||||
log.warn("invalid tui plugin theme", { path: spec, theme: src })
|
||||
return
|
||||
}
|
||||
|
||||
const source_dir = path.dirname(meta.source)
|
||||
const local_dir =
|
||||
path.basename(source_dir) === ".opencode"
|
||||
? path.join(source_dir, "themes")
|
||||
: path.join(source_dir, ".opencode", "themes")
|
||||
const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes")
|
||||
const dest = path.join(dest_dir, `${theme}.json`)
|
||||
if (!(await Filesystem.exists(dest))) {
|
||||
await Filesystem.write(dest, text).catch((error) => {
|
||||
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
|
||||
})
|
||||
}
|
||||
|
||||
addTheme(theme, data)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadExternalPlugin(
|
||||
item: Config.PluginSpec,
|
||||
meta: TuiConfig.PluginMeta | undefined,
|
||||
retry = false,
|
||||
): Promise<PluginLoad | undefined> {
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (isDeprecatedPlugin(spec)) return
|
||||
log.info("loading tui plugin", { path: spec, retry })
|
||||
const resolved = await resolvePluginTarget(spec).catch((error) => {
|
||||
fail("failed to resolve tui plugin", { path: spec, retry, error })
|
||||
return
|
||||
})
|
||||
if (!resolved) return
|
||||
|
||||
const source = pluginSource(spec)
|
||||
if (source === "npm") {
|
||||
const ok = await checkPluginCompatibility(resolved, Installation.VERSION)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
fail("tui plugin incompatible", { path: spec, retry, error })
|
||||
return false
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const target = resolved
|
||||
if (!meta) {
|
||||
fail("missing tui plugin metadata", {
|
||||
path: spec,
|
||||
retry,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const root = resolveRoot(source === "file" ? spec : target)
|
||||
const install_theme = createThemeInstaller(meta, root, spec)
|
||||
const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
|
||||
fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
|
||||
return
|
||||
})
|
||||
if (!entry) return
|
||||
|
||||
const mod = await import(entry)
|
||||
.then((raw) => {
|
||||
const mod = getDefaultPlugin(raw) as TuiPluginModule | undefined
|
||||
if (!mod?.tui) throw new TypeError(`Plugin ${spec} must default export an object with tui()`)
|
||||
return mod
|
||||
})
|
||||
.catch((error) => {
|
||||
fail("failed to load tui plugin", { path: spec, target: entry, retry, error })
|
||||
return
|
||||
})
|
||||
if (!mod) return
|
||||
|
||||
const id = await resolvePluginId(source, spec, target, readPluginId(mod.id, spec)).catch((error) => {
|
||||
fail("failed to load tui plugin", { path: spec, target, retry, error })
|
||||
return
|
||||
})
|
||||
if (!id) return
|
||||
|
||||
return {
|
||||
item,
|
||||
spec,
|
||||
target,
|
||||
retry,
|
||||
source,
|
||||
id,
|
||||
module: mod,
|
||||
install_theme,
|
||||
}
|
||||
}
|
||||
|
||||
function createMeta(
|
||||
source: PluginLoad["source"],
|
||||
spec: string,
|
||||
target: string,
|
||||
meta: { state: PluginMeta.State; entry: PluginMeta.Entry } | undefined,
|
||||
id?: string,
|
||||
): TuiPluginMeta {
|
||||
if (meta) {
|
||||
return {
|
||||
state: meta.state,
|
||||
...meta.entry,
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
return {
|
||||
state: source === "internal" ? "same" : "first",
|
||||
id: id ?? spec,
|
||||
source,
|
||||
spec,
|
||||
target,
|
||||
first_time: now,
|
||||
last_time: now,
|
||||
time_changed: now,
|
||||
load_count: 1,
|
||||
fingerprint: target,
|
||||
}
|
||||
}
|
||||
|
||||
function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
|
||||
const spec = item.id
|
||||
const target = spec
|
||||
|
||||
return {
|
||||
spec,
|
||||
target,
|
||||
retry: false,
|
||||
source: "internal",
|
||||
id: item.id,
|
||||
module: item,
|
||||
install_theme: createThemeInstaller(
|
||||
{
|
||||
scope: "global",
|
||||
source: target,
|
||||
},
|
||||
process.cwd(),
|
||||
spec,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function createPluginScope(load: PluginLoad, id: string) {
|
||||
const ctrl = new AbortController()
|
||||
let list: { key: symbol; fn: TuiDispose }[] = []
|
||||
let done = false
|
||||
|
||||
const onDispose = (fn: TuiDispose) => {
|
||||
if (done) return () => {}
|
||||
const key = Symbol()
|
||||
list.push({ key, fn })
|
||||
let drop = false
|
||||
return () => {
|
||||
if (drop) return
|
||||
drop = true
|
||||
list = list.filter((x) => x.key !== key)
|
||||
}
|
||||
}
|
||||
|
||||
const track = (fn: (() => void) | undefined) => {
|
||||
if (!fn) return () => {}
|
||||
const off = onDispose(fn)
|
||||
let drop = false
|
||||
return () => {
|
||||
if (drop) return
|
||||
drop = true
|
||||
off()
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
const lifecycle: TuiPluginApi["lifecycle"] = {
|
||||
signal: ctrl.signal,
|
||||
onDispose,
|
||||
}
|
||||
|
||||
const dispose = async () => {
|
||||
if (done) return
|
||||
done = true
|
||||
ctrl.abort()
|
||||
const queue = [...list].reverse()
|
||||
list = []
|
||||
const until = Date.now() + DISPOSE_TIMEOUT_MS
|
||||
for (const item of queue) {
|
||||
const left = until - Date.now()
|
||||
if (left <= 0) {
|
||||
fail("timed out cleaning up tui plugin", {
|
||||
path: load.spec,
|
||||
id,
|
||||
timeout: DISPOSE_TIMEOUT_MS,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const out = await runCleanup(item.fn, left)
|
||||
if (out.type === "ok") continue
|
||||
if (out.type === "timeout") {
|
||||
fail("timed out cleaning up tui plugin", {
|
||||
path: load.spec,
|
||||
id,
|
||||
timeout: DISPOSE_TIMEOUT_MS,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
if (out.type === "error") {
|
||||
fail("failed to clean up tui plugin", {
|
||||
path: load.spec,
|
||||
id,
|
||||
error: out.error,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
lifecycle,
|
||||
track,
|
||||
dispose,
|
||||
}
|
||||
}
|
||||
|
||||
function readPluginEnabledMap(value: unknown) {
|
||||
if (!isRecord(value)) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((item): item is [string, boolean] => typeof item[1] === "boolean"),
|
||||
)
|
||||
}
|
||||
|
||||
function pluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
|
||||
return {
|
||||
...readPluginEnabledMap(config.plugin_enabled),
|
||||
...readPluginEnabledMap(state.api.kv.get(KV_KEY, {})),
|
||||
}
|
||||
}
|
||||
|
||||
function writePluginEnabledState(api: Api, id: string, enabled: boolean) {
|
||||
api.kv.set(KV_KEY, {
|
||||
...readPluginEnabledMap(api.kv.get(KV_KEY, {})),
|
||||
[id]: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
function listPluginStatus(state: RuntimeState): TuiPluginStatus[] {
|
||||
return state.plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
source: plugin.meta.source,
|
||||
spec: plugin.meta.spec,
|
||||
target: plugin.meta.target,
|
||||
enabled: plugin.enabled,
|
||||
active: plugin.scope !== undefined,
|
||||
}))
|
||||
}
|
||||
|
||||
async function deactivatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
|
||||
plugin.enabled = false
|
||||
if (persist) writePluginEnabledState(state.api, plugin.id, false)
|
||||
if (!plugin.scope) return true
|
||||
const scope = plugin.scope
|
||||
plugin.scope = undefined
|
||||
await scope.dispose()
|
||||
return true
|
||||
}
|
||||
|
||||
async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, persist: boolean) {
|
||||
plugin.enabled = true
|
||||
if (persist) writePluginEnabledState(state.api, plugin.id, true)
|
||||
if (plugin.scope) return true
|
||||
|
||||
const scope = createPluginScope(plugin.load, plugin.id)
|
||||
const api = pluginApi(state, plugin.load, scope, plugin.id)
|
||||
const ok = await Promise.resolve()
|
||||
.then(async () => {
|
||||
await plugin.plugin(api, plugin.options, plugin.meta)
|
||||
return true
|
||||
})
|
||||
.catch((error) => {
|
||||
fail("failed to initialize tui plugin", {
|
||||
path: plugin.load.spec,
|
||||
id: plugin.id,
|
||||
error,
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!ok) {
|
||||
await scope.dispose()
|
||||
return false
|
||||
}
|
||||
|
||||
if (!plugin.enabled) {
|
||||
await scope.dispose()
|
||||
return true
|
||||
}
|
||||
|
||||
plugin.scope = scope
|
||||
return true
|
||||
}
|
||||
|
||||
async function activatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
|
||||
if (!state) return false
|
||||
const plugin = state.plugins_by_id.get(id)
|
||||
if (!plugin) return false
|
||||
return activatePluginEntry(state, plugin, persist)
|
||||
}
|
||||
|
||||
async function deactivatePluginById(state: RuntimeState | undefined, id: string, persist: boolean) {
|
||||
if (!state) return false
|
||||
const plugin = state.plugins_by_id.get(id)
|
||||
if (!plugin) return false
|
||||
return deactivatePluginEntry(state, plugin, persist)
|
||||
}
|
||||
|
||||
function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, base: string): TuiPluginApi {
|
||||
const api = runtime.api
|
||||
const host = runtime.slots
|
||||
const command: TuiPluginApi["command"] = {
|
||||
register(cb) {
|
||||
return scope.track(api.command.register(cb))
|
||||
},
|
||||
trigger(value) {
|
||||
api.command.trigger(value)
|
||||
},
|
||||
}
|
||||
|
||||
const route: TuiPluginApi["route"] = {
|
||||
register(list) {
|
||||
return scope.track(api.route.register(list))
|
||||
},
|
||||
navigate(name, params) {
|
||||
api.route.navigate(name, params)
|
||||
},
|
||||
get current() {
|
||||
return api.route.current
|
||||
},
|
||||
}
|
||||
|
||||
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
|
||||
install: load.install_theme,
|
||||
})
|
||||
|
||||
const event: TuiPluginApi["event"] = {
|
||||
on(type, handler) {
|
||||
return scope.track(api.event.on(type, handler))
|
||||
},
|
||||
}
|
||||
|
||||
let count = 0
|
||||
|
||||
const slots: TuiPluginApi["slots"] = {
|
||||
register(plugin) {
|
||||
const id = count ? `${base}:${count}` : base
|
||||
count += 1
|
||||
scope.track(host.register({ ...plugin, id }))
|
||||
return id
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
app: api.app,
|
||||
command,
|
||||
route,
|
||||
ui: api.ui,
|
||||
keybind: api.keybind,
|
||||
tuiConfig: api.tuiConfig,
|
||||
kv: api.kv,
|
||||
state: api.state,
|
||||
theme,
|
||||
get client() {
|
||||
return api.client
|
||||
},
|
||||
scopedClient: api.scopedClient,
|
||||
workspace: api.workspace,
|
||||
event,
|
||||
renderer: api.renderer,
|
||||
slots,
|
||||
plugins: {
|
||||
list() {
|
||||
return listPluginStatus(runtime)
|
||||
},
|
||||
activate(id) {
|
||||
return activatePluginById(runtime, id, true)
|
||||
},
|
||||
deactivate(id) {
|
||||
return deactivatePluginById(runtime, id, true)
|
||||
},
|
||||
add(spec) {
|
||||
return addPluginBySpec(runtime, spec)
|
||||
},
|
||||
install(spec, options) {
|
||||
return installPluginBySpec(runtime, spec, options?.global)
|
||||
},
|
||||
},
|
||||
lifecycle: scope.lifecycle,
|
||||
}
|
||||
}
|
||||
|
||||
function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) {
|
||||
// TUI stays default-only so plugin ids, lifecycle, and errors remain stable.
|
||||
const plugin = load.module.tui
|
||||
if (!plugin) return []
|
||||
const options = load.item ? Config.pluginOptions(load.item) : undefined
|
||||
return [
|
||||
{
|
||||
id: load.id,
|
||||
load,
|
||||
meta,
|
||||
plugin,
|
||||
options,
|
||||
enabled: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
|
||||
if (state.plugins_by_id.has(plugin.id)) {
|
||||
fail("duplicate tui plugin id", {
|
||||
id: plugin.id,
|
||||
path: plugin.load.spec,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
state.plugins_by_id.set(plugin.id, plugin)
|
||||
state.plugins.push(plugin)
|
||||
return true
|
||||
}
|
||||
|
||||
function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.Info) {
|
||||
const map = pluginEnabledState(state, config)
|
||||
for (const plugin of state.plugins) {
|
||||
const enabled = map[plugin.id]
|
||||
if (enabled === undefined) continue
|
||||
plugin.enabled = enabled
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveExternalPlugins(
|
||||
list: Config.PluginSpec[],
|
||||
wait: () => Promise<void>,
|
||||
meta: (item: Config.PluginSpec) => TuiConfig.PluginMeta | undefined,
|
||||
) {
|
||||
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item, meta(item))))
|
||||
const ready: PluginLoad[] = []
|
||||
let deps: Promise<void> | undefined
|
||||
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
let entry = loaded[i]
|
||||
if (!entry) {
|
||||
const item = list[i]
|
||||
if (!item) continue
|
||||
const spec = Config.pluginSpecifier(item)
|
||||
if (pluginSource(spec) !== "file") continue
|
||||
deps ??= wait().catch((error) => {
|
||||
log.warn("failed waiting for tui plugin dependencies", { error })
|
||||
})
|
||||
await deps
|
||||
entry = await loadExternalPlugin(item, meta(item), true)
|
||||
}
|
||||
if (!entry) continue
|
||||
ready.push(entry)
|
||||
}
|
||||
|
||||
return ready
|
||||
}
|
||||
|
||||
async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
|
||||
if (!ready.length) return { plugins: [] as PluginEntry[], ok: true }
|
||||
|
||||
const meta = await PluginMeta.touchMany(
|
||||
ready.map((item) => ({
|
||||
spec: item.spec,
|
||||
target: item.target,
|
||||
id: item.id,
|
||||
})),
|
||||
).catch((error) => {
|
||||
log.warn("failed to track tui plugins", { error })
|
||||
return undefined
|
||||
})
|
||||
|
||||
const plugins: PluginEntry[] = []
|
||||
let ok = true
|
||||
for (let i = 0; i < ready.length; i++) {
|
||||
const entry = ready[i]
|
||||
if (!entry) continue
|
||||
const hit = meta?.[i]
|
||||
if (hit && hit.state !== "same") {
|
||||
log.info("tui plugin metadata updated", {
|
||||
path: entry.spec,
|
||||
retry: entry.retry,
|
||||
state: hit.state,
|
||||
source: hit.entry.source,
|
||||
version: hit.entry.version,
|
||||
modified: hit.entry.modified,
|
||||
})
|
||||
}
|
||||
|
||||
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
|
||||
for (const plugin of collectPluginEntries(entry, row)) {
|
||||
if (!addPluginEntry(state, plugin)) {
|
||||
ok = false
|
||||
continue
|
||||
}
|
||||
plugins.push(plugin)
|
||||
}
|
||||
}
|
||||
|
||||
return { plugins, ok }
|
||||
}
|
||||
|
||||
function defaultPluginMeta(state: RuntimeState): TuiConfig.PluginMeta {
|
||||
return {
|
||||
scope: "local",
|
||||
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
|
||||
}
|
||||
}
|
||||
|
||||
function installCause(err: unknown) {
|
||||
if (!err || typeof err !== "object") return
|
||||
if (!("cause" in err)) return
|
||||
return (err as { cause?: unknown }).cause
|
||||
}
|
||||
|
||||
function installDetail(err: unknown) {
|
||||
const hit = installCause(err) ?? err
|
||||
if (!(hit instanceof Process.RunFailedError)) {
|
||||
return {
|
||||
message: errorMessage(hit),
|
||||
missing: false,
|
||||
}
|
||||
}
|
||||
|
||||
const lines = hit.stderr
|
||||
.toString()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
const errs = lines.filter((line) => line.startsWith("error:")).map((line) => line.replace(/^error:\s*/, ""))
|
||||
return {
|
||||
message: errs[0] ?? lines.at(-1) ?? errorMessage(hit),
|
||||
missing: lines.some((line) => line.includes("No version matching")),
|
||||
}
|
||||
}
|
||||
|
||||
async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
||||
if (!state) return false
|
||||
const spec = raw.trim()
|
||||
if (!spec) return false
|
||||
|
||||
const pending = state.pending.get(spec)
|
||||
const item = pending?.item ?? spec
|
||||
const nextSpec = Config.pluginSpecifier(item)
|
||||
if (state.plugins.some((plugin) => plugin.load.spec === nextSpec)) {
|
||||
state.pending.delete(spec)
|
||||
return true
|
||||
}
|
||||
|
||||
const meta = pending?.meta ?? defaultPluginMeta(state)
|
||||
|
||||
const ready = await Instance.provide({
|
||||
directory: state.directory,
|
||||
fn: () =>
|
||||
resolveExternalPlugins(
|
||||
[item],
|
||||
() => TuiConfig.waitForDependencies(),
|
||||
() => meta,
|
||||
),
|
||||
}).catch((error) => {
|
||||
fail("failed to add tui plugin", { path: nextSpec, error })
|
||||
return [] as PluginLoad[]
|
||||
})
|
||||
if (!ready.length) {
|
||||
fail("failed to add tui plugin", { path: nextSpec })
|
||||
return false
|
||||
}
|
||||
|
||||
const first = ready[0]
|
||||
if (!first) {
|
||||
fail("failed to add tui plugin", { path: nextSpec })
|
||||
return false
|
||||
}
|
||||
if (state.plugins_by_id.has(first.id)) {
|
||||
state.pending.delete(spec)
|
||||
return true
|
||||
}
|
||||
|
||||
const out = await addExternalPluginEntries(state, [first])
|
||||
let ok = out.ok && out.plugins.length > 0
|
||||
for (const plugin of out.plugins) {
|
||||
const active = await activatePluginEntry(state, plugin, false)
|
||||
if (!active) ok = false
|
||||
}
|
||||
|
||||
if (ok) state.pending.delete(spec)
|
||||
if (!ok) {
|
||||
fail("failed to add tui plugin", { path: nextSpec })
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
async function installPluginBySpec(
|
||||
state: RuntimeState | undefined,
|
||||
raw: string,
|
||||
global = false,
|
||||
): Promise<TuiPluginInstallResult> {
|
||||
if (!state) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Plugin runtime is not ready.",
|
||||
}
|
||||
}
|
||||
|
||||
const spec = raw.trim()
|
||||
if (!spec) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Plugin package name is required",
|
||||
}
|
||||
}
|
||||
|
||||
const dir = state.api.state.path
|
||||
if (!dir.directory) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "Paths are still syncing. Try again in a moment.",
|
||||
}
|
||||
}
|
||||
|
||||
const install = await installModulePlugin(spec)
|
||||
if (!install.ok) {
|
||||
const out = installDetail(install.error)
|
||||
return {
|
||||
ok: false,
|
||||
message: out.message,
|
||||
missing: out.missing,
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = await readPluginManifest(install.target)
|
||||
if (!manifest.ok) {
|
||||
if (manifest.code === "manifest_no_targets") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `"${spec}" does not declare supported targets in package.json`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: `Installed "${spec}" but failed to read ${manifest.file}`,
|
||||
}
|
||||
}
|
||||
|
||||
const patch = await patchPluginConfig({
|
||||
spec,
|
||||
targets: manifest.targets,
|
||||
global,
|
||||
vcs: dir.worktree && dir.worktree !== "/" ? "git" : undefined,
|
||||
worktree: dir.worktree,
|
||||
directory: dir.directory,
|
||||
})
|
||||
if (!patch.ok) {
|
||||
if (patch.code === "invalid_json") {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Invalid JSON in ${patch.file} (${patch.parse} at line ${patch.line}, column ${patch.col})`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
message: errorMessage(patch.error),
|
||||
}
|
||||
}
|
||||
|
||||
const tui = manifest.targets.find((item) => item.kind === "tui")
|
||||
if (tui) {
|
||||
const file = patch.items.find((item) => item.kind === "tui")?.file
|
||||
state.pending.set(spec, {
|
||||
item: tui.opts ? [spec, tui.opts] : spec,
|
||||
meta: {
|
||||
scope: global ? "global" : "local",
|
||||
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
dir: patch.dir,
|
||||
tui: Boolean(tui),
|
||||
}
|
||||
}
|
||||
|
||||
export namespace TuiPluginRuntime {
|
||||
let dir = ""
|
||||
let loaded: Promise<void> | undefined
|
||||
let runtime: RuntimeState | undefined
|
||||
export const Slot = View
|
||||
|
||||
export async function init(api: HostPluginApi) {
|
||||
const cwd = process.cwd()
|
||||
if (loaded) {
|
||||
if (dir !== cwd) {
|
||||
throw new Error(`TuiPluginRuntime.init() called with a different working directory. expected=${dir} got=${cwd}`)
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
dir = cwd
|
||||
loaded = load(api)
|
||||
return loaded
|
||||
}
|
||||
|
||||
export function list() {
|
||||
if (!runtime) return []
|
||||
return listPluginStatus(runtime)
|
||||
}
|
||||
|
||||
export async function activatePlugin(id: string) {
|
||||
return activatePluginById(runtime, id, true)
|
||||
}
|
||||
|
||||
export async function deactivatePlugin(id: string) {
|
||||
return deactivatePluginById(runtime, id, true)
|
||||
}
|
||||
|
||||
export async function addPlugin(spec: string) {
|
||||
return addPluginBySpec(runtime, spec)
|
||||
}
|
||||
|
||||
export async function installPlugin(spec: string, options?: { global?: boolean }) {
|
||||
return installPluginBySpec(runtime, spec, options?.global)
|
||||
}
|
||||
|
||||
export async function dispose() {
|
||||
const task = loaded
|
||||
loaded = undefined
|
||||
dir = ""
|
||||
if (task) await task
|
||||
const state = runtime
|
||||
runtime = undefined
|
||||
if (!state) return
|
||||
const queue = [...state.plugins].reverse()
|
||||
for (const plugin of queue) {
|
||||
await deactivatePluginEntry(state, plugin, false)
|
||||
}
|
||||
}
|
||||
|
||||
async function load(api: Api) {
|
||||
const cwd = process.cwd()
|
||||
const slots = setupSlots(api)
|
||||
const next: RuntimeState = {
|
||||
directory: cwd,
|
||||
api,
|
||||
slots,
|
||||
plugins: [],
|
||||
plugins_by_id: new Map(),
|
||||
pending: new Map(),
|
||||
}
|
||||
runtime = next
|
||||
|
||||
await Instance.provide({
|
||||
directory: cwd,
|
||||
fn: async () => {
|
||||
const config = await TuiConfig.get()
|
||||
const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? [])
|
||||
if (Flag.OPENCODE_PURE && config.plugin?.length) {
|
||||
log.info("skipping external tui plugins in pure mode", { count: config.plugin.length })
|
||||
}
|
||||
|
||||
for (const item of INTERNAL_TUI_PLUGINS) {
|
||||
log.info("loading internal tui plugin", { id: item.id })
|
||||
const entry = loadInternalPlugin(item)
|
||||
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
|
||||
for (const plugin of collectPluginEntries(entry, meta)) {
|
||||
addPluginEntry(next, plugin)
|
||||
}
|
||||
}
|
||||
|
||||
const ready = await resolveExternalPlugins(
|
||||
plugins,
|
||||
() => TuiConfig.waitForDependencies(),
|
||||
(item) => config.plugin_meta?.[Config.pluginSpecifier(item)],
|
||||
)
|
||||
await addExternalPluginEntries(next, ready)
|
||||
|
||||
applyInitialPluginEnabledState(next, config)
|
||||
for (const plugin of next.plugins) {
|
||||
if (!plugin.enabled) continue
|
||||
// Keep plugin execution sequential for deterministic side effects:
|
||||
// command registration order affects keybind/command precedence,
|
||||
// route registration is last-wins when ids collide,
|
||||
// and hook chains rely on stable plugin ordering.
|
||||
await activatePluginEntry(next, plugin, false)
|
||||
}
|
||||
},
|
||||
}).catch((error) => {
|
||||
fail("failed to load tui plugins", { directory: cwd, error })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { type SlotMode, type TuiPluginApi, type TuiSlotContext, type TuiSlotMap } from "@opencode-ai/plugin/tui"
|
||||
import { createSlot, createSolidSlotRegistry, type JSX, type SolidPlugin } from "@opentui/solid"
|
||||
import { isRecord } from "@/util/record"
|
||||
|
||||
type SlotProps<K extends keyof TuiSlotMap> = {
|
||||
name: K
|
||||
mode?: SlotMode
|
||||
children?: JSX.Element
|
||||
} & TuiSlotMap[K]
|
||||
|
||||
type Slot = <K extends keyof TuiSlotMap>(props: SlotProps<K>) => JSX.Element | null
|
||||
export type HostSlotPlugin = SolidPlugin<TuiSlotMap, TuiSlotContext>
|
||||
|
||||
export type HostPluginApi = TuiPluginApi
|
||||
export type HostSlots = {
|
||||
register: (plugin: HostSlotPlugin) => () => void
|
||||
}
|
||||
|
||||
function empty<K extends keyof TuiSlotMap>(_props: SlotProps<K>) {
|
||||
return null
|
||||
}
|
||||
|
||||
let view: Slot = empty
|
||||
|
||||
export const Slot: Slot = (props) => view(props)
|
||||
|
||||
function isHostSlotPlugin(value: unknown): value is HostSlotPlugin {
|
||||
if (!isRecord(value)) return false
|
||||
if (typeof value.id !== "string") return false
|
||||
if (!isRecord(value.slots)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function setupSlots(api: HostPluginApi): HostSlots {
|
||||
const reg = createSolidSlotRegistry<TuiSlotMap, 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<TuiSlotMap, TuiSlotContext>(reg)
|
||||
view = (props) => slot(props)
|
||||
return {
|
||||
register(plugin) {
|
||||
if (!isHostSlotPlugin(plugin)) return () => {}
|
||||
return reg.register(plugin)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user