export * as TuiConfig from "./tui" import path from "path" import { mergeDeep, unique } from "remeda" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Context, Effect, Fiber, Layer } from "effect" import { ConfigParse } from "@/config/parse" import * as ConfigPaths from "@/config/paths" import { migrateTuiConfig } from "./tui-migrate" import { resolveHostAttentionSoundPaths } from "./tui-host-attention" import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@opencode-ai/tui/util/record" import { Global } from "@opencode-ai/core/global" import { FSUtil } from "@opencode-ai/core/fs-util" import { CurrentWorkingDirectory } from "./tui-cwd" import { ConfigPlugin } from "@/config/plugin" import { TuiKeybind } from "@opencode-ai/tui/config/keybind" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { Filesystem } from "@/util/filesystem" import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" import { FormatError, FormatUnknownError } from "@/cli/error" import { TuiConfig } from "@opencode-ai/tui/config" export const Info = TuiConfig.Info export type Info = TuiConfig.Info type Acc = { result: Info plugin_origins: ConfigPlugin.Origin[] } export type Resolved = TuiConfig.Resolved export type HostMetadata = { plugin_origins?: ConfigPlugin.Origin[] } export interface Interface { readonly get: () => Effect.Effect readonly pluginOrigins: () => Effect.Effect readonly waitForDependencies: () => Effect.Effect } export class Service extends Context.Service()("@opencode/TuiConfig") {} function pluginScope(file: string, ctx: { directory: string }): ConfigPlugin.Scope { if (Filesystem.contains(ctx.directory, file)) return "local" // if (ctx.worktree !== "/" && Filesystem.contains(ctx.worktree, file)) return "local" return "global" } function normalize(raw: Record) { const data = { ...raw } if (!("tui" in data)) return data if (!isRecord(data.tui)) { delete data.tui return data } const tui = data.tui delete data.tui return { ...tui, ...data, } } function dropUnknownKeybinds(input: Record) { if (!isRecord(input.keybinds)) return input const invalid = TuiKeybind.unknownKeys(input.keybinds) if (!invalid.length) return input return { ...input, keybinds: Object.fromEntries(Object.entries(input.keybinds).filter(([key]) => !invalid.includes(key))), } } const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { const afs = yield* FSUtil.Service let appliedOrder = 0 const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect => Effect.gen(function* () { const plugins = config.plugin if (!plugins) return config return { ...config, plugin: yield* Effect.forEach(plugins, (plugin) => Effect.promise(() => ConfigPlugin.resolvePluginSpec(plugin as ConfigPlugin.Origin["spec"], configFilepath)), ), } }) const load = (text: string, configFilepath: string): Effect.Effect => Effect.gen(function* () { const expanded = yield* Effect.promise(() => ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), ) const data = ConfigParse.jsonc(expanded, configFilepath) if (!isRecord(data)) return {} as Info // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json // (mirroring the old opencode.json shape) still get their settings applied. const normalized = dropUnknownKeybinds(normalize(data)) const parsed = ConfigParse.schema(Info, normalized, configFilepath) const validated = parsed.attention?.sounds ? { ...parsed, attention: { ...parsed.attention, sounds: resolveHostAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds), }, } : parsed return yield* resolvePlugins(validated, configFilepath) }).pipe( // catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation // can sync-throw — those become defects, which orElseSucceed wouldn't catch. Effect.catchCause((cause) => Effect.logWarning("skipping invalid tui config", { path: configFilepath, reason: FormatError(Cause.squash(cause)) ?? FormatUnknownError(Cause.squash(cause)), }).pipe(Effect.as({} as Info)), ), ) const loadFile = (filepath: string): Effect.Effect => Effect.gen(function* () { // Silent-swallow non-NotFound read errors (perms, EISDIR, IO) → log + skip. // Matches how parse/schema/plugin failures in load() are handled — every // broken-config path degrades gracefully rather than crashing TUI startup. const text = yield* afs.readFileStringSafe(filepath).pipe( Effect.catchCause((cause) => Effect.logWarning("failed to read tui config", { path: filepath, reason: FormatError(Cause.squash(cause)) ?? FormatUnknownError(Cause.squash(cause)), }).pipe(Effect.as(undefined)), ), ) if (!text) return {} as Info yield* Effect.logInfo("loading tui config", { path: filepath }) return yield* load(text, filepath) }) const mergeFile = (acc: Acc, file: string) => Effect.gen(function* () { const data = yield* loadFile(file) if (Object.keys(data).length) { appliedOrder += 1 yield* Effect.logInfo("applying tui config", { path: file, order: appliedOrder }) } acc.result = mergeDeep(acc.result, data) if (!data.plugin?.length) return const scope = pluginScope(file, ctx) const plugins = ConfigPlugin.deduplicatePluginOrigins([ ...acc.plugin_origins, ...data.plugin.map((spec) => ({ spec: spec as ConfigPlugin.Origin["spec"], scope, source: file })), ]) acc.result = { ...acc.result, plugin: plugins.map((item) => item.spec), } acc.plugin_origins = plugins }) // Every config dir we may read from: global config dir, any `.opencode` // folders between cwd and home, and OPENCODE_CONFIG_DIR. const directories = yield* ConfigPaths.directories(ctx.directory) yield* Effect.promise(() => migrateTuiConfig({ directories, cwd: ctx.directory })) const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : yield* ConfigPaths.files("tui", ctx.directory) const acc: Acc = { result: {}, plugin_origins: [], } // 1. Global tui config (lowest precedence). for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { yield* mergeFile(acc, file) } // 2. Explicit OPENCODE_TUI_CONFIG override, if set. if (Flag.OPENCODE_TUI_CONFIG) { const configFile = Flag.OPENCODE_TUI_CONFIG yield* mergeFile(acc, configFile) yield* Effect.logDebug("loaded custom tui config", { path: configFile }) } // 3. Project tui files, applied root-first so the closest file wins. for (const file of projectFiles) { yield* mergeFile(acc, file) } // 4. `.opencode` directories (and OPENCODE_CONFIG_DIR) discovered while // walking up the tree. Also returned below so callers can install plugin // dependencies from each location. const dirs = unique(directories).filter((dir) => dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) for (const dir of dirs) { if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue for (const file of ConfigPaths.fileInDirectory(dir, "tui")) { yield* mergeFile(acc, file) } } const result = TuiConfig.resolve( { ...acc.result, }, { terminalSuspend: process.platform !== "win32", }, ) return { config: result, pluginOrigins: acc.plugin_origins, dirs: result.plugin?.length ? dirs : [], } }) const layer = Layer.effect( Service, Effect.gen(function* () { const directory = yield* CurrentWorkingDirectory const npm = yield* Npm.Service const data = yield* loadState({ directory }) const deps = yield* Effect.forEach( data.dirs, (dir) => npm .install(dir, { add: [ { name: "@opencode-ai/plugin", version: InstallationLocal ? undefined : InstallationVersion, }, ], }) .pipe(Effect.forkScoped), { concurrency: "unbounded", }, ) const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config)) const pluginOrigins = Effect.fn("TuiConfig.pluginOrigins")(() => Effect.succeed(data.pluginOrigins)) const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() => Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid), ) return Service.of({ get, pluginOrigins, waitForDependencies }) }).pipe(Effect.withSpan("TuiConfig.layer")), ) export const node = LayerNode.make({ service: Service, layer, deps: [Npm.node, FSUtil.node] }) const { runPromise } = makeRuntime(Service, AppNodeBuilder.build(node)) export async function waitForDependencies() { await runPromise((svc) => svc.waitForDependencies()) } export async function get() { return runPromise((svc) => svc.get()) } export async function pluginOrigins() { return runPromise((svc) => svc.pluginOrigins()) }