diff --git a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts index 3ce5c4b739..9323dd979a 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui-migrate.ts @@ -26,7 +26,6 @@ const TuiLegacy = z interface MigrateInput { cwd: string directories: string[] - custom?: string } /** diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index d264273bca..6e5296db87 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -17,197 +17,203 @@ import { InstallationLocal, InstallationVersion } from "@/installation/version" import { makeRuntime } from "@/cli/effect/runtime" import { Filesystem, Log } from "@/util" -const log = Log.create({ service: "tui.config" }) +export namespace TuiConfig { + 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> -} + type State = { + config: Info + deps: Array> + } -export type Info = z.output & { - // Internal resolved plugin list used by runtime loading. - plugin_origins?: ConfigPlugin.Origin[] -} + export type Info = z.output & { + // Internal resolved plugin list used by runtime loading. + plugin_origins?: ConfigPlugin.Origin[] + } -export interface Interface { - readonly get: () => Effect.Effect - readonly waitForDependencies: () => Effect.Effect -} + export interface Interface { + readonly get: () => Effect.Effect + readonly waitForDependencies: () => Effect.Effect + } -export class Service extends Context.Service()("@opencode/TuiConfig") {} + 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 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 customPath() { - return Flag.OPENCODE_TUI_CONFIG -} + function normalize(raw: Record) { + const data = { ...raw } + if (!("tui" in data)) return data + if (!isRecord(data.tui)) { + delete data.tui + return data + } -function normalize(raw: Record) { - const data = { ...raw } - if (!("tui" in data)) return data - if (!isRecord(data.tui)) { + const tui = data.tui delete data.tui - return data - } - - const tui = data.tui - delete data.tui - return { - ...tui, - ...data, - } -} - -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 -} - -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 -} - -async function loadState(ctx: { directory: string }) { - let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory) - const directories = await ConfigPaths.directories(ctx.directory) - const custom = customPath() - await migrateTuiConfig({ directories, custom, cwd: ctx.directory }) - // Re-compute after migration since migrateTuiConfig may have created new tui.json files - projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG ? [] : await ConfigPaths.projectFiles("tui", ctx.directory) - - const acc: Acc = { - result: {}, - } - - for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { - await mergeFile(acc, file, ctx) - } - - if (custom) { - await mergeFile(acc, custom, ctx) - log.debug("loaded custom tui config", { path: custom }) - } - - for (const file of projectFiles) { - await mergeFile(acc, file, ctx) - } - - 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")) { - await mergeFile(acc, file, ctx) + return { + ...tui, + ...data, } } - 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(",") + 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 } - acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds) - return { - config: acc.result, - dirs: acc.result.plugin?.length ? dirs : [], + 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 } -} -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", - }, - ) + 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 get = Effect.fn("TuiConfig.get")(() => Effect.succeed(data.config)) + const projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG + ? [] + : await ConfigPaths.projectFiles("tui", ctx.directory) - 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")), -) + const acc: Acc = { + result: {}, + } -export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer)) + // 1. Global tui config (lowest precedence). + for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { + await mergeFile(acc, file, ctx) + } -const { runPromise } = makeRuntime(Service, defaultLayer) + // 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 }) + } -export async function waitForDependencies() { - await runPromise((svc) => svc.waitForDependencies()) -} + // 3. Project tui files, applied root-first so the closest file wins. + for (const file of projectFiles) { + await mergeFile(acc, file, ctx) + } -export async function get() { - return runPromise((svc) => svc.get()) -} + // 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) -async function loadFile(filepath: string): Promise { - 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 {} - }) -} + 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) + } + } -async function load(text: string, configFilepath: string): Promise { - return ConfigParse.load(Info, text, { - type: "path", - path: configFilepath, - missing: "empty", - normalize: (data) => { - if (!isRecord(data)) return {} + 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) - // 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 { + config: acc.result, + dirs: acc.result.plugin?.length ? dirs : [], + } + } + + 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()) + } + + export async function get() { + return runPromise((svc) => svc.get()) + } + + async function loadFile(filepath: string): Promise { + 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 * as TuiConfig from "./tui" + async function load(text: string, configFilepath: string): Promise { + 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 {} + }) + } +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3cbc539600..adccb6353b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -19,7 +19,6 @@ import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" import { Account } from "@/account" import { isRecord } from "@/util/record" -import { InvalidError, JsonError } from "./error" import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { InstanceState } from "@/effect" diff --git a/packages/opencode/src/config/keybinds.ts b/packages/opencode/src/config/keybinds.ts index cb146b7cae..8a22289d2a 100644 --- a/packages/opencode/src/config/keybinds.ts +++ b/packages/opencode/src/config/keybinds.ts @@ -106,7 +106,12 @@ export const Keybinds = z input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"), input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"), input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"), - input_undo: z.string().optional().default("ctrl+-,super+z").describe("Undo in input"), + input_undo: z + .string() + .optional() + // On Windows prepend ctrl+z since terminal_suspend releases the binding. + .default(process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z") + .describe("Undo in input"), input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"), input_word_forward: z .string() @@ -144,7 +149,12 @@ export const Keybinds = z session_child_cycle: z.string().optional().default("right").describe("Go to next child session"), session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"), session_parent: z.string().optional().default("up").describe("Go to parent session"), - terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"), + terminal_suspend: z + .string() + .optional() + .default("ctrl+z") + .transform((v) => (process.platform === "win32" ? "none" : v)) + .describe("Suspend terminal"), terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"), tips_toggle: z.string().optional().default("h").describe("Toggle tips on home screen"), plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),