|
|
@@ -1,3 +1,5 @@
|
|
|
+export * as TuiConfig from "./tui"
|
|
|
+
|
|
|
import z from "zod"
|
|
|
import { mergeDeep, unique } from "remeda"
|
|
|
import { Context, Effect, Fiber, Layer } from "effect"
|
|
|
@@ -17,203 +19,199 @@ import { InstallationLocal, InstallationVersion } from "@/installation/version"
|
|
|
import { makeRuntime } from "@/cli/effect/runtime"
|
|
|
import { Filesystem, Log } from "@/util"
|
|
|
|
|
|
-export namespace TuiConfig {
|
|
|
- const log = Log.create({ service: "tui.config" })
|
|
|
+const log = Log.create({ service: "tui.config" })
|
|
|
|
|
|
- export const Info = TuiInfo
|
|
|
+export const Info = TuiInfo
|
|
|
|
|
|
- type Acc = {
|
|
|
- result: Info
|
|
|
- }
|
|
|
+type Acc = {
|
|
|
+ result: Info
|
|
|
+}
|
|
|
+
|
|
|
+type State = {
|
|
|
+ config: Info
|
|
|
+ deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
|
|
+}
|
|
|
+
|
|
|
+export type Info = z.output<typeof Info> & {
|
|
|
+ // Internal resolved plugin list used by runtime loading.
|
|
|
+ plugin_origins?: ConfigPlugin.Origin[]
|
|
|
+}
|
|
|
+
|
|
|
+export interface Interface {
|
|
|
+ readonly get: () => Effect.Effect<Info>
|
|
|
+ readonly waitForDependencies: () => Effect.Effect<void>
|
|
|
+}
|
|
|
+
|
|
|
+export class Service extends Context.Service<Service, Interface>()("@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"
|
|
|
+}
|
|
|
|
|
|
- type State = {
|
|
|
- config: Info
|
|
|
- deps: Array<Fiber.Fiber<void, AppFileSystem.Error>>
|
|
|
+function normalize(raw: Record<string, unknown>) {
|
|
|
+ const data = { ...raw }
|
|
|
+ if (!("tui" in data)) return data
|
|
|
+ if (!isRecord(data.tui)) {
|
|
|
+ delete data.tui
|
|
|
+ return data
|
|
|
}
|
|
|
|
|
|
- export type Info = z.output<typeof Info> & {
|
|
|
- // Internal resolved plugin list used by runtime loading.
|
|
|
- plugin_origins?: ConfigPlugin.Origin[]
|
|
|
+ const tui = data.tui
|
|
|
+ delete data.tui
|
|
|
+ return {
|
|
|
+ ...tui,
|
|
|
+ ...data,
|
|
|
}
|
|
|
+}
|
|
|
|
|
|
- export interface Interface {
|
|
|
- readonly get: () => Effect.Effect<Info>
|
|
|
- readonly waitForDependencies: () => Effect.Effect<void>
|
|
|
+async function resolvePlugins(config: Info, configFilepath: string) {
|
|
|
+ if (!config.plugin) return config
|
|
|
+ for (let i = 0; i < config.plugin.length; i++) {
|
|
|
+ config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
|
|
|
}
|
|
|
+ return config
|
|
|
+}
|
|
|
|
|
|
- export class Service extends Context.Service<Service, Interface>()("@opencode/TuiConfig") {}
|
|
|
+async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
|
|
|
+ const data = await loadFile(file)
|
|
|
+ acc.result = mergeDeep(acc.result, data)
|
|
|
+ if (!data.plugin?.length) return
|
|
|
+
|
|
|
+ const scope = pluginScope(file, ctx)
|
|
|
+ const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
|
|
+ ...(acc.result.plugin_origins ?? []),
|
|
|
+ ...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
|
|
+ ])
|
|
|
+ acc.result.plugin = plugins.map((item) => item.spec)
|
|
|
+ acc.result.plugin_origins = plugins
|
|
|
+}
|
|
|
|
|
|
- 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"
|
|
|
- }
|
|
|
+async function loadState(ctx: { directory: string }) {
|
|
|
+ // Every config dir we may read from: global config dir, any `.opencode`
|
|
|
+ // folders between cwd and home, and OPENCODE_CONFIG_DIR.
|
|
|
+ const directories = await ConfigPaths.directories(ctx.directory)
|
|
|
+ // One-time migration: extract tui keys (theme/keybinds/tui) from existing
|
|
|
+ // opencode.json files into sibling tui.json files.
|
|
|
+ await migrateTuiConfig({ directories, cwd: ctx.directory })
|
|
|
|
|
|
- function normalize(raw: Record<string, unknown>) {
|
|
|
- const data = { ...raw }
|
|
|
- if (!("tui" in data)) return data
|
|
|
- if (!isRecord(data.tui)) {
|
|
|
- delete data.tui
|
|
|
- return data
|
|
|
- }
|
|
|
+ const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory)
|
|
|
|
|
|
- const tui = data.tui
|
|
|
- delete data.tui
|
|
|
- return {
|
|
|
- ...tui,
|
|
|
- ...data,
|
|
|
- }
|
|
|
+ const acc: Acc = {
|
|
|
+ result: {},
|
|
|
}
|
|
|
|
|
|
- async function resolvePlugins(config: Info, configFilepath: string) {
|
|
|
- if (!config.plugin) return config
|
|
|
- for (let i = 0; i < config.plugin.length; i++) {
|
|
|
- config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], configFilepath)
|
|
|
- }
|
|
|
- return config
|
|
|
+ // 1. Global tui config (lowest precedence).
|
|
|
+ for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
|
|
+ await mergeFile(acc, file, ctx)
|
|
|
}
|
|
|
|
|
|
- async function mergeFile(acc: Acc, file: string, ctx: { directory: string }) {
|
|
|
- const data = await loadFile(file)
|
|
|
- acc.result = mergeDeep(acc.result, data)
|
|
|
- if (!data.plugin?.length) return
|
|
|
-
|
|
|
- const scope = pluginScope(file, ctx)
|
|
|
- const plugins = ConfigPlugin.deduplicatePluginOrigins([
|
|
|
- ...(acc.result.plugin_origins ?? []),
|
|
|
- ...data.plugin.map((spec) => ({ spec, scope, source: file })),
|
|
|
- ])
|
|
|
- acc.result.plugin = plugins.map((item) => item.spec)
|
|
|
- acc.result.plugin_origins = plugins
|
|
|
+ // 2. Explicit OPENCODE_TUI_CONFIG override, if set.
|
|
|
+ if (Flag.OPENCODE_TUI_CONFIG) {
|
|
|
+ await mergeFile(acc, Flag.OPENCODE_TUI_CONFIG, ctx)
|
|
|
+ log.debug("loaded custom tui config", { path: Flag.OPENCODE_TUI_CONFIG })
|
|
|
}
|
|
|
|
|
|
- async function loadState(ctx: { directory: string }) {
|
|
|
- // Every config dir we may read from: global config dir, any `.opencode`
|
|
|
- // folders between cwd and home, and OPENCODE_CONFIG_DIR.
|
|
|
- const directories = await ConfigPaths.directories(ctx.directory)
|
|
|
- // One-time migration: extract tui keys (theme/keybinds/tui) from existing
|
|
|
- // opencode.json files into sibling tui.json files.
|
|
|
- await migrateTuiConfig({ directories, cwd: ctx.directory })
|
|
|
-
|
|
|
- const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
|
|
|
- ? []
|
|
|
- : await ConfigPaths.projectFiles("tui", ctx.directory)
|
|
|
+ // 3. Project tui files, applied root-first so the closest file wins.
|
|
|
+ for (const file of projectFiles) {
|
|
|
+ await mergeFile(acc, file, ctx)
|
|
|
+ }
|
|
|
|
|
|
- const acc: Acc = {
|
|
|
- result: {},
|
|
|
- }
|
|
|
+ // 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)
|
|
|
|
|
|
- // 1. Global tui config (lowest precedence).
|
|
|
- for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
|
|
|
+ for (const dir of dirs) {
|
|
|
+ if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
|
|
+ for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
|
|
await mergeFile(acc, file, ctx)
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- // 2. Explicit OPENCODE_TUI_CONFIG override, if set.
|
|
|
- if (Flag.OPENCODE_TUI_CONFIG) {
|
|
|
- await mergeFile(acc, Flag.OPENCODE_TUI_CONFIG, ctx)
|
|
|
- log.debug("loaded custom tui config", { path: Flag.OPENCODE_TUI_CONFIG })
|
|
|
- }
|
|
|
+ const keybinds = { ...(acc.result.keybinds ?? {}) }
|
|
|
+ if (process.platform === "win32") {
|
|
|
+ // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
|
|
+ keybinds.terminal_suspend = "none"
|
|
|
+ keybinds.input_undo ??= unique([
|
|
|
+ "ctrl+z",
|
|
|
+ ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
|
|
|
+ ]).join(",")
|
|
|
+ }
|
|
|
+ acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
|
|
|
|
|
- // 3. Project tui files, applied root-first so the closest file wins.
|
|
|
- for (const file of projectFiles) {
|
|
|
- await mergeFile(acc, file, ctx)
|
|
|
- }
|
|
|
+ return {
|
|
|
+ config: acc.result,
|
|
|
+ dirs: acc.result.plugin?.length ? dirs : [],
|
|
|
+ }
|
|
|
+}
|
|
|
|
|
|
- // 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)
|
|
|
+export const layer = Layer.effect(
|
|
|
+ Service,
|
|
|
+ Effect.gen(function* () {
|
|
|
+ const directory = yield* CurrentWorkingDirectory
|
|
|
+ const npm = yield* Npm.Service
|
|
|
+ const data = yield* Effect.promise(() => loadState({ directory }))
|
|
|
+ const deps = yield* Effect.forEach(
|
|
|
+ data.dirs,
|
|
|
+ (dir) =>
|
|
|
+ npm
|
|
|
+ .install(dir, {
|
|
|
+ add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
|
|
|
+ })
|
|
|
+ .pipe(Effect.forkScoped),
|
|
|
+ {
|
|
|
+ concurrency: "unbounded",
|
|
|
+ },
|
|
|
+ )
|
|
|
|
|
|
- for (const dir of dirs) {
|
|
|
- if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
|
|
|
- for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
|
|
|
- await mergeFile(acc, file, ctx)
|
|
|
- }
|
|
|
- }
|
|
|
+ const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
|
|
|
|
|
|
- const keybinds = { ...(acc.result.keybinds ?? {}) }
|
|
|
- if (process.platform === "win32") {
|
|
|
- // Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
|
|
- keybinds.terminal_suspend = "none"
|
|
|
- keybinds.input_undo ??= unique([
|
|
|
- "ctrl+z",
|
|
|
- ...ConfigKeybinds.Keybinds.shape.input_undo.parse(undefined).split(","),
|
|
|
- ]).join(",")
|
|
|
- }
|
|
|
- acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds)
|
|
|
+ const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
|
|
+ Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
|
|
|
+ )
|
|
|
+ return Service.of({ get, waitForDependencies })
|
|
|
+ }).pipe(Effect.withSpan("TuiConfig.layer")),
|
|
|
+)
|
|
|
|
|
|
- return {
|
|
|
- config: acc.result,
|
|
|
- dirs: acc.result.plugin?.length ? dirs : [],
|
|
|
- }
|
|
|
- }
|
|
|
+export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
|
|
|
|
|
|
- export const layer = Layer.effect(
|
|
|
- Service,
|
|
|
- Effect.gen(function* () {
|
|
|
- const directory = yield* CurrentWorkingDirectory
|
|
|
- const npm = yield* Npm.Service
|
|
|
- const data = yield* Effect.promise(() => loadState({ directory }))
|
|
|
- const deps = yield* Effect.forEach(
|
|
|
- data.dirs,
|
|
|
- (dir) =>
|
|
|
- npm
|
|
|
- .install(dir, {
|
|
|
- add: ["@opencode-ai/plugin" + (InstallationLocal ? "" : "@" + InstallationVersion)],
|
|
|
- })
|
|
|
- .pipe(Effect.forkScoped),
|
|
|
- {
|
|
|
- concurrency: "unbounded",
|
|
|
- },
|
|
|
- )
|
|
|
-
|
|
|
- const get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config))
|
|
|
-
|
|
|
- const waitForDependencies = Effect.fn("TuiConfig.waitForDependencies")(() =>
|
|
|
- Effect.forEach(deps, Fiber.join, { concurrency: "unbounded" }).pipe(Effect.ignore(), Effect.asVoid),
|
|
|
- )
|
|
|
- return Service.of({ get, waitForDependencies })
|
|
|
- }).pipe(Effect.withSpan("TuiConfig.layer")),
|
|
|
- )
|
|
|
-
|
|
|
- export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer))
|
|
|
-
|
|
|
- const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
|
-
|
|
|
- export async function waitForDependencies() {
|
|
|
- await runPromise((svc) => svc.waitForDependencies())
|
|
|
- }
|
|
|
+const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
|
|
|
|
- export async function get() {
|
|
|
- return runPromise((svc) => svc.get())
|
|
|
- }
|
|
|
+export async function waitForDependencies() {
|
|
|
+ await runPromise((svc) => svc.waitForDependencies())
|
|
|
+}
|
|
|
|
|
|
- async function loadFile(filepath: string): Promise<Info> {
|
|
|
- const text = await ConfigPaths.readFile(filepath)
|
|
|
- if (!text) return {}
|
|
|
- return load(text, filepath).catch((error) => {
|
|
|
- log.warn("failed to load tui config", { path: filepath, error })
|
|
|
- return {}
|
|
|
- })
|
|
|
- }
|
|
|
+export async function get() {
|
|
|
+ return runPromise((svc) => svc.get())
|
|
|
+}
|
|
|
|
|
|
- async function load(text: string, configFilepath: string): Promise<Info> {
|
|
|
- return ConfigParse.load(Info, text, {
|
|
|
- type: "path",
|
|
|
- path: configFilepath,
|
|
|
- missing: "empty",
|
|
|
- normalize: (data) => {
|
|
|
- if (!isRecord(data)) return {}
|
|
|
-
|
|
|
- // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
|
|
- // (mirroring the old opencode.json shape) still get their settings applied.
|
|
|
- return normalize(data)
|
|
|
- },
|
|
|
+async function loadFile(filepath: string): Promise<Info> {
|
|
|
+ const text = await ConfigPaths.readFile(filepath)
|
|
|
+ if (!text) return {}
|
|
|
+ return load(text, filepath).catch((error) => {
|
|
|
+ log.warn("failed to load tui config", { path: filepath, error })
|
|
|
+ return {}
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+async function load(text: string, configFilepath: string): Promise<Info> {
|
|
|
+ return ConfigParse.load(Info, text, {
|
|
|
+ type: "path",
|
|
|
+ path: configFilepath,
|
|
|
+ missing: "empty",
|
|
|
+ normalize: (data) => {
|
|
|
+ if (!isRecord(data)) return {}
|
|
|
+
|
|
|
+ // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
|
|
+ // (mirroring the old opencode.json shape) still get their settings applied.
|
|
|
+ return normalize(data)
|
|
|
+ },
|
|
|
+ })
|
|
|
+ .then((data) => resolvePlugins(data, configFilepath))
|
|
|
+ .catch((error) => {
|
|
|
+ log.warn("invalid tui config", { path: configFilepath, error })
|
|
|
+ return {}
|
|
|
})
|
|
|
- .then((data) => resolvePlugins(data, configFilepath))
|
|
|
- .catch((error) => {
|
|
|
- log.warn("invalid tui config", { path: configFilepath, error })
|
|
|
- return {}
|
|
|
- })
|
|
|
- }
|
|
|
}
|