diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index d5aef34f6e..0a6465e289 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,7 +1,15 @@ -import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { + createSlot, + createSolidSlotRegistry, + render, + useKeyboard, + useRenderer, + useTerminalDimensions, + type SolidPlugin, +} from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { Selection } from "@tui/util/selection" -import { MouseButton, TextAttributes } from "@opentui/core" +import { createCliRenderer, MouseButton, TextAttributes, type CliRendererConfig } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" @@ -41,6 +49,9 @@ import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" import { TuiConfigProvider } from "./context/tui-config" import { TuiConfig } from "@/config/tui" +import type { TuiSlotContext, TuiSlotMap, TuiSlots } from "@opencode-ai/plugin" + +type TuiSlot = (props: { name: K } & TuiSlotMap[K]) => unknown async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -104,6 +115,25 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { import type { EventSource } from "./context/sdk" +function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { + return { + targetFps: 60, + gatherStats: false, + exitOnCtrlC: false, + useKittyKeyboard: {}, + autoFocus: false, + openConsoleOnError: false, + consoleOptions: { + keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], + onCopySelection: (text) => { + Clipboard.copy(text).catch((error) => { + console.error(`Failed to copy console selection to clipboard: ${error}`) + }) + }, + }, + } +} + export function tui(input: { url: string args: Args @@ -129,77 +159,74 @@ export function tui(input: { resolve() } - render( - () => { - return ( - } - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) + const renderer = await createCliRenderer(rendererConfig(input.config)) + const registry = createSolidSlotRegistry(renderer, { + url: input.url, + directory: input.directory, + }) + const Slot = createSlot(registry) + const slot: TuiSlot = (props) => Slot(props) + const slots: TuiSlots = { + register(plugin) { + return registry.register(plugin as SolidPlugin) }, - { - targetFps: 60, - gatherStats: false, - exitOnCtrlC: false, - useKittyKeyboard: {}, - autoFocus: false, - openConsoleOnError: false, - consoleOptions: { - keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], - onCopySelection: (text) => { - Clipboard.copy(text).catch((error) => { - console.error(`Failed to copy console selection to clipboard: ${error}`) - }) - }, - }, - }, - ) + } + + await render(() => { + return ( + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) + }, renderer) }) } -function App() { +function App(props: { slot: TuiSlot }) { const route = useRoute() const dimensions = useTerminalDimensions() const renderer = useRenderer() @@ -766,10 +793,10 @@ function App() { > - + - + diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 2403a4e938..13051494cf 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -1,7 +1,10 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2" +import type { CliRenderer } from "@opentui/core" +import type { TuiSlots } from "@opencode-ai/plugin" import { createSimpleContext } from "./helper" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup, onMount } from "solid-js" +import { TuiPlugin } from "../plugin" export type EventSource = { on: (handler: (event: Event) => void) => () => void @@ -12,6 +15,8 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { url: string + renderer: CliRenderer + slots: TuiSlots directory?: string fetch?: typeof fetch headers?: RequestInit["headers"] @@ -38,6 +43,17 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ [key in Event["type"]]: Extract }>() + TuiPlugin.init({ + client: sdk, + event: emitter, + url: props.url, + directory: props.directory, + renderer: props.renderer, + slots: props.slots, + }).catch((error) => { + console.error("Failed to load TUI plugins", error) + }) + let queue: Event[] = [] let timer: Timer | undefined let last = 0 diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 2320c08ccc..5865e64366 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -1,6 +1,6 @@ import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" import path from "path" -import { createEffect, createMemo, onMount } from "solid-js" +import { createEffect, createMemo, onCleanup, onMount } from "solid-js" import { createSimpleContext } from "./helper" import { Glob } from "../../../../util/glob" import aura from "./theme/aura.json" with { type: "json" } @@ -138,6 +138,44 @@ type ThemeJson = { } } +type ThemeRegistry = { + themes: Record + listeners: Set<(themes: Record) => void> +} + +const registry: ThemeRegistry = { + themes: {}, + listeners: new Set(), +} + +export function registerThemes(themes: Record) { + const entries = Object.entries(themes).filter((entry): entry is [string, ThemeJson] => { + const theme = entry[1] + if (!theme || typeof theme !== "object") return false + if (!("theme" in theme)) return false + return true + }) + if (entries.length === 0) return + + for (const [name, theme] of entries) { + registry.themes[name] = theme + } + + const payload = Object.fromEntries(entries) + for (const handler of registry.listeners) { + handler(payload) + } +} + +function registeredThemes() { + return registry.themes +} + +function onThemes(handler: (themes: Record) => void) { + registry.listeners.add(handler) + return () => registry.listeners.delete(handler) +} + export const DEFAULT_THEMES: Record = { aura, ayu, @@ -296,6 +334,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ function init() { resolveSystemTheme() + mergeThemes(registeredThemes()) getCustomThemes() .then((custom) => { setStore( @@ -315,6 +354,22 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } onMount(init) + onCleanup( + onThemes((themes) => { + mergeThemes(themes) + }), + ) + + function mergeThemes(themes: Record) { + setStore( + produce((draft) => { + for (const [name, theme] of Object.entries(themes)) { + if (draft.themes[name]) continue + draft.themes[name] = theme + } + }), + ) + } function resolveSystemTheme() { console.log("resolveSystemTheme") diff --git a/packages/opencode/src/cli/cmd/tui/plugin.ts b/packages/opencode/src/cli/cmd/tui/plugin.ts new file mode 100644 index 0000000000..6d3d33efdf --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/plugin.ts @@ -0,0 +1,107 @@ +import type { PluginModule, TuiPlugin as TuiPluginFn, TuiPluginInput, TuiSlotPlugin } from "@opencode-ai/plugin" +import type { JSX } from "solid-js" +import { Config } from "@/config/config" +import { TuiConfig } from "@/config/tui" +import { Log } from "@/util/log" +import { BunProc } from "@/bun" +import { Instance } from "@/project/instance" +import { registerThemes } from "./context/theme" +import { existsSync } from "fs" + +export namespace TuiPlugin { + const log = Log.create({ service: "tui.plugin" }) + let loaded: Promise | undefined + + export async function init(input: TuiPluginInput) { + if (loaded) return loaded + loaded = load(input) + return loaded + } + + async function resolve(spec: string) { + if (spec.startsWith("file://")) return spec + const lastAtIndex = spec.lastIndexOf("@") + const pkg = lastAtIndex > 0 ? spec.substring(0, lastAtIndex) : spec + const version = lastAtIndex > 0 ? spec.substring(lastAtIndex + 1) : "latest" + return BunProc.install(pkg, version) + } + + function slot(entry: unknown) { + if (!entry || typeof entry !== "object") return + if ("id" in entry && typeof entry.id === "string" && "slots" in entry && typeof entry.slots === "object") { + return entry as TuiSlotPlugin + } + if (!("slots" in entry)) return + const value = entry.slots + if (!value || typeof value !== "object") return + if (!("id" in value) || typeof value.id !== "string") return + if (!("slots" in value) || typeof value.slots !== "object") return + return value as TuiSlotPlugin + } + + async function load(input: TuiPluginInput) { + const base = input.directory ?? process.cwd() + const dir = existsSync(base) ? base : process.cwd() + if (dir !== base) { + log.info("tui plugin directory not found, using local cwd", { + requested: base, + directory: dir, + }) + } + + await Instance.provide({ + directory: dir, + fn: async () => { + const config = await TuiConfig.get() + const plugins = config.plugin ?? [] + if (plugins.length) await TuiConfig.waitForDependencies() + + for (const item of plugins) { + const spec = Config.pluginSpecifier(item) + log.info("loading tui plugin", { path: spec }) + const path = await resolve(spec).catch((error) => { + log.error("failed to install tui plugin", { path: spec, error }) + return + }) + if (!path) continue + + const mod = await import(path).catch((error) => { + log.error("failed to load tui plugin", { path: spec, error }) + return + }) + if (!mod) continue + + const seen = new Set() + for (const entry of Object.values(mod)) { + if (seen.has(entry)) continue + seen.add(entry) + + const themes = (() => { + if (!entry || typeof entry !== "object") return + if (!("themes" in entry)) return + if (!entry.themes || typeof entry.themes !== "object") return + return entry.themes as Record + })() + if (themes) registerThemes(themes) + + const plugin = slot(entry) + if (plugin) { + input.slots.register(plugin) + } + + const tui = (() => { + if (!entry || typeof entry !== "object") return + if (!("tui" in entry)) return + if (typeof entry.tui !== "function") return + return entry.tui as TuiPluginFn + })() + if (!tui) continue + await tui(input, Config.pluginOptions(item)) + } + } + }, + }).catch((error) => { + log.error("failed to load tui plugins", { directory: dir, error }) + }) + } +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 24ea8f3b31..f74826b24c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -15,11 +15,14 @@ import { Installation } from "@/installation" import { useKV } from "../context/kv" import { useCommandDialog } from "../component/dialog-command" import { useLocal } from "../context/local" +import type { TuiSlotMap } from "@opencode-ai/plugin" + +type Slot = (props: { name: K } & TuiSlotMap[K]) => unknown // TODO: what is the best way to do this? let once = false -export function Home() { +export function Home(props: { slot: Slot }) { const sync = useSync() const kv = useKV() const { theme } = useTheme() @@ -57,8 +60,8 @@ export function Home() { ]) const Hint = ( - 0}> - + + 0}> @@ -71,8 +74,9 @@ export function Home() { - - + + {props.slot({ name: "home_hint" }) as never} + ) let prompt: PromptRef @@ -150,6 +154,7 @@ export function Home() { + {props.slot({ name: "home_footer" }) as never} {Installation.VERSION} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 5358b61ef3..242ffcffd3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -70,7 +70,6 @@ import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import { Editor } from "../../util/editor" import stripAnsi from "strip-ansi" -import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" @@ -81,9 +80,12 @@ import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import type { TuiSlotMap } from "@opencode-ai/plugin" addDefaultParsers(parsers.parsers) +type Slot = (props: { name: "session_footer"; session_id: TuiSlotMap["session_footer"]["session_id"] }) => unknown + class CustomSpeedScroll implements ScrollAcceleration { constructor(private speed: number) {} @@ -113,7 +115,7 @@ function use() { return ctx } -export function Session() { +export function Session(props: { slot: Slot }) { const route = useRouteData("session") const { navigate } = useRoute() const sync = useSync() @@ -1178,6 +1180,7 @@ export function Session() { }} sessionID={route.sessionID} /> + {props.slot({ name: "session_footer", session_id: route.sessionID }) as never} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6b4242a225..a10ee0d2ab 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,6 +1,6 @@ import { Log } from "../util/log" import path from "path" -import { pathToFileURL, fileURLToPath } from "url" +import { pathToFileURL } from "url" import { createRequire } from "module" import os from "os" import z from "zod" @@ -38,6 +38,11 @@ import { Filesystem } from "@/util/filesystem" export namespace Config { const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) + const PluginOptions = z.record(z.string(), z.unknown()) + export const PluginSpec = z.union([z.string(), z.tuple([z.string(), PluginOptions])]) + + export type PluginOptions = z.infer + export type PluginSpec = z.infer const log = Log.create({ service: "config" }) @@ -449,7 +454,7 @@ export namespace Config { } async function loadPlugin(dir: string) { - const plugins: string[] = [] + const plugins: PluginSpec[] = [] for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", { cwd: dir, @@ -462,6 +467,32 @@ export namespace Config { return plugins } + export function pluginSpecifier(plugin: PluginSpec): string { + return Array.isArray(plugin) ? plugin[0] : plugin + } + + export function pluginOptions(plugin: PluginSpec): PluginOptions | undefined { + return Array.isArray(plugin) ? plugin[1] : undefined + } + + export function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): PluginSpec { + const spec = pluginSpecifier(plugin) + try { + const resolved = import.meta.resolve!(spec, configFilepath) + if (Array.isArray(plugin)) return [resolved, plugin[1]] + return resolved + } catch { + try { + const require = createRequire(configFilepath) + const resolved = pathToFileURL(require.resolve(spec)).href + if (Array.isArray(plugin)) return [resolved, plugin[1]] + return resolved + } catch { + return plugin + } + } + } + /** * Extracts a canonical plugin name from a plugin specifier. * - For file:// URLs: extracts filename without extension @@ -472,15 +503,16 @@ export namespace Config { * getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode" * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" */ - export function getPluginName(plugin: string): string { - if (plugin.startsWith("file://")) { - return path.parse(new URL(plugin).pathname).name + export function getPluginName(plugin: PluginSpec): string { + const spec = pluginSpecifier(plugin) + if (spec.startsWith("file://")) { + return path.parse(new URL(spec).pathname).name } - const lastAt = plugin.lastIndexOf("@") + const lastAt = spec.lastIndexOf("@") if (lastAt > 0) { - return plugin.substring(0, lastAt) + return spec.substring(0, lastAt) } - return plugin + return spec } /** @@ -494,14 +526,14 @@ export namespace Config { * Since plugins are added in low-to-high priority order, * we reverse, deduplicate (keeping first occurrence), then restore order. */ - export function deduplicatePlugins(plugins: string[]): string[] { + export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] { // seenNames: canonical plugin names for duplicate detection // e.g., "oh-my-opencode", "@scope/pkg" const seenNames = new Set() // uniqueSpecifiers: full plugin specifiers to return - // e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js" - const uniqueSpecifiers: string[] = [] + // e.g., "oh-my-opencode@2.4.3", ["file:///path/to/plugin.js", { ... }] + const uniqueSpecifiers: PluginSpec[] = [] for (const specifier of plugins.toReversed()) { const name = getPluginName(specifier) @@ -997,7 +1029,7 @@ export namespace Config { ignore: z.array(z.string()).optional(), }) .optional(), - plugin: z.string().array().optional(), + plugin: PluginSpec.array().optional(), snapshot: z.boolean().optional(), share: z .enum(["manual", "auto", "disabled"]) @@ -1245,19 +1277,7 @@ export namespace Config { const data = parsed.data if (data.plugin && isFile) { for (let i = 0; i < data.plugin.length; i++) { - const plugin = data.plugin[i] - try { - data.plugin[i] = import.meta.resolve!(plugin, options.path) - } catch (e) { - try { - // import.meta.resolve sometimes fails with newly created node_modules - const require = createRequire(options.path) - const resolvedPath = require.resolve(plugin) - data.plugin[i] = pathToFileURL(resolvedPath).href - } catch { - // Ignore, plugin might be a generic string identifier like "mcp-server" - } - } + data.plugin[i] = resolvePluginSpec(data.plugin[i], options.path) } } return data diff --git a/packages/opencode/src/config/tui-schema.ts b/packages/opencode/src/config/tui-schema.ts index f9068e3f01..1637b27c79 100644 --- a/packages/opencode/src/config/tui-schema.ts +++ b/packages/opencode/src/config/tui-schema.ts @@ -29,6 +29,7 @@ export const TuiInfo = z $schema: z.string().optional(), theme: z.string().optional(), keybinds: KeybindOverride.optional(), + plugin: Config.PluginSpec.array().optional(), }) .extend(TuiOptions.shape) .strict() diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index f0964f63b3..60ddd8bc6c 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -18,7 +18,11 @@ export namespace TuiConfig { export type Info = z.output function mergeInfo(target: Info, source: Info): Info { - return mergeDeep(target, source) + const merged = mergeDeep(target, source) + if (target.plugin && source.plugin) { + merged.plugin = [...target.plugin, ...source.plugin] + } + return merged } function customPath() { @@ -67,9 +71,23 @@ export namespace TuiConfig { } result.keybinds = Config.Keybinds.parse(result.keybinds ?? {}) + result.plugin = Config.deduplicatePlugins(result.plugin ?? []) + + const deps: Promise[] = [] + for (const dir of unique(directories)) { + if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue + deps.push( + (async () => { + const shouldInstall = await Config.needsInstall(dir) + if (!shouldInstall) return + await Config.installDependencies(dir) + })(), + ) + } return { config: result, + deps, } }) @@ -77,6 +95,11 @@ export namespace TuiConfig { return state().then((x) => x.config) } + export async function waitForDependencies() { + const deps = await state().then((x) => x.deps) + await Promise.all(deps) + } + async function loadFile(filepath: string): Promise { const text = await ConfigPaths.readFile(filepath) if (!text) return {} @@ -87,13 +110,13 @@ export namespace TuiConfig { } async function load(text: string, configFilepath: string): Promise { - const data = await ConfigPaths.parseText(text, configFilepath, "empty") - if (!data || typeof data !== "object" || Array.isArray(data)) return {} + const raw = await ConfigPaths.parseText(text, configFilepath, "empty") + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {} // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json // (mirroring the old opencode.json shape) still get their settings applied. const normalized = (() => { - const copy = { ...(data as Record) } + const copy = { ...(raw as Record) } if (!("tui" in copy)) return copy if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) { delete copy.tui @@ -113,6 +136,13 @@ export namespace TuiConfig { return {} } - return parsed.data + const data = parsed.data + if (data.plugin) { + for (let i = 0; i < data.plugin.length; i++) { + data.plugin[i] = Config.resolvePluginSpec(data.plugin[i], configFilepath) + } + } + + return data } } diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 1c129f6082..a9f3c56e4f 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -54,48 +54,75 @@ export namespace Plugin { plugins = [...BUILTIN, ...plugins] } - for (let plugin of plugins) { - // ignore old codex plugin since it is supported first party now - if (plugin.includes("opencode-openai-codex-auth") || plugin.includes("opencode-copilot-auth")) continue - log.info("loading plugin", { path: plugin }) - if (!plugin.startsWith("file://")) { - const lastAtIndex = plugin.lastIndexOf("@") - const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin - const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest" - plugin = await BunProc.install(pkg, version).catch((err) => { - const cause = err instanceof Error ? err.cause : err - const detail = cause instanceof Error ? cause.message : String(cause ?? err) - log.error("failed to install plugin", { pkg, version, error: detail }) - Bus.publish(Session.Event.Error, { - error: new NamedError.Unknown({ - message: `Failed to install plugin ${pkg}@${version}: ${detail}`, - }).toObject(), - }) - return "" + async function resolve(spec: string) { + if (spec.startsWith("file://")) return spec + const lastAtIndex = spec.lastIndexOf("@") + const pkg = lastAtIndex > 0 ? spec.substring(0, lastAtIndex) : spec + const version = lastAtIndex > 0 ? spec.substring(lastAtIndex + 1) : "latest" + const builtIn = BUILTIN.some((x) => x.startsWith(pkg + "@")) + const installed = await BunProc.install(pkg, version).catch((err) => { + const cause = err instanceof Error ? err.cause : err + const detail = cause instanceof Error ? cause.message : String(cause ?? err) + log.error("failed to install plugin", { pkg, version, error: detail }) + const label = builtIn ? "built-in plugin" : "plugin" + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to install ${label} ${pkg}@${version}: ${detail}`, + }).toObject(), }) - if (!plugin) continue - } + return "" + }) + if (!installed) return + return installed + } + + for (const item of plugins) { + const spec = Config.pluginSpecifier(item) + // ignore old codex plugin since it is supported first party now + if (spec.includes("opencode-openai-codex-auth") || spec.includes("opencode-copilot-auth")) continue + log.info("loading plugin", { path: spec }) + const path = await resolve(spec) + if (!path) continue + const mod = await import(path).catch((err) => { + const message = err instanceof Error ? err.message : String(err) + log.error("failed to load plugin", { path: spec, error: message }) + Bus.publish(Session.Event.Error, { + error: new NamedError.Unknown({ + message: `Failed to load plugin ${spec}: ${message}`, + }).toObject(), + }) + return + }) + if (!mod) continue + // Prevent duplicate initialization when plugins export the same function // as both a named export and default export (e.g., `export const X` and `export default X`). // Object.entries(mod) would return both entries pointing to the same function reference. - await import(plugin) - .then(async (mod) => { - const seen = new Set() - for (const [_name, fn] of Object.entries(mod)) { - if (seen.has(fn)) continue - seen.add(fn) - hooks.push(await fn(input)) - } - }) - .catch((err) => { + const seen = new Set() + for (const entry of Object.values(mod)) { + if (seen.has(entry)) continue + seen.add(entry) + const server = (() => { + if (typeof entry === "function") return entry as PluginInstance + if (!entry || typeof entry !== "object") return + if (!("server" in entry)) return + if (typeof entry.server !== "function") return + return entry.server as PluginInstance + })() + if (!server) continue + const init = await server(input, Config.pluginOptions(item)).catch((err) => { const message = err instanceof Error ? err.message : String(err) - log.error("failed to load plugin", { path: plugin, error: message }) + log.error("failed to initialize plugin", { path: spec, error: message }) Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ - message: `Failed to load plugin ${plugin}: ${message}`, + message: `Failed to initialize plugin ${spec}: ${message}`, }).toObject(), }) + return }) + if (!init) continue + hooks.push(init) + } } return { diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts index f9de5b041b..b928c7c51d 100644 --- a/packages/opencode/test/config/tui.test.ts +++ b/packages/opencode/test/config/tui.test.ts @@ -508,3 +508,57 @@ test("gracefully falls back when tui.json has invalid JSON", async () => { }, }) }) + +test("supports tuple plugin specs with options in tui.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + plugin: [["acme-plugin@1.2.3", { enabled: true, label: "demo" }]], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.plugin).toEqual([["acme-plugin@1.2.3", { enabled: true, label: "demo" }]]) + }, + }) +}) + +test("deduplicates tuple plugin specs by name with higher precedence winning", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(Global.Path.config, "tui.json"), + JSON.stringify({ + plugin: [["acme-plugin@1.0.0", { source: "global" }]], + }), + ) + await Bun.write( + path.join(dir, "tui.json"), + JSON.stringify({ + plugin: [ + ["acme-plugin@2.0.0", { source: "project" }], + ["second-plugin@3.0.0", { source: "project" }], + ], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await TuiConfig.get() + expect(config.plugin).toEqual([ + ["acme-plugin@2.0.0", { source: "project" }], + ["second-plugin@3.0.0", { source: "project" }], + ]) + }, + }) +}) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 3f1f6af95f..7ab04901cb 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -16,6 +16,7 @@ "dist" ], "dependencies": { + "@opentui/core": "0.1.86", "@opencode-ai/sdk": "workspace:*", "zod": "catalog:" }, diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 76370d1d5a..ea07082000 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -9,13 +9,16 @@ import type { Message, Part, Auth, - Config, + Config as SDKConfig, } from "@opencode-ai/sdk" +import type { createOpencodeClient as createOpencodeClientV2, Event as TuiEvent } from "@opencode-ai/sdk/v2" +import type { CliRenderer, Plugin as SlotPlugin } from "@opentui/core" import type { BunShell } from "./shell" import { type ToolDefinition } from "./tool" export * from "./tool" +export type { CliRenderer, SlotMode } from "@opentui/core" export type ProviderContext = { source: "env" | "config" | "custom" | "api" @@ -32,7 +35,80 @@ export type PluginInput = { $: BunShell } -export type Plugin = (input: PluginInput) => Promise +export type PluginOptions = Record + +export type Config = Omit & { + plugin?: Array +} + +type HexColor = `#${string}` +type RefName = string +type Variant = { + dark: HexColor | RefName | number + light: HexColor | RefName | number +} +type ThemeColorValue = HexColor | RefName | number | Variant + +export type ThemeJson = { + $schema?: string + defs?: Record + theme: Record & { + selectedListItemText?: ThemeColorValue + backgroundMenu?: ThemeColorValue + thinkingOpacity?: number + } +} + +export type TuiSlotMap = { + home_hint: {} + home_footer: {} + session_footer: { + session_id: string + } +} + +export type TuiSlotContext = { + url: string + directory?: string +} + +export type TuiSlotPlugin = SlotPlugin + +export type TuiSlots = { + register: (plugin: TuiSlotPlugin) => () => void +} + +export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise + +export type TuiEventBus = { + on: ( + type: Type, + handler: (event: Extract) => void, + ) => () => void +} + +export type TuiPluginInput = { + client: ReturnType + event: TuiEventBus + url: string + directory?: string + renderer: Renderer + slots: TuiSlots +} + +export type TuiPlugin = ( + input: TuiPluginInput, + options?: PluginOptions, +) => Promise + +export type PluginModule = + | Plugin + | { + server?: Plugin + tui?: TuiPlugin + slots?: TuiSlotPlugin + themes?: Record + } export type AuthHook = { provider: string