| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985 |
- 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 { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared"
- import { PluginLoader } from "@/plugin/loader"
- import { PluginMeta } from "@/plugin/meta"
- import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
- import { hasTheme, upsertTheme } from "../context/theme"
- import { Global } from "@/global"
- import { Filesystem } from "@/util/filesystem"
- import { Process } from "@/util/process"
- import { Flag } from "@/flag/flag"
- import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
- import { setupSlots, Slot as View } from "./slots"
- import type { HostPluginApi, HostSlots } from "./slots"
- type PluginLoad = {
- options: Config.PluginOptions | undefined
- spec: string
- target: string
- retry: boolean
- source: PluginSource | "internal"
- id: string
- module: TuiPluginModule
- theme_meta: TuiConfig.PluginMeta
- theme_root: string
- }
- type Api = HostPluginApi
- type PluginScope = {
- lifecycle: TuiPluginApi["lifecycle"]
- track: (fn: (() => void) | undefined) => () => void
- dispose: () => Promise<void>
- }
- type PluginEntry = {
- id: string
- load: PluginLoad
- meta: TuiPluginMeta
- themes: Record<string, PluginMeta.Theme>
- plugin: TuiPlugin
- enabled: boolean
- scope?: PluginScope
- }
- type RuntimeState = {
- directory: string
- api: Api
- slots: HostSlots
- plugins: PluginEntry[]
- plugins_by_id: Map<string, PluginEntry>
- pending: Map<string, TuiConfig.PluginRecord>
- }
- 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,
- plugin: PluginEntry,
- ): TuiTheme["install"] {
- return async (file) => {
- const raw = file.startsWith("file://") ? fileURLToPath(file) : file
- const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
- const name = path.basename(src, path.extname(src))
- 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, `${name}.json`)
- const stat = await Filesystem.statAsync(src)
- const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
- const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
- const exists = hasTheme(name)
- const prev = plugin.themes[name]
- if (exists) {
- if (plugin.meta.state !== "updated") return
- if (!prev) {
- if (await Filesystem.exists(dest)) {
- plugin.themes[name] = {
- src,
- dest,
- mtime,
- size,
- }
- await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
- log.warn("failed to track tui plugin theme", {
- path: spec,
- id: plugin.id,
- theme: src,
- dest,
- error,
- })
- })
- }
- return
- }
- if (prev.dest !== dest) return
- if (prev.mtime === mtime && prev.size === size) 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
- }
- if (exists || !(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 })
- })
- }
- upsertTheme(name, data)
- plugin.themes[name] = {
- src,
- dest,
- mtime,
- size,
- }
- await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
- log.warn("failed to track tui plugin theme", {
- path: spec,
- id: plugin.id,
- theme: src,
- dest,
- error,
- })
- })
- }
- }
- async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
- const plan = PluginLoader.plan(cfg.item)
- if (plan.deprecated) return
- log.info("loading tui plugin", { path: plan.spec, retry })
- const resolved = await PluginLoader.resolve(plan, "tui")
- if (!resolved.ok) {
- if (resolved.stage === "install") {
- fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
- return
- }
- if (resolved.stage === "compatibility") {
- fail("tui plugin incompatible", { path: plan.spec, retry, error: resolved.error })
- return
- }
- fail("failed to resolve tui plugin entry", { path: plan.spec, retry, error: resolved.error })
- return
- }
- const loaded = await PluginLoader.load(resolved.value)
- if (!loaded.ok) {
- fail("failed to load tui plugin", {
- path: plan.spec,
- target: resolved.value.entry,
- retry,
- error: loaded.error,
- })
- return
- }
- const mod = await Promise.resolve()
- .then(() => {
- return readV1Plugin(loaded.value.mod as Record<string, unknown>, plan.spec, "tui") as TuiPluginModule
- })
- .catch((error) => {
- fail("failed to load tui plugin", {
- path: plan.spec,
- target: loaded.value.entry,
- retry,
- error,
- })
- return
- })
- if (!mod) return
- const id = await resolvePluginId(
- loaded.value.source,
- plan.spec,
- loaded.value.target,
- readPluginId(mod.id, plan.spec),
- loaded.value.pkg,
- ).catch((error) => {
- fail("failed to load tui plugin", { path: plan.spec, target: loaded.value.target, retry, error })
- return
- })
- if (!id) return
- return {
- options: plan.options,
- spec: plan.spec,
- target: loaded.value.target,
- retry,
- source: loaded.value.source,
- id,
- module: mod,
- theme_meta: {
- scope: cfg.scope,
- source: cfg.source,
- },
- theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target),
- }
- }
- 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 {
- options: undefined,
- spec,
- target,
- retry: false,
- source: "internal",
- id: item.id,
- module: item,
- theme_meta: {
- scope: "global",
- source: target,
- },
- theme_root: process.cwd(),
- }
- }
- 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, scope, plugin.id)
- const ok = await Promise.resolve()
- .then(async () => {
- await plugin.plugin(api, plugin.load.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, plugin: PluginEntry, scope: PluginScope, base: string): TuiPluginApi {
- const api = runtime.api
- const host = runtime.slots
- const load = plugin.load
- 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: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin),
- })
- 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 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: TuiConfig.PluginRecord[], wait: () => Promise<void>) {
- const loaded = await Promise.all(list.map((item) => loadExternalPlugin(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
- if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
- deps ??= wait().catch((error) => {
- log.warn("failed waiting for tui plugin dependencies", { error })
- })
- await deps
- entry = await loadExternalPlugin(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)
- const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
- const plugin: PluginEntry = {
- id: entry.id,
- load: entry,
- meta: row,
- themes,
- plugin: entry.module.tui,
- enabled: true,
- }
- if (!addPluginEntry(state, plugin)) {
- ok = false
- continue
- }
- plugins.push(plugin)
- }
- return { plugins, ok }
- }
- function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
- return {
- item: spec,
- 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 cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
- const next = Config.pluginSpecifier(cfg.item)
- if (state.plugins.some((plugin) => plugin.load.spec === next)) {
- state.pending.delete(spec)
- return true
- }
- const ready = await Instance.provide({
- directory: state.directory,
- fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
- }).catch((error) => {
- fail("failed to add tui plugin", { path: next, error })
- return [] as PluginLoad[]
- })
- if (!ready.length) {
- fail("failed to add tui plugin", { path: next })
- return false
- }
- const first = ready[0]
- if (!first) {
- fail("failed to add tui plugin", { path: next })
- 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: next })
- }
- 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
- const item = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
- state.pending.set(spec, {
- item,
- 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 records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? [])
- if (Flag.OPENCODE_PURE && config.plugin_records?.length) {
- log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.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)
- addPluginEntry(next, {
- id: entry.id,
- load: entry,
- meta,
- themes: {},
- plugin: entry.module.tui,
- enabled: true,
- })
- }
- const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
- 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 })
- })
- }
- }
|