actual-tui-plugins
Sebastian Herrlinger 2026-03-03 22:40:39 +01:00
parent 89d6f60d25
commit b99e3efad2
13 changed files with 565 additions and 143 deletions

View File

@ -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 = <K extends keyof TuiSlotMap>(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 (
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
>
<ArgsProvider {...input.args}>
<ExitProvider onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</ErrorBoundary>
)
const renderer = await createCliRenderer(rendererConfig(input.config))
const registry = createSolidSlotRegistry<TuiSlotMap, TuiSlotContext>(renderer, {
url: input.url,
directory: input.directory,
})
const Slot = createSlot<TuiSlotMap, TuiSlotContext>(registry)
const slot: TuiSlot = (props) => Slot(props)
const slots: TuiSlots = {
register(plugin) {
return registry.register(plugin as SolidPlugin<TuiSlotMap, TuiSlotContext>)
},
{
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 (
<ErrorBoundary
fallback={(error, reset) => <ErrorComponent error={error} reset={reset} onExit={onExit} mode={mode} />}
>
<ArgsProvider {...input.args}>
<ExitProvider onExit={onExit}>
<KVProvider>
<ToastProvider>
<RouteProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
renderer={renderer}
slots={slots}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App slot={slot} />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
</ExitProvider>
</ArgsProvider>
</ErrorBoundary>
)
}, renderer)
})
}
function App() {
function App(props: { slot: TuiSlot }) {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
@ -766,10 +793,10 @@ function App() {
>
<Switch>
<Match when={route.data.type === "home"}>
<Home />
<Home slot={props.slot} />
</Match>
<Match when={route.data.type === "session"}>
<Session />
<Session slot={props.slot} />
</Match>
</Switch>
</box>

View File

@ -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<Event, { type: key }>
}>()
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

View File

@ -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<string, ThemeJson>
listeners: Set<(themes: Record<string, ThemeJson>) => void>
}
const registry: ThemeRegistry = {
themes: {},
listeners: new Set(),
}
export function registerThemes(themes: Record<string, unknown>) {
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<string, ThemeJson>) => void) {
registry.listeners.add(handler)
return () => registry.listeners.delete(handler)
}
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
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<string, ThemeJson>) {
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")

View File

@ -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<void> | 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<JSX.Element>
}
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<JSX.Element>
}
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<unknown>()
for (const entry of Object.values<PluginModule>(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<string, unknown>
})()
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 })
})
}
}

View File

@ -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 = <K extends "home_hint" | "home_footer">(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 = (
<Show when={connectedMcpCount() > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
<box flexShrink={0} flexDirection="row" gap={1}>
<Show when={connectedMcpCount() > 0}>
<text fg={theme.text}>
<Switch>
<Match when={mcpError()}>
@ -71,8 +74,9 @@ export function Home() {
</Match>
</Switch>
</text>
</box>
</Show>
</Show>
{props.slot({ name: "home_hint" }) as never}
</box>
)
let prompt: PromptRef
@ -150,6 +154,7 @@ export function Home() {
</Show>
</box>
<box flexGrow={1} />
{props.slot({ name: "home_footer" }) as never}
<box flexShrink={0}>
<text fg={theme.textMuted}>{Installation.VERSION}</text>
</box>

View File

@ -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}
</box>
</Show>
<Toast />

View File

@ -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<typeof PluginOptions>
export type PluginSpec = z.infer<typeof PluginSpec>
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<string>()
// 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

View File

@ -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()

View File

@ -18,7 +18,11 @@ export namespace TuiConfig {
export type Info = z.output<typeof Info>
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<void>[] = []
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<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
@ -87,13 +110,13 @@ export namespace TuiConfig {
}
async function load(text: string, configFilepath: string): Promise<Info> {
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<string, unknown>) }
const copy = { ...(raw as Record<string, unknown>) }
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
}
}

View File

@ -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<PluginInstance>()
for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
if (seen.has(fn)) continue
seen.add(fn)
hooks.push(await fn(input))
}
})
.catch((err) => {
const seen = new Set<unknown>()
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 {

View File

@ -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" }],
])
},
})
})

View File

@ -16,6 +16,7 @@
"dist"
],
"dependencies": {
"@opentui/core": "0.1.86",
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:"
},

View File

@ -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<Hooks>
export type PluginOptions = Record<string, unknown>
export type Config = Omit<SDKConfig, "plugin"> & {
plugin?: Array<string | [string, PluginOptions]>
}
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<string, HexColor | RefName>
theme: Record<string, ThemeColorValue> & {
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<Node = unknown> = SlotPlugin<Node, TuiSlotMap, TuiSlotContext>
export type TuiSlots = {
register: (plugin: TuiSlotPlugin) => () => void
}
export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>
export type TuiEventBus = {
on: <Type extends TuiEvent["type"]>(
type: Type,
handler: (event: Extract<TuiEvent, { type: Type }>) => void,
) => () => void
}
export type TuiPluginInput<Renderer = CliRenderer> = {
client: ReturnType<typeof createOpencodeClientV2>
event: TuiEventBus
url: string
directory?: string
renderer: Renderer
slots: TuiSlots
}
export type TuiPlugin<Renderer = CliRenderer> = (
input: TuiPluginInput<Renderer>,
options?: PluginOptions,
) => Promise<void>
export type PluginModule<Renderer = CliRenderer> =
| Plugin
| {
server?: Plugin
tui?: TuiPlugin<Renderer>
slots?: TuiSlotPlugin
themes?: Record<string, ThemeJson>
}
export type AuthHook = {
provider: string