Sebastian Herrlinger hai 2 meses
pai
achega
27090c122d

+ 34 - 29
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -38,6 +38,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
 import open from "open"
 import { writeHeapSnapshot } from "v8"
 import { PromptRefProvider, usePromptRef } from "./context/prompt"
+import { TuiConfigProvider } from "./context/tui-config"
+import { TuiConfig } from "@/config/tui"
 
 async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
   // can't set raw mode if not a TTY
@@ -104,6 +106,7 @@ import type { EventSource } from "./context/sdk"
 export function tui(input: {
   url: string
   args: Args
+  config: TuiConfig.Info
   directory?: string
   fetch?: typeof fetch
   headers?: RequestInit["headers"]
@@ -138,35 +141,37 @@ export function tui(input: {
                 <KVProvider>
                   <ToastProvider>
                     <RouteProvider>
-                      <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 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>

+ 8 - 0
packages/opencode/src/cli/cmd/tui/attach.ts

@@ -1,6 +1,9 @@
 import { cmd } from "../cmd"
 import { tui } from "./app"
 import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
+import { TuiConfig } from "@/config/tui"
+import { Instance } from "@/project/instance"
+import { existsSync } from "fs"
 
 export const AttachCommand = cmd({
   command: "attach <url>",
@@ -47,8 +50,13 @@ export const AttachCommand = cmd({
         const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
         return { Authorization: auth }
       })()
+      const config = await Instance.provide({
+        directory: directory && existsSync(directory) ? directory : process.cwd(),
+        fn: () => TuiConfig.get(),
+      })
       await tui({
         url: args.url,
+        config,
         args: { sessionID: args.session },
         directory,
         headers,

+ 6 - 2
packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx

@@ -4,19 +4,23 @@ import { useTheme } from "../context/theme"
 import { useDialog } from "@tui/ui/dialog"
 import { useSync } from "@tui/context/sync"
 import { For, Match, Switch, Show, createMemo } from "solid-js"
+import { useTuiConfig } from "../context/tui-config"
+import { Config } from "@/config/config"
 
 export type DialogStatusProps = {}
 
 export function DialogStatus() {
   const sync = useSync()
+  const config = useTuiConfig()
   const { theme } = useTheme()
   const dialog = useDialog()
 
   const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
 
   const plugins = createMemo(() => {
-    const list = sync.data.config.plugin ?? []
-    const result = list.map((value) => {
+    const list = config.plugin ?? []
+    const result = list.map((item) => {
+      const value = Config.pluginSpecifier(item)
       if (value.startsWith("file://")) {
         const path = fileURLToPath(value)
         const parts = path.split("/")

+ 3 - 3
packages/opencode/src/cli/cmd/tui/component/tips.tsx

@@ -80,11 +80,11 @@ const TIPS = [
   "Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
   "Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
   "Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
-  "Create {highlight}opencode.json{/highlight} in project root for project-specific settings",
-  "Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config",
+  "Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
+  "Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
   "Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
   "Configure {highlight}model{/highlight} in config to set your default model",
-  "Override any keybind in config via the {highlight}keybinds{/highlight} section",
+  "Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
   "Set any keybind to {highlight}none{/highlight} to disable it completely",
   "Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
   "OpenCode auto-handles OAuth for remote MCP servers requiring auth",

+ 4 - 4
packages/opencode/src/cli/cmd/tui/context/keybind.tsx

@@ -1,5 +1,4 @@
 import { createMemo } from "solid-js"
-import { useSync } from "@tui/context/sync"
 import { Keybind } from "@/util/keybind"
 import { pipe, mapValues } from "remeda"
 import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
@@ -7,14 +6,15 @@ import type { ParsedKey, Renderable } from "@opentui/core"
 import { createStore } from "solid-js/store"
 import { useKeyboard, useRenderer } from "@opentui/solid"
 import { createSimpleContext } from "./helper"
+import { useTuiConfig } from "./tui-config"
 
 export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
   name: "Keybind",
   init: () => {
-    const sync = useSync()
-    const keybinds = createMemo(() => {
+    const config = useTuiConfig()
+    const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
       return pipe(
-        sync.data.config.keybinds ?? {},
+        (config.keybinds ?? {}) as Record<string, string>,
         mapValues((value) => Keybind.parse(value)),
       )
     })

+ 10 - 0
packages/opencode/src/cli/cmd/tui/context/sdk.tsx

@@ -2,6 +2,7 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
 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
@@ -29,6 +30,15 @@ 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,
+    }).catch((error) => {
+      console.error("Failed to load TUI plugins", error)
+    })
+
     let queue: Event[] = []
     let timer: Timer | undefined
     let last = 0

+ 60 - 5
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -1,7 +1,6 @@
 import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
 import path from "path"
-import { createEffect, createMemo, onMount } from "solid-js"
-import { useSync } from "@tui/context/sync"
+import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
 import { createSimpleContext } from "./helper"
 import aura from "./theme/aura.json" with { type: "json" }
 import ayu from "./theme/ayu.json" with { type: "json" }
@@ -41,6 +40,7 @@ import { useRenderer } from "@opentui/solid"
 import { createStore, produce } from "solid-js/store"
 import { Global } from "@/global"
 import { Filesystem } from "@/util/filesystem"
+import { useTuiConfig } from "./tui-config"
 
 type ThemeColors = {
   primary: RGBA
@@ -137,6 +137,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,
@@ -279,22 +317,23 @@ function ansiToRgba(code: number): RGBA {
 export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
   name: "Theme",
   init: (props: { mode: "dark" | "light" }) => {
-    const sync = useSync()
+    const config = useTuiConfig()
     const kv = useKV()
     const [store, setStore] = createStore({
       themes: DEFAULT_THEMES,
       mode: kv.get("theme_mode", props.mode),
-      active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
+      active: (config.theme ?? kv.get("theme", "opencode")) as string,
       ready: false,
     })
 
     createEffect(() => {
-      const theme = sync.data.config.theme
+      const theme = config.theme
       if (theme) setStore("active", theme)
     })
 
     function init() {
       resolveSystemTheme()
+      mergeThemes(registeredThemes())
       getCustomThemes()
         .then((custom) => {
           setStore(
@@ -314,6 +353,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")

+ 9 - 0
packages/opencode/src/cli/cmd/tui/context/tui-config.tsx

@@ -0,0 +1,9 @@
+import { TuiConfig } from "@/config/tui"
+import { createSimpleContext } from "./helper"
+
+export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
+  name: "TuiConfig",
+  init: (props: { config: TuiConfig.Info }) => {
+    return props.config
+  },
+})

+ 72 - 0
packages/opencode/src/cli/cmd/tui/plugin.ts

@@ -0,0 +1,72 @@
+import type { TuiPlugin as TuiPluginFn, TuiPluginInput } from "@opencode-ai/plugin"
+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 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()
+
+        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)
+        }
+
+        for (const item of plugins) {
+          const spec = Config.pluginSpecifier(item)
+          log.info("loading tui plugin", { path: spec })
+          const path = await resolve(spec)
+          const mod = await import(path)
+          const seen = new Set<unknown>()
+          for (const [_name, entry] of Object.entries(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 tui = (() => {
+              if (typeof entry === "function") return
+              if (!entry || typeof entry !== "object") return
+              if ("tui" in entry && typeof entry.tui === "function") return entry.tui as TuiPluginFn
+              return
+            })()
+            if (!tui) continue
+            await tui(input, Config.pluginOptions(item))
+          }
+        }
+      },
+    }).catch((error) => {
+      log.error("failed to load tui plugins", { directory: dir, error })
+    })
+  }
+}

+ 7 - 3
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -78,6 +78,7 @@ import { QuestionPrompt } from "./question"
 import { DialogExportOptions } from "../../ui/dialog-export-options"
 import { formatTranscript } from "../../util/transcript"
 import { UI } from "@/cli/ui.ts"
+import { useTuiConfig } from "../../context/tui-config"
 
 addDefaultParsers(parsers.parsers)
 
@@ -100,6 +101,7 @@ const context = createContext<{
   showDetails: () => boolean
   diffWrapMode: () => "word" | "none"
   sync: ReturnType<typeof useSync>
+  tui: ReturnType<typeof useTuiConfig>
 }>()
 
 function use() {
@@ -112,6 +114,7 @@ export function Session() {
   const route = useRouteData("session")
   const { navigate } = useRoute()
   const sync = useSync()
+  const tuiConfig = useTuiConfig()
   const kv = useKV()
   const { theme } = useTheme()
   const promptRef = usePromptRef()
@@ -164,7 +167,7 @@ export function Session() {
   const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
 
   const scrollAcceleration = createMemo(() => {
-    const tui = sync.data.config.tui
+    const tui = tuiConfig.tui
     if (tui?.scroll_acceleration?.enabled) {
       return new MacOSScrollAccel()
     }
@@ -968,6 +971,7 @@ export function Session() {
         showDetails,
         diffWrapMode,
         sync,
+        tui: tuiConfig,
       }}
     >
       <box flexDirection="row">
@@ -1912,7 +1916,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
   const { theme, syntax } = useTheme()
 
   const view = createMemo(() => {
-    const diffStyle = ctx.sync.data.config.tui?.diff_style
+    const diffStyle = ctx.tui.tui?.diff_style
     if (diffStyle === "stacked") return "unified"
     // Default to "auto" behavior
     return ctx.width > 120 ? "split" : "unified"
@@ -1983,7 +1987,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
   const files = createMemo(() => props.metadata.files ?? [])
 
   const view = createMemo(() => {
-    const diffStyle = ctx.sync.data.config.tui?.diff_style
+    const diffStyle = ctx.tui.tui?.diff_style
     if (diffStyle === "stacked") return "unified"
     return ctx.width > 120 ? "split" : "unified"
   })

+ 3 - 2
packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx

@@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
 import { Locale } from "@/util/locale"
 import { Global } from "@/global"
 import { useDialog } from "../../ui/dialog"
+import { useTuiConfig } from "../../context/tui-config"
 
 type PermissionStage = "permission" | "always" | "reject"
 
@@ -48,14 +49,14 @@ function EditBody(props: { request: PermissionRequest }) {
   const themeState = useTheme()
   const theme = themeState.theme
   const syntax = themeState.syntax
-  const sync = useSync()
+  const config = useTuiConfig()
   const dimensions = useTerminalDimensions()
 
   const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
   const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
 
   const view = createMemo(() => {
-    const diffStyle = sync.data.config.tui?.diff_style
+    const diffStyle = config.tui?.diff_style
     if (diffStyle === "stacked") return "unified"
     return dimensions().width > 120 ? "split" : "unified"
   })

+ 8 - 0
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -10,6 +10,8 @@ import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
 import type { Event } from "@opencode-ai/sdk/v2"
 import type { EventSource } from "./context/sdk"
 import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
+import { TuiConfig } from "@/config/tui"
+import { Instance } from "@/project/instance"
 
 declare global {
   const OPENCODE_WORKER_PATH: string
@@ -133,6 +135,10 @@ export const TuiThreadCommand = cmd({
         if (!args.prompt) return piped
         return piped ? piped + "\n" + args.prompt : args.prompt
       })
+      const config = await Instance.provide({
+        directory: cwd,
+        fn: () => TuiConfig.get(),
+      })
 
       // Check if server should be started (port or hostname explicitly set in CLI or config)
       const networkOpts = await resolveNetworkOptions(args)
@@ -161,6 +167,8 @@ export const TuiThreadCommand = cmd({
 
       const tuiPromise = tui({
         url,
+        config,
+        directory: cwd,
         fetch: customFetch,
         events,
         args: {

+ 105 - 87
packages/opencode/src/config/config.ts

@@ -31,15 +31,21 @@ import { Event } from "../server/event"
 import { PackageRegistry } from "@/bun/registry"
 import { proxied } from "@/util/proxied"
 import { iife } from "@/util/iife"
+import { ConfigPaths } from "./paths"
 
 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())
+  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" })
 
   // Managed settings directory for enterprise deployments (highest priority, admin-controlled)
   // These settings override all user and project settings
-  function getManagedConfigDir(): string {
+  function systemManagedConfigDir(): string {
     switch (process.platform) {
       case "darwin":
         return "/Library/Application Support/opencode"
@@ -50,13 +56,17 @@ export namespace Config {
     }
   }
 
-  const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
+  export function managedConfigDir() {
+    return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
+  }
+
+  const managedDir = managedConfigDir()
 
   // Custom merge function that concatenates array fields instead of replacing them
   function mergeConfigConcatArrays(target: Info, source: Info): Info {
     const merged = mergeDeep(target, source)
     if (target.plugin && source.plugin) {
-      merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
+      merged.plugin = [...target.plugin, ...source.plugin]
     }
     if (target.instructions && source.instructions) {
       merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
@@ -107,11 +117,8 @@ export namespace Config {
 
     // Project config overrides global and remote config.
     if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
-      for (const file of ["opencode.jsonc", "opencode.json"]) {
-        const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
-        for (const resolved of found.toReversed()) {
-          result = mergeConfigConcatArrays(result, await loadFile(resolved))
-        }
+      for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
+        result = mergeConfigConcatArrays(result, await loadFile(file))
       }
     }
 
@@ -119,31 +126,10 @@ export namespace Config {
     result.mode = result.mode || {}
     result.plugin = result.plugin || []
 
-    const directories = [
-      Global.Path.config,
-      // Only scan project .opencode/ directories when project discovery is enabled
-      ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
-        ? await Array.fromAsync(
-            Filesystem.up({
-              targets: [".opencode"],
-              start: Instance.directory,
-              stop: Instance.worktree,
-            }),
-          )
-        : []),
-      // Always scan ~/.opencode/ (user home directory)
-      ...(await Array.fromAsync(
-        Filesystem.up({
-          targets: [".opencode"],
-          start: Global.Path.home,
-          stop: Global.Path.home,
-        }),
-      )),
-    ]
+    const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
 
     // .opencode directory config overrides (project and global) config sources.
     if (Flag.OPENCODE_CONFIG_DIR) {
-      directories.push(Flag.OPENCODE_CONFIG_DIR)
       log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
     }
 
@@ -184,9 +170,9 @@ export namespace Config {
     // Kept separate from directories array to avoid write operations when installing plugins
     // which would fail on system directories requiring elevated permissions
     // This way it only loads config file and not skills/plugins/commands
-    if (existsSync(managedConfigDir)) {
+    if (existsSync(managedDir)) {
       for (const file of ["opencode.jsonc", "opencode.json"]) {
-        result = mergeConfigConcatArrays(result, await loadFile(path.join(managedConfigDir, file)))
+        result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
       }
     }
 
@@ -225,8 +211,6 @@ export namespace Config {
       result.share = "auto"
     }
 
-    if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
-
     // Apply flag overrides for compaction settings
     if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
       result.compaction = { ...result.compaction, auto: false }
@@ -288,7 +272,7 @@ export namespace Config {
     }
   }
 
-  async function needsInstall(dir: string) {
+  export async function needsInstall(dir: string) {
     // Some config dirs may be read-only.
     // Installing deps there will fail; skip installation in that case.
     const writable = await isWritable(dir)
@@ -478,15 +462,35 @@ 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 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 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 spec
+  }
+
+  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 {
+      return plugin
     }
-    return plugin
   }
 
   /**
@@ -500,14 +504,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)
@@ -1004,10 +1008,7 @@ export namespace Config {
   export const Info = z
     .object({
       $schema: z.string().optional().describe("JSON schema reference for configuration validation"),
-      theme: z.string().optional().describe("Theme name to use for the interface"),
-      keybinds: Keybinds.optional().describe("Custom keybind configurations"),
       logLevel: Log.Level.optional().describe("Log level"),
-      tui: TUI.optional().describe("TUI specific settings"),
       server: Server.optional().describe("Server configuration for opencode serve and web commands"),
       command: z
         .record(z.string(), Command)
@@ -1019,7 +1020,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"])
@@ -1239,49 +1240,57 @@ export namespace Config {
     return load(text, filepath)
   }
 
-  async function load(text: string, configFilepath: string) {
-    const original = text
+  export async function substitute(text: string, configFilepath: string, missing: "error" | "empty" = "error") {
     text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
       return process.env[varName] || ""
     })
 
     const fileMatches = text.match(/\{file:[^}]+\}/g)
-    if (fileMatches) {
-      const configDir = path.dirname(configFilepath)
-      const lines = text.split("\n")
+    if (!fileMatches) return text
 
-      for (const match of fileMatches) {
-        const lineIndex = lines.findIndex((line) => line.includes(match))
-        if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
-          continue // Skip if line is commented
-        }
-        let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
-        if (filePath.startsWith("~/")) {
-          filePath = path.join(os.homedir(), filePath.slice(2))
-        }
-        const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
-        const fileContent = (
-          await Bun.file(resolvedPath)
-            .text()
-            .catch((error) => {
-              const errMsg = `bad file reference: "${match}"`
-              if (error.code === "ENOENT") {
-                throw new InvalidError(
-                  {
-                    path: configFilepath,
-                    message: errMsg + ` ${resolvedPath} does not exist`,
-                  },
-                  { cause: error },
-                )
-              }
-              throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
-            })
-        ).trim()
-        // escape newlines/quotes, strip outer quotes
-        text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
+    const configDir = path.dirname(configFilepath)
+    const lines = text.split("\n")
+
+    for (const match of fileMatches) {
+      const lineIndex = lines.findIndex((line) => line.includes(match))
+      if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) continue
+
+      let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
+      if (filePath.startsWith("~/")) {
+        filePath = path.join(os.homedir(), filePath.slice(2))
       }
+
+      const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
+      const fileContent = (
+        await Bun.file(resolvedPath)
+          .text()
+          .catch((error) => {
+            if (missing === "empty") return ""
+
+            const errMsg = `bad file reference: "${match}"`
+            if (error.code === "ENOENT") {
+              throw new InvalidError(
+                {
+                  path: configFilepath,
+                  message: errMsg + ` ${resolvedPath} does not exist`,
+                },
+                { cause: error },
+              )
+            }
+            throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
+          })
+      ).trim()
+
+      text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
     }
 
+    return text
+  }
+
+  async function load(text: string, configFilepath: string) {
+    const original = text
+    text = await substitute(text, configFilepath)
+
     const errors: JsoncParseError[] = []
     const data = parseJsonc(text, errors, { allowTrailingComma: true })
     if (errors.length) {
@@ -1306,7 +1315,19 @@ export namespace Config {
       })
     }
 
-    const parsed = Info.safeParse(data)
+    const normalized = (() => {
+      if (!data || typeof data !== "object" || Array.isArray(data)) return data
+      const copy = { ...(data as Record<string, unknown>) }
+      const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
+      if (!hadLegacy) return copy
+      delete copy.theme
+      delete copy.keybinds
+      delete copy.tui
+      log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: configFilepath })
+      return copy
+    })()
+
+    const parsed = Info.safeParse(normalized)
     if (parsed.success) {
       if (!parsed.data.$schema) {
         parsed.data.$schema = "https://opencode.ai/config.json"
@@ -1317,10 +1338,7 @@ export namespace Config {
       const data = parsed.data
       if (data.plugin) {
         for (let i = 0; i < data.plugin.length; i++) {
-          const plugin = data.plugin[i]
-          try {
-            data.plugin[i] = import.meta.resolve!(plugin, configFilepath)
-          } catch (err) {}
+          data.plugin[i] = resolvePluginSpec(data.plugin[i], configFilepath)
         }
       }
       return data

+ 44 - 0
packages/opencode/src/config/paths.ts

@@ -0,0 +1,44 @@
+import path from "path"
+import { Filesystem } from "@/util/filesystem"
+import { Flag } from "@/flag/flag"
+import { Global } from "@/global"
+
+export namespace ConfigPaths {
+  export async function projectFiles(name: string, directory: string, worktree: string) {
+    const files: string[] = []
+    for (const file of [`${name}.jsonc`, `${name}.json`]) {
+      const found = await Filesystem.findUp(file, directory, worktree)
+      for (const resolved of found.toReversed()) {
+        files.push(resolved)
+      }
+    }
+    return files
+  }
+
+  export async function directories(directory: string, worktree: string) {
+    return [
+      Global.Path.config,
+      ...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+        ? await Array.fromAsync(
+            Filesystem.up({
+              targets: [".opencode"],
+              start: directory,
+              stop: worktree,
+            }),
+          )
+        : []),
+      ...(await Array.fromAsync(
+        Filesystem.up({
+          targets: [".opencode"],
+          start: Global.Path.home,
+          stop: Global.Path.home,
+        }),
+      )),
+      ...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
+    ]
+  }
+
+  export function fileInDirectory(dir: string, name: string) {
+    return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
+  }
+}

+ 220 - 0
packages/opencode/src/config/tui.ts

@@ -0,0 +1,220 @@
+import path from "path"
+import { existsSync } from "fs"
+import z from "zod"
+import { parse as parseJsonc } from "jsonc-parser"
+import { mergeDeep, unique } from "remeda"
+import { Config } from "./config"
+import { ConfigPaths } from "./paths"
+import { Instance } from "@/project/instance"
+import { Flag } from "@/flag/flag"
+import { Log } from "@/util/log"
+import { Global } from "@/global"
+
+export namespace TuiConfig {
+  const log = Log.create({ service: "tui.config" })
+
+  export const Info = z
+    .object({
+      $schema: z.string().optional(),
+      theme: z.string().optional(),
+      keybinds: Config.Keybinds.optional(),
+      tui: Config.TUI.optional(),
+      plugin: z.array(z.union([z.string(), z.tuple([z.string(), z.record(z.string(), z.unknown())])])).optional(),
+    })
+    .strict()
+
+  export type Info = z.output<typeof Info>
+
+  function mergeInfo(target: Info, source: Info): Info {
+    const merged = mergeDeep(target, source)
+    if (target.plugin && source.plugin) {
+      merged.plugin = [...target.plugin, ...source.plugin]
+    }
+    return merged
+  }
+
+  function customPath() {
+    if (!Flag.OPENCODE_CONFIG) return
+    const file = path.basename(Flag.OPENCODE_CONFIG)
+    if (file === "tui.json" || file === "tui.jsonc") return Flag.OPENCODE_CONFIG
+    if (file === "opencode.jsonc") return path.join(path.dirname(Flag.OPENCODE_CONFIG), "tui.jsonc")
+    return path.join(path.dirname(Flag.OPENCODE_CONFIG), "tui.json")
+  }
+
+  const state = Instance.state(async () => {
+    let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+      ? []
+      : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
+    const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
+    const custom = customPath()
+    const managed = Config.managedConfigDir()
+    await migrateFromOpencode({ projectFiles, directories, custom, managed })
+    projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+      ? []
+      : await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
+
+    let result: Info = {}
+
+    for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
+      result = mergeInfo(result, await loadFile(file))
+    }
+
+    if (custom) {
+      result = mergeInfo(result, await loadFile(custom))
+      log.debug("loaded custom tui config", { path: custom })
+    }
+
+    for (const file of projectFiles) {
+      result = mergeInfo(result, await loadFile(file))
+    }
+
+    for (const dir of unique(directories)) {
+      if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
+      for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
+        result = mergeInfo(result, await loadFile(file))
+      }
+    }
+
+    if (existsSync(managed)) {
+      for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
+        result = mergeInfo(result, await loadFile(file))
+      }
+    }
+
+    result.keybinds ??= Config.Keybinds.parse({})
+    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,
+    }
+  })
+
+  export async function get() {
+    return state().then((x) => x.config)
+  }
+
+  export async function waitForDependencies() {
+    const deps = await state().then((x) => x.deps)
+    await Promise.all(deps)
+  }
+
+  async function migrateFromOpencode(input: {
+    projectFiles: string[]
+    directories: string[]
+    custom?: string
+    managed: string
+  }) {
+    const existing = await hasAnyTuiConfig(input)
+    if (existing) return
+
+    const opencode = await opencodeFiles(input)
+    for (const file of opencode) {
+      const source = await Bun.file(file)
+        .text()
+        .catch(() => undefined)
+      if (!source) continue
+      const data = parseJsonc(source)
+      if (!data || typeof data !== "object" || Array.isArray(data)) continue
+
+      const extracted = {
+        theme: "theme" in data ? (data.theme as string | undefined) : undefined,
+        keybinds: "keybinds" in data ? (data.keybinds as Info["keybinds"]) : undefined,
+        tui: "tui" in data ? (data.tui as Info["tui"]) : undefined,
+      }
+      if (!extracted.theme && !extracted.keybinds && !extracted.tui) continue
+
+      const target = path.join(path.dirname(file), "tui.json")
+      const targetExists = await Bun.file(target).exists()
+      if (targetExists) continue
+
+      const payload: Info = {
+        $schema: "https://opencode.ai/config.json",
+      }
+      if (extracted.theme) payload.theme = extracted.theme
+      if (extracted.keybinds) payload.keybinds = extracted.keybinds
+      if (extracted.tui) payload.tui = extracted.tui
+
+      await Bun.write(target, JSON.stringify(payload, null, 2))
+      log.info("migrated tui config", { from: file, to: target })
+    }
+  }
+
+  async function hasAnyTuiConfig(input: {
+    projectFiles: string[]
+    directories: string[]
+    custom?: string
+    managed: string
+  }) {
+    for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
+      if (await Bun.file(file).exists()) return true
+    }
+    if (input.projectFiles.length) return true
+    for (const dir of unique(input.directories)) {
+      if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
+      for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
+        if (await Bun.file(file).exists()) return true
+      }
+    }
+    if (input.custom && (await Bun.file(input.custom).exists())) return true
+    for (const file of ConfigPaths.fileInDirectory(input.managed, "tui")) {
+      if (await Bun.file(file).exists()) return true
+    }
+    return false
+  }
+
+  async function opencodeFiles(input: { directories: string[]; managed: string }) {
+    const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
+      ? []
+      : await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
+    const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
+    for (const dir of unique(input.directories)) {
+      files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
+    }
+    if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
+    files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
+
+    const existing = await Promise.all(
+      unique(files).map(async (file) => {
+        const ok = await Bun.file(file).exists()
+        return ok ? file : undefined
+      }),
+    )
+    return existing.filter((file): file is string => !!file)
+  }
+
+  async function loadFile(filepath: string): Promise<Info> {
+    let text = await Bun.file(filepath)
+      .text()
+      .catch(() => undefined)
+    if (!text) return {}
+    return load(text, filepath)
+  }
+
+  async function load(text: string, configFilepath: string): Promise<Info> {
+    text = await Config.substitute(text, configFilepath, "empty")
+
+    const parsed = Info.safeParse(parseJsonc(text))
+    if (!parsed.success) return {}
+
+    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
+  }
+}

+ 45 - 32
packages/opencode/src/plugin/index.ts

@@ -51,43 +51,56 @@ 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"
-        const builtin = BUILTIN.some((x) => x.startsWith(pkg + "@"))
-        plugin = await BunProc.install(pkg, version).catch((err) => {
-          if (!builtin) throw err
-
-          const message = err instanceof Error ? err.message : String(err)
-          log.error("failed to install builtin plugin", {
-            pkg,
-            version,
-            error: message,
-          })
-          Bus.publish(Session.Event.Error, {
-            error: new NamedError.Unknown({
-              message: `Failed to install built-in plugin ${pkg}@${version}: ${message}`,
-            }).toObject(),
-          })
+    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) => {
+        if (!builtin) throw err
 
-          return ""
+        const message = err instanceof Error ? err.message : String(err)
+        log.error("failed to install builtin plugin", {
+          pkg,
+          version,
+          error: message,
         })
-        if (!plugin) continue
-      }
-      const mod = await import(plugin)
+        Bus.publish(Session.Event.Error, {
+          error: new NamedError.Unknown({
+            message: `Failed to install built-in plugin ${pkg}@${version}: ${message}`,
+          }).toObject(),
+        })
+
+        return ""
+      })
+      if (!installed) return
+      return installed
+    }
+
+    for (const item of plugins) {
+      // ignore old codex plugin since it is supported first party now
+      const spec = Config.pluginSpecifier(item)
+      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)
       // 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.
-      const seen = new Set<PluginInstance>()
-      for (const [_name, fn] of Object.entries<PluginInstance>(mod)) {
-        if (seen.has(fn)) continue
-        seen.add(fn)
-        const init = await fn(input)
+      const seen = new Set<unknown>()
+      for (const [_name, entry] of Object.entries(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 && typeof entry.server === "function") return entry.server as PluginInstance
+          return
+        })()
+        if (!server) continue
+        const init = await server(input, Config.pluginOptions(item))
         hooks.push(init)
       }
     }

+ 112 - 25
packages/opencode/test/config/config.test.ts

@@ -24,6 +24,9 @@ async function writeConfig(dir: string, config: object, name = "opencode.json")
   await Bun.write(path.join(dir, name), JSON.stringify(config))
 }
 
+const spec = (plugin: Config.PluginSpec) => Config.pluginSpecifier(plugin)
+const name = (plugin: Config.PluginSpec) => Config.getPluginName(plugin)
+
 test("loads config with defaults when no files exist", async () => {
   await using tmp = await tmpdir()
   await Instance.provide({
@@ -55,6 +58,28 @@ test("loads JSON config file", async () => {
   })
 })
 
+test("ignores legacy tui keys in opencode config", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await writeConfig(dir, {
+        $schema: "https://opencode.ai/config.json",
+        model: "test/model",
+        theme: "legacy",
+        tui: { scroll_speed: 4 },
+      })
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.model).toBe("test/model")
+      expect((config as Record<string, unknown>).theme).toBeUndefined()
+      expect((config as Record<string, unknown>).tui).toBeUndefined()
+    },
+  })
+})
+
 test("loads JSONC config file", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
@@ -109,14 +134,14 @@ test("merges multiple config files with correct precedence", async () => {
 
 test("handles environment variable substitution", async () => {
   const originalEnv = process.env["TEST_VAR"]
-  process.env["TEST_VAR"] = "test_theme"
+  process.env["TEST_VAR"] = "test-user"
 
   try {
     await using tmp = await tmpdir({
       init: async (dir) => {
         await writeConfig(dir, {
           $schema: "https://opencode.ai/config.json",
-          theme: "{env:TEST_VAR}",
+          username: "{env:TEST_VAR}",
         })
       },
     })
@@ -124,7 +149,7 @@ test("handles environment variable substitution", async () => {
       directory: tmp.path,
       fn: async () => {
         const config = await Config.get()
-        expect(config.theme).toBe("test_theme")
+        expect(config.username).toBe("test-user")
       },
     })
   } finally {
@@ -147,7 +172,7 @@ test("preserves env variables when adding $schema to config", async () => {
         await Bun.write(
           path.join(dir, "opencode.json"),
           JSON.stringify({
-            theme: "{env:PRESERVE_VAR}",
+            username: "{env:PRESERVE_VAR}",
           }),
         )
       },
@@ -156,7 +181,7 @@ test("preserves env variables when adding $schema to config", async () => {
       directory: tmp.path,
       fn: async () => {
         const config = await Config.get()
-        expect(config.theme).toBe("secret_value")
+        expect(config.username).toBe("secret_value")
 
         // Read the file to verify the env variable was preserved
         const content = await Bun.file(path.join(tmp.path, "opencode.json")).text()
@@ -177,10 +202,10 @@ test("preserves env variables when adding $schema to config", async () => {
 test("handles file inclusion substitution", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
-      await Bun.write(path.join(dir, "included.txt"), "test_theme")
+      await Bun.write(path.join(dir, "included.txt"), "test-user")
       await writeConfig(dir, {
         $schema: "https://opencode.ai/config.json",
-        theme: "{file:included.txt}",
+        username: "{file:included.txt}",
       })
     },
   })
@@ -188,7 +213,7 @@ test("handles file inclusion substitution", async () => {
     directory: tmp.path,
     fn: async () => {
       const config = await Config.get()
-      expect(config.theme).toBe("test_theme")
+      expect(config.username).toBe("test-user")
     },
   })
 })
@@ -199,7 +224,7 @@ test("handles file inclusion with replacement tokens", async () => {
       await Bun.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
       await writeConfig(dir, {
         $schema: "https://opencode.ai/config.json",
-        theme: "{file:included.md}",
+        username: "{file:included.md}",
       })
     },
   })
@@ -207,7 +232,7 @@ test("handles file inclusion with replacement tokens", async () => {
     directory: tmp.path,
     fn: async () => {
       const config = await Config.get()
-      expect(config.theme).toBe("const out = await Bun.$`echo hi`")
+      expect(config.username).toBe("const out = await Bun.$`echo hi`")
     },
   })
 })
@@ -690,15 +715,79 @@ test("resolves scoped npm plugins in config", async () => {
       const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
       const expected = import.meta.resolve("@scope/plugin", baseUrl)
 
-      expect(pluginEntries.includes(expected)).toBe(true)
+      const specs = pluginEntries.map(spec)
+
+      expect(specs.includes(expected)).toBe(true)
 
-      const scopedEntry = pluginEntries.find((entry) => entry === expected)
+      const scopedEntry = specs.find((entry) => entry === expected)
       expect(scopedEntry).toBeDefined()
       expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
     },
   })
 })
 
+test("preserves plugin options while resolving specifiers", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
+      await fs.mkdir(pluginDir, { recursive: true })
+
+      await Bun.write(
+        path.join(dir, "package.json"),
+        JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
+      )
+
+      await Bun.write(
+        path.join(pluginDir, "package.json"),
+        JSON.stringify(
+          {
+            name: "@scope/plugin",
+            version: "1.0.0",
+            type: "module",
+            main: "./index.js",
+          },
+          null,
+          2,
+        ),
+      )
+
+      await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n")
+
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify(
+          {
+            $schema: "https://opencode.ai/config.json",
+            plugin: [["@scope/plugin", { mode: "tui", nested: { foo: "bar" } }]],
+          },
+          null,
+          2,
+        ),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      const pluginEntries = config.plugin ?? []
+      const entry = pluginEntries.find(
+        (item) => Array.isArray(item) && spec(item).includes("/node_modules/@scope/plugin/"),
+      )
+
+      expect(entry).toBeDefined()
+      if (!entry || !Array.isArray(entry)) return
+
+      const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
+      const expected = import.meta.resolve("@scope/plugin", baseUrl)
+
+      expect(entry[0]).toBe(expected)
+      expect(entry[1]).toEqual({ mode: "tui", nested: { foo: "bar" } })
+    },
+  })
+})
+
 test("merges plugin arrays from global and local configs", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {
@@ -734,12 +823,12 @@ test("merges plugin arrays from global and local configs", async () => {
       const plugins = config.plugin ?? []
 
       // Should contain both global and local plugins
-      expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
-      expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
-      expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
+      expect(plugins.some((p) => name(p) === "global-plugin-1")).toBe(true)
+      expect(plugins.some((p) => name(p) === "global-plugin-2")).toBe(true)
+      expect(plugins.some((p) => name(p) === "local-plugin-1")).toBe(true)
 
       // Should have all 3 plugins (not replaced, but merged)
-      const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
+      const pluginNames = plugins.filter((p) => name(p).includes("global-plugin") || name(p).includes("local-plugin"))
       expect(pluginNames.length).toBeGreaterThanOrEqual(3)
     },
   })
@@ -893,17 +982,17 @@ test("deduplicates duplicate plugins from global and local configs", async () =>
       const plugins = config.plugin ?? []
 
       // Should contain all unique plugins
-      expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
-      expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
-      expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
+      expect(plugins.some((p) => name(p) === "global-plugin-1")).toBe(true)
+      expect(plugins.some((p) => name(p) === "local-plugin-1")).toBe(true)
+      expect(plugins.some((p) => name(p) === "duplicate-plugin")).toBe(true)
 
       // Should deduplicate the duplicate plugin
-      const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
+      const duplicatePlugins = plugins.filter((p) => name(p) === "duplicate-plugin")
       expect(duplicatePlugins.length).toBe(1)
 
       // Should have exactly 3 unique plugins
-      const pluginNames = plugins.filter(
-        (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
+      const pluginNames = plugins.filter((p) =>
+        ["global-plugin-1", "local-plugin-1", "duplicate-plugin"].includes(name(p)),
       )
       expect(pluginNames.length).toBe(3)
     },
@@ -1042,7 +1131,6 @@ test("managed settings override project settings", async () => {
         $schema: "https://opencode.ai/config.json",
         autoupdate: true,
         disabled_providers: [],
-        theme: "dark",
       })
     },
   })
@@ -1059,7 +1147,6 @@ test("managed settings override project settings", async () => {
       const config = await Config.get()
       expect(config.autoupdate).toBe(false)
       expect(config.disabled_providers).toEqual(["openai"])
-      expect(config.theme).toBe("dark")
     },
   })
 })
@@ -1596,7 +1683,7 @@ describe("deduplicatePlugins", () => {
 
         const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
         expect(myPlugins.length).toBe(1)
-        expect(myPlugins[0].startsWith("file://")).toBe(true)
+        expect(spec(myPlugins[0]).startsWith("file://")).toBe(true)
       },
     })
   })

+ 83 - 0
packages/opencode/test/config/tui.test.ts

@@ -0,0 +1,83 @@
+import { afterEach, expect, test } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { tmpdir } from "../fixture/fixture"
+import { Instance } from "../../src/project/instance"
+import { TuiConfig } from "../../src/config/tui"
+import { Global } from "../../src/global"
+
+afterEach(async () => {
+  delete process.env.OPENCODE_CONFIG
+  await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
+  await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
+})
+
+test("loads tui config with the same precedence order as server config paths", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
+      await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2))
+      await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
+      await Bun.write(
+        path.join(dir, ".opencode", "tui.json"),
+        JSON.stringify({ theme: "local", tui: { diff_style: "stacked" } }, null, 2),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBe("local")
+      expect(config.tui?.diff_style).toBe("stacked")
+    },
+  })
+})
+
+test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify(
+          {
+            theme: "migrated-theme",
+            tui: { scroll_speed: 5 },
+            keybinds: { app_exit: "ctrl+q" },
+          },
+          null,
+          2,
+        ),
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.theme).toBe("migrated-theme")
+      expect(config.tui?.scroll_speed).toBe(5)
+      expect(config.keybinds?.app_exit).toBe("ctrl+q")
+      expect(await Bun.file(path.join(tmp.path, "tui.json")).exists()).toBe(true)
+    },
+  })
+})
+
+test("only reads plugin list from tui.json", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["server-only"] }, null, 2))
+      await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ plugin: ["tui-only"] }, null, 2))
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await TuiConfig.get()
+      expect(config.plugin).toEqual(["tui-only"])
+    },
+  })
+})

+ 45 - 2
packages/plugin/src/index.ts

@@ -9,8 +9,9 @@ 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 { BunShell } from "./shell"
 import { type ToolDefinition } from "./tool"
@@ -32,7 +33,49 @@ 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 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 = {
+  client: ReturnType<typeof createOpencodeClientV2>
+  event: TuiEventBus
+  url: string
+  directory?: string
+}
+
+export type TuiPlugin = (input: TuiPluginInput, options?: PluginOptions) => Promise<void>
+
+export type PluginModule = Plugin | { server?: Plugin; tui?: TuiPlugin; themes?: Record<string, ThemeJson> }
 
 export type AuthHook = {
   provider: string

+ 3 - 1
packages/web/src/content/docs/config.mdx

@@ -540,10 +540,12 @@ You can configure MCP servers you want to use through the `mcp` option.
 
 Place plugin files in `.opencode/plugins/` or `~/.config/opencode/plugins/`. You can also load plugins from npm through the `plugin` option.
 
+Each entry can be a string specifier or a `[specifier, options]` tuple. Options are passed to the plugin initializer.
+
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["opencode-helicone-session", "@my-org/custom-plugin"]
+  "plugin": ["opencode-helicone-session", ["@my-org/custom-plugin", { "arbitrary": "options" }]]
 }
 ```
 

+ 137 - 7
packages/web/src/content/docs/plugins.mdx

@@ -9,6 +9,23 @@ For examples, check out the [plugins](/docs/ecosystem#plugins) created by the co
 
 ---
 
+## System overview
+
+OpenCode plugins support two entry points:
+
+- `server` (loaded by the OpenCode server from `opencode.json`)
+- `tui` (loaded by the terminal UI from `tui.json`)
+
+A plugin can implement either entry point, or both in the same module.
+
+In short:
+
+- **v1 compatibility**: a default exported function is treated as a server plugin.
+- **v2 format**: export an object with `server` and/or `tui` keys.
+- **TUI plugin scope**: only plugins listed in `tui.json` are loaded for the TUI.
+
+---
+
 ## Use a plugin
 
 There are two ways to load plugins.
@@ -33,7 +50,11 @@ Specify npm packages in your config file.
 ```json title="opencode.json"
 {
   "$schema": "https://opencode.ai/config.json",
-  "plugin": ["opencode-helicone-session", "opencode-wakatime", "@my-org/custom-plugin"]
+  "plugin": [
+    "opencode-helicone-session",
+    ["opencode-wakatime", { "project": "vault-33" }],
+    ["@my-org/custom-plugin", { "arbitrary": "options" }]
+  ]
 }
 ```
 
@@ -53,14 +74,25 @@ Browse available plugins in the [ecosystem](/docs/ecosystem#plugins).
 
 ### Load order
 
-Plugins are loaded from all sources and all hooks run in sequence. The load order is:
+Plugins are loaded from all sources and all hooks run in sequence. When the same plugin appears multiple times, **higher-priority sources win**:
 
-1. Global config (`~/.config/opencode/opencode.json`)
+1. Project plugin directory (`.opencode/plugins/`)
 2. Project config (`opencode.json`)
 3. Global plugin directory (`~/.config/opencode/plugins/`)
-4. Project plugin directory (`.opencode/plugins/`)
+4. Global config (`~/.config/opencode/opencode.json`)
 
-Duplicate npm packages with the same name and version are loaded once. However, a local plugin and an npm plugin with similar names are both loaded separately.
+Plugins are deduplicated by canonical name (package name or local file name). A higher‑priority local file named `my-plugin.js` will override a lower‑priority npm package named `my-plugin`.
+
+---
+
+### Plugin options from config
+
+Each entry in the `plugin` array can be either:
+
+- A string specifier (package name or file URL), or
+- A tuple of `[specifier, options]`
+
+Options are passed as the **second argument** to your plugin initializer (both `server` and `tui`).
 
 ---
 
@@ -104,7 +136,7 @@ export const MyPlugin = async (ctx) => {
 ### Basic structure
 
 ```js title=".opencode/plugins/example.js"
-export const MyPlugin = async ({ project, client, $, directory, worktree }) => {
+export const MyPlugin = async ({ project, client, $, directory, worktree, serverUrl }, options) => {
   console.log("Plugin initialized!")
 
   return {
@@ -120,6 +152,66 @@ The plugin function receives:
 - `worktree`: The git worktree path.
 - `client`: An opencode SDK client for interacting with the AI.
 - `$`: Bun's [shell API](https://bun.com/docs/runtime/shell) for executing commands.
+- `serverUrl`: The server URL for the current OpenCode instance.
+
+If you provided options in config, they will be available as the second argument.
+
+---
+
+### TUI plugins
+
+Plugins can also export a `{ server, tui }` object. The server loader executes `server` (same as a normal plugin function). The TUI loader executes `tui` **only** when a TUI is running.
+
+```ts title=".opencode/plugins/example.ts"
+export const MyPlugin = {
+  server: async (ctx, options) => {
+    return {
+      // Server hooks
+    }
+  },
+  tui: async (ctx, options) => {
+    // TUI-only setup (subscribe to events, call client APIs, etc.)
+  },
+}
+```
+
+TUI input includes:
+
+- `client`: the SDK client for the connected server
+- `event`: an event bus for server events
+- `url`: server URL
+- `directory`: optional working directory
+
+---
+
+### Themes from plugins
+
+Plugins can register one or more TUI themes. Define them as `themes` on the exported object. The TUI will register them as soon as the plugin module is loaded.
+
+```ts title=".opencode/plugins/theme-pack.ts"
+export const MyPlugin = {
+  themes: {
+    "vault-tec": {
+      theme: {
+        primary: "#5ea9ff",
+        secondary: "#7cff7c",
+        accent: "#ffd06a",
+        // ...all required theme colors
+      },
+    },
+    "vault-tec-light": {
+      theme: {
+        primary: "#1b4b8a",
+        secondary: "#2f8a2f",
+        accent: "#a86a00",
+      },
+    },
+  },
+  tui: async () => {},
+}
+```
+
+Plugin themes are added to the theme list. If a theme name already exists (built‑in or custom), the existing theme takes precedence.
 
 ---
 
@@ -141,7 +233,7 @@ export const MyPlugin: Plugin = async ({ project, client, $, directory, worktree
 
 ### Events
 
-Plugins can subscribe to events as seen below in the Examples section. Here is a list of the different events available.
+Plugins can subscribe to events by implementing the `event` hook. The hook receives `{ event }`, where `event.type` is the event name and `event.data` contains the payload. Here is a list of the different events available.
 
 #### Command Events
 
@@ -206,6 +298,44 @@ Plugins can subscribe to events as seen below in the Examples section. Here is a
 - `tui.prompt.append`
 - `tui.command.execute`
 - `tui.toast.show`
+- `tui.session.select`
+
+These fire only when a TUI is connected or a client drives the TUI via `/tui/*` APIs.
+
+---
+
+## Hook reference
+
+Beyond the `event` hook, plugins can implement the following **stable** hooks:
+
+| Hook                     | Purpose                                                                            |
+| ------------------------ | ---------------------------------------------------------------------------------- |
+| `config`                 | Receives the merged config after startup.                                          |
+| `tool`                   | Register custom tools with `@opencode-ai/plugin`.                                  |
+| `auth`                   | Provide custom authentication flows for providers.                                 |
+| `chat.message`           | Runs when a new user message is received (modify `output.message`/`output.parts`). |
+| `chat.params`            | Modify LLM parameters such as temperature, topP, topK, or provider options.        |
+| `chat.headers`           | Inject custom request headers for provider calls.                                  |
+| `permission.ask`         | Decide whether a permission request should be allowed, denied, or asked.           |
+| `command.execute.before` | Modify or inject parts before a slash command executes.                            |
+| `tool.execute.before`    | Modify tool arguments before execution.                                            |
+| `tool.execute.after`     | Modify tool output metadata/title/text after execution.                            |
+| `shell.env`              | Inject environment variables into all shell executions.                            |
+
+---
+
+## Experimental hooks
+
+:::caution
+Experimental hooks are unstable and can change without notice.
+:::
+
+| Hook                                   | Purpose                                                        |
+| -------------------------------------- | -------------------------------------------------------------- |
+| `experimental.chat.messages.transform` | Transform the full list of message parts sent to the model.    |
+| `experimental.chat.system.transform`   | Modify system prompts before sending them to the model.        |
+| `experimental.session.compacting`      | Customize compaction context or replace the compaction prompt. |
+| `experimental.text.complete`           | Post-process generated text parts before they are committed.   |
 
 ---