feat(tui): add managed config interface

This commit is contained in:
Dax Raad 2026-07-12 16:54:08 -04:00
commit 3568dd1b99
52 changed files with 848 additions and 266 deletions

View file

@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { TuiConfig } from "../../tui-config"
import { Config } from "../../config"
import { Effect, Option } from "effect"
import { Server } from "../../services/server"
import { Updater } from "../../services/updater"
@ -35,13 +35,16 @@ export default Runtime.handler(Commands, (input) =>
),
)
preflight.loading()
const config = yield* TuiConfig.load()
const configService = yield* Config.Service
let disposeSlots: (() => void) | undefined
const runFork = Effect.runForkWith(yield* Effect.context())
yield* run({
server,
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
config,
config: {
get: () => Effect.runPromise(configService.get()),
update: (update) => Effect.runPromise(configService.update(update)),
},
terminalHandoff: () => preflight.finish(),
log: (level, message, tags) => {
const effect =

View file

@ -0,0 +1,109 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/core/global"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
import { ConfigMigration } from "./migrate"
import { Info } from "./schema"
export * from "./schema"
export interface Interface {
readonly path: string
readonly get: () => Effect.Effect<Info>
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
const decode = Schema.decodeUnknownOption(Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
const empty: Info = {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const file = path.join(global.config, "cli.json")
const lock = yield* Semaphore.make(1)
const readJson = Effect.fnUntraced(function* () {
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (text === undefined) return undefined
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
})
const write = Effect.fnUntraced(function* (text: string) {
const temp = file + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
yield* fs.writeFileString(temp, text, { mode: 0o600 })
yield* fs.rename(temp, file)
})
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
)
const get = Effect.fn("cli.config.get")(function* () {
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
return Option.getOrElse(decode(yield* readJson()), () => empty)
})
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
lock
.withPermits(1)(
Effect.gen(function* () {
yield* migrate
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
),
text,
)
const errors: ParseError[] = []
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
return config
}),
)
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
)
return Service.of({ path: file, get, update })
}),
)
type Edit = { readonly path: (string | number)[]; readonly value: any }
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (
before !== null &&
after !== null &&
typeof before === "object" &&
typeof after === "object" &&
!Array.isArray(before) &&
!Array.isArray(after)
) {
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
return changes(before[key], after[key], [...path, key])
})
}
return [{ path, value: after }]
}

View file

@ -0,0 +1 @@
export * as Config from "./config"

View file

@ -0,0 +1,142 @@
export * as ConfigMigration from "./migrate"
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
import { Effect, FileSystem, Option, Schema } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
import type { Info } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly file: string
readonly config: string
readonly state: string
}) {
const fs = yield* FileSystem.FileSystem
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
const kv = yield* readJson(path.join(input.state, "kv.json"))
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
const temp = input.file + ".tmp"
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
yield* fs.rename(temp, input.file)
yield* Effect.logInfo("migrated cli config", {
from: [
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
kv === undefined ? undefined : path.join(input.state, "kv.json"),
].filter(Boolean),
to: input.file,
})
})
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
const plugins = [
...(legacy?.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
) ?? []),
...Object.entries(legacy?.plugin_enabled ?? {}).map(([id, enabled]) => (enabled ? id : `-${id}`)),
]
const themeName = legacy?.theme ?? kv.theme
const themeMode = kv.theme_mode_lock
const attentionSoundPack = kv.attention_sound_pack
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
const thinking =
kv.thinking_mode ??
(kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
return {
...(themeName !== undefined || themeMode !== undefined
? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } }
: {}),
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
...(plugins.length ? { plugins } : {}),
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
? {}
: {
scroll: {
...(legacy.scroll_speed === undefined ? {} : { speed: legacy.scroll_speed }),
...(legacy.scroll_acceleration?.enabled === undefined
? {}
: { acceleration: legacy.scroll_acceleration.enabled }),
},
}),
...(legacy?.attention === undefined && attentionSoundPack === undefined
? {}
: {
attention: {
...legacy?.attention,
...(attentionSoundPack === undefined ? {} : { sound_pack: attentionSoundPack }),
},
}),
...(legacy?.diff_style === undefined &&
kv.diff_wrap_mode === undefined &&
kv.diff_viewer_show_file_tree === undefined &&
kv.diff_viewer_single_patch === undefined &&
diffView === undefined
? {}
: {
diffs: {
...(kv.diff_wrap_mode === undefined ? {} : { wrap: kv.diff_wrap_mode }),
...(kv.diff_viewer_show_file_tree === undefined ? {} : { tree: kv.diff_viewer_show_file_tree }),
...(kv.diff_viewer_single_patch === undefined ? {} : { single: kv.diff_viewer_single_patch }),
...(diffView === undefined ? {} : { view: diffView }),
},
}),
...(kv.terminal_title_enabled === undefined ? {} : { terminal: { title: kv.terminal_title_enabled } }),
...(kv.file_context_enabled === undefined && kv.paste_summary_enabled === undefined
? {}
: {
prompt: {
...(kv.file_context_enabled === undefined ? {} : { editor: kv.file_context_enabled }),
...(kv.paste_summary_enabled === undefined
? {}
: { paste: kv.paste_summary_enabled ? ("compact" as const) : ("full" as const) }),
},
}),
...(kv.sidebar === undefined &&
kv.scrollbar_visible === undefined &&
thinking === undefined &&
kv.exploration_grouping === undefined
? {}
: {
session: {
...(kv.sidebar === undefined ? {} : { sidebar: kv.sidebar }),
...(kv.scrollbar_visible === undefined ? {} : { scrollbar: kv.scrollbar_visible }),
...(thinking === undefined ? {} : { thinking }),
...(kv.exploration_grouping === undefined
? {}
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
},
}),
...(kv.tips_hidden === undefined && kv.dismissed_getting_started === undefined
? {}
: {
hints: {
...(kv.tips_hidden === undefined ? {} : { tips: !kv.tips_hidden }),
...(kv.dismissed_getting_started === undefined
? {}
: { onboarding: !kv.dismissed_getting_started }),
},
}),
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
}
}
const readJson = Effect.fnUntraced(function* (target: string) {
const fs = yield* FileSystem.FileSystem
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (text === undefined) return undefined
const errors: ParseError[] = []
const value: any = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return undefined
return Option.getOrUndefined(decodeRecord(value))
})

View file

@ -0,0 +1,5 @@
import { TuiConfig } from "@opencode-ai/tui/config"
import { Schema } from "effect"
export const Info = Schema.Struct({ ...TuiConfig.Info.fields })
export type Info = Schema.Schema.Type<typeof Info>

View file

@ -3,6 +3,7 @@ import { Command } from "effect/unstable/cli"
import { Spec } from "./spec"
import { Global } from "@opencode-ai/core/global"
import { Updater } from "../services/updater"
import { Config } from "../config"
export type Input<Value> =
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@ -13,18 +14,26 @@ export type Input<Value> =
type RuntimeHandler = (
input: unknown,
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
) => Effect.Effect<
void,
unknown,
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
>
type Loader<Node extends Spec.Any> = () => Promise<{
default: (
input: Input<Node>,
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
) => Effect.Effect<
void,
any,
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
>
}>
type ProvidedCommand = Command.Command<
string,
unknown,
unknown,
unknown,
FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
>
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never

View file

@ -11,6 +11,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Global } from "@opencode-ai/core/global"
import { AppProcess } from "@opencode-ai/core/process"
import { Config } from "./config"
const Handlers = Runtime.handlers(Commands, {
$: () => import("./commands/handlers/default"),
@ -51,6 +52,7 @@ Effect.logInfo("cli starting", {
}).pipe(
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: InstallationVersion })),
Effect.annotateLogs({ role: "cli" }),
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node]))),
Effect.provide(Observability.layer),

View file

@ -1,24 +0,0 @@
export * as TuiConfig from "./tui-config"
import { Global } from "@opencode-ai/core/global"
import { TuiConfig } from "@opencode-ai/tui/config/v1"
import { Effect, FileSystem, Option, Schema } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
export const load = Effect.fn("TuiConfig.load")(function* () {
const fs = yield* FileSystem.FileSystem
const global = yield* Global.Service
const filepath = path.join(global.config, "tui.json")
const text = yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!text) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return TuiConfig.resolve({}, { terminalSuspend: process.platform !== "win32" })
return TuiConfig.resolve(
Option.getOrElse(Schema.decodeUnknownOption(TuiConfig.Info)(input), () => ({})),
{ terminalSuspend: process.platform !== "win32" },
)
})