Sebastian Herrlinger 1 lună în urmă
părinte
comite
b99e3efad2

+ 98 - 71
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 = <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>
-        )
-      },
-      {
-        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}`)
-            })
-          },
-        },
+    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>)
       },
-    )
+    }
+
+    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>

+ 16 - 0
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<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

+ 56 - 1
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<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")

+ 107 - 0
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<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 })
+    })
+  }
+}

+ 10 - 5
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 = <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>

+ 5 - 2
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}
             </box>
           </Show>
           <Toast />

+ 45 - 25
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<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("[email protected]") // "oh-my-opencode"
    * getPluginName("@scope/[email protected]") // "@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., "[email protected]", "file:///path/to/plugin.js"
-    const uniqueSpecifiers: string[] = []
+    // e.g., "[email protected]", ["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

+ 1 - 0
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()

+ 35 - 5
packages/opencode/src/config/tui.ts

@@ -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
   }
 }

+ 58 - 31
packages/opencode/src/plugin/index.ts

@@ -54,48 +54,75 @@ export namespace Plugin {
       plugins = [...BUILTIN, ...plugins]
     }
 
-    for (let plugin of plugins) {
+    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(),
+        })
+        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 (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 ""
+      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(),
         })
-        if (!plugin) continue
-      }
+        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 {

+ 54 - 0
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: [["[email protected]", { enabled: true, label: "demo" }]],
+        }),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.plugin).toEqual([["[email protected]", { 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: [["[email protected]", { source: "global" }]],
+        }),
+      )
+      await Bun.write(
+        path.join(dir, "tui.json"),
+        JSON.stringify({
+          plugin: [
+            ["[email protected]", { source: "project" }],
+            ["[email protected]", { source: "project" }],
+          ],
+        }),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.plugin).toEqual([
+        ["[email protected]", { source: "project" }],
+        ["[email protected]", { source: "project" }],
+      ])
+    },
+  })
+})

+ 1 - 0
packages/plugin/package.json

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

+ 78 - 2
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<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