From 27090c122dc692bea1b2d4bbc6926c8ce6d33f79 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Fri, 13 Feb 2026 16:28:54 +0100 Subject: [PATCH] split config --- packages/opencode/src/cli/cmd/tui/app.tsx | 63 ++--- packages/opencode/src/cli/cmd/tui/attach.ts | 8 + .../cli/cmd/tui/component/dialog-status.tsx | 8 +- .../src/cli/cmd/tui/component/tips.tsx | 6 +- .../src/cli/cmd/tui/context/keybind.tsx | 8 +- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 10 + .../src/cli/cmd/tui/context/theme.tsx | 65 +++++- .../src/cli/cmd/tui/context/tui-config.tsx | 9 + packages/opencode/src/cli/cmd/tui/plugin.ts | 72 ++++++ .../src/cli/cmd/tui/routes/session/index.tsx | 10 +- .../cli/cmd/tui/routes/session/permission.tsx | 5 +- packages/opencode/src/cli/cmd/tui/thread.ts | 8 + packages/opencode/src/config/config.ts | 192 ++++++++------- packages/opencode/src/config/paths.ts | 44 ++++ packages/opencode/src/config/tui.ts | 220 ++++++++++++++++++ packages/opencode/src/plugin/index.ts | 77 +++--- packages/opencode/test/config/config.test.ts | 137 +++++++++-- packages/opencode/test/config/tui.test.ts | 83 +++++++ packages/plugin/src/index.ts | 47 +++- packages/web/src/content/docs/config.mdx | 4 +- packages/web/src/content/docs/plugins.mdx | 144 +++++++++++- 21 files changed, 1018 insertions(+), 202 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/context/tui-config.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/plugin.ts create mode 100644 packages/opencode/src/config/paths.ts create mode 100644 packages/opencode/src/config/tui.ts create mode 100644 packages/opencode/test/config/tui.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index ab3d096892..97c910a47d 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/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: { - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 8b8979c831..09125ec4d8 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/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 ", @@ -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, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index 3b6b5ef218..e499be66cc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/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("/") diff --git a/packages/opencode/src/cli/cmd/tui/component/tips.tsx b/packages/opencode/src/cli/cmd/tui/component/tips.tsx index 7870ab2ea4..8c539d3b65 100644 --- a/packages/opencode/src/cli/cmd/tui/component/tips.tsx +++ b/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", diff --git a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx b/packages/opencode/src/cli/cmd/tui/context/keybind.tsx index 0dbbbc6f9e..9ec438b7f7 100644 --- a/packages/opencode/src/cli/cmd/tui/context/keybind.tsx +++ b/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>(() => { return pipe( - sync.data.config.keybinds ?? {}, + (config.keybinds ?? {}) as Record, mapValues((value) => Keybind.parse(value)), ) }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 7fa7e05c3d..9d74adfebd 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/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 }>() + 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 diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 41c5a4a831..49307a3ee3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/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 + listeners: Set<(themes: Record) => void> +} + +const registry: ThemeRegistry = { + themes: {}, + listeners: new Set(), +} + +export function registerThemes(themes: Record) { + const entries = Object.entries(themes).filter((entry): entry is [string, ThemeJson] => { + const theme = entry[1] + if (!theme || typeof theme !== "object") return false + if (!("theme" in theme)) return false + return true + }) + if (entries.length === 0) return + + for (const [name, theme] of entries) { + registry.themes[name] = theme + } + + const payload = Object.fromEntries(entries) + for (const handler of registry.listeners) { + handler(payload) + } +} + +function registeredThemes() { + return registry.themes +} + +function onThemes(handler: (themes: Record) => void) { + registry.listeners.add(handler) + return () => registry.listeners.delete(handler) +} + export const DEFAULT_THEMES: Record = { aura, ayu, @@ -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) { + setStore( + produce((draft) => { + for (const [name, theme] of Object.entries(themes)) { + if (draft.themes[name]) continue + draft.themes[name] = theme + } + }), + ) + } function resolveSystemTheme() { console.log("resolveSystemTheme") diff --git a/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx b/packages/opencode/src/cli/cmd/tui/context/tui-config.tsx new file mode 100644 index 0000000000..62dbf1ebd1 --- /dev/null +++ b/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 + }, +}) diff --git a/packages/opencode/src/cli/cmd/tui/plugin.ts b/packages/opencode/src/cli/cmd/tui/plugin.ts new file mode 100644 index 0000000000..3db8bb96af --- /dev/null +++ b/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 | 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() + 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 + })() + 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 }) + }) + } +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index b843bda1c9..b4408237da 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/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 + tui: ReturnType }>() 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, }} > @@ -1912,7 +1916,7 @@ function Edit(props: ToolProps) { 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) { 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" }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 9e79c76bf5..b52ed8f6a8 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/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" }) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 6d41fe857a..b6af7af9e8 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/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: { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8f0f583ea3..31903831b5 100644 --- a/packages/opencode/src/config/config.ts +++ b/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 + export type PluginSpec = z.infer 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("oh-my-opencode@2.4.3") // "oh-my-opencode" * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" */ - export function getPluginName(plugin: string): string { - if (plugin.startsWith("file://")) { - return path.parse(new URL(plugin).pathname).name + export function 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() // uniqueSpecifiers: full plugin specifiers to return - // e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js" - const uniqueSpecifiers: string[] = [] + // e.g., "oh-my-opencode@2.4.3", ["file:///path/to/plugin.js", { ... }] + const uniqueSpecifiers: PluginSpec[] = [] for (const specifier of plugins.toReversed()) { const name = getPluginName(specifier) @@ -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) } + 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 diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts new file mode 100644 index 0000000000..7d4cf567e4 --- /dev/null +++ b/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`)] + } +} diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts new file mode 100644 index 0000000000..e1da3d007a --- /dev/null +++ b/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 + + 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[] = [] + 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 { + let text = await Bun.file(filepath) + .text() + .catch(() => undefined) + if (!text) return {} + return load(text, filepath) + } + + async function load(text: string, configFilepath: string): Promise { + 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 + } +} diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 24dc695d63..c6cdc90ce0 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/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 + 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 - 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(), - }) - - 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() - for (const [_name, fn] of Object.entries(mod)) { - if (seen.has(fn)) continue - seen.add(fn) - const init = await fn(input) + const seen = new Set() + 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) } } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 91b87f6498..88097a165d 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/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).theme).toBeUndefined() + expect((config as Record).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) - const scopedEntry = pluginEntries.find((entry) => entry === expected) + expect(specs.includes(expected)).toBe(true) + + 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) }, }) }) diff --git a/packages/opencode/test/config/tui.test.ts b/packages/opencode/test/config/tui.test.ts new file mode 100644 index 0000000000..f1696d38ad --- /dev/null +++ b/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"]) + }, + }) +}) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index bd4ba53049..12e26979f4 100644 --- a/packages/plugin/src/index.ts +++ b/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 +export type PluginOptions = Record + +export type Config = Omit & { + plugin?: Array +} + +type HexColor = `#${string}` +type RefName = string +type Variant = { + dark: HexColor | RefName | number + light: HexColor | RefName | number +} +type ThemeColorValue = HexColor | RefName | number | Variant + +export type ThemeJson = { + $schema?: string + defs?: Record + theme: Record & { + selectedListItemText?: ThemeColorValue + backgroundMenu?: ThemeColorValue + thinkingOpacity?: number + } +} + +export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise + +export type TuiEventBus = { + on: ( + type: Type, + handler: (event: Extract) => void, + ) => () => void +} + +export type TuiPluginInput = { + client: ReturnType + event: TuiEventBus + url: string + directory?: string +} + +export type TuiPlugin = (input: TuiPluginInput, options?: PluginOptions) => Promise + +export type PluginModule = Plugin | { server?: Plugin; tui?: TuiPlugin; themes?: Record } export type AuthHook = { provider: string diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index eeccde2f79..8e6c1603e2 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/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" }]] } ``` diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index 411b827d22..7b857ca365 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/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. | ---