feat(tui): add managed config interface · argszero/opencode@3568dd1 · GitHub
Skip to content

Commit 3568dd1

Browse files
committed
feat(tui): add managed config interface
1 parent 1e17202 commit 3568dd1

52 files changed

Lines changed: 848 additions & 266 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli/package.json

Lines changed: 1 addition & 0 deletions

packages/cli/src/commands/handlers/default.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
44
import { loadBuiltinPlugins } from "@opencode-ai/tui/builtins"
55
import { Commands } from "../commands"
66
import { Runtime } from "../../framework/runtime"
7-
import { TuiConfig } from "../../tui-config"
7+
import { Config } from "../../config"
88
import { Effect, Option } from "effect"
99
import { Server } from "../../services/server"
1010
import { Updater } from "../../services/updater"
@@ -35,13 +35,16 @@ export default Runtime.handler(Commands, (input) =>
3535
),
3636
)
3737
preflight.loading()
38-
const config = yield* TuiConfig.load()
38+
const configService = yield* Config.Service
3939
let disposeSlots: (() => void) | undefined
4040
const runFork = Effect.runForkWith(yield* Effect.context())
4141
yield* run({
4242
server,
4343
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
44-
config,
44+
config: {
45+
get: () => Effect.runPromise(configService.get()),
46+
update: (update) => Effect.runPromise(configService.update(update)),
47+
},
4548
terminalHandoff: () => preflight.finish(),
4649
log: (level, message, tags) => {
4750
const effect =

packages/cli/src/config/config.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
export * as Config from "./config"
2+
3+
import { Global } from "@opencode-ai/core/global"
4+
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
5+
import { produce, type Draft } from "immer"
6+
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
7+
import path from "path"
8+
import { ConfigMigration } from "./migrate"
9+
import { Info } from "./schema"
10+
11+
export * from "./schema"
12+
13+
export interface Interface {
14+
readonly path: string
15+
readonly get: () => Effect.Effect<Info>
16+
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, Error>
17+
}
18+
19+
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
20+
21+
const decode = Schema.decodeUnknownOption(Info)
22+
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
23+
const empty: Info = {}
24+
25+
export const layer = Layer.effect(
26+
Service,
27+
Effect.gen(function* () {
28+
const fs = yield* FileSystem.FileSystem
29+
const global = yield* Global.Service
30+
const file = path.join(global.config, "cli.json")
31+
const lock = yield* Semaphore.make(1)
32+
33+
const readJson = Effect.fnUntraced(function* () {
34+
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
35+
if (text === undefined) return undefined
36+
const errors: ParseError[] = []
37+
const value: any = parse(text, errors, { allowTrailingComma: true })
38+
if (errors.length) return undefined
39+
return Option.getOrUndefined(decodeRecord(value))
40+
})
41+
42+
const write = Effect.fnUntraced(function* (text: string) {
43+
const temp = file + ".tmp"
44+
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
45+
yield* fs.writeFileString(temp, text, { mode: 0o600 })
46+
yield* fs.rename(temp, file)
47+
})
48+
49+
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
50+
Effect.provideService(FileSystem.FileSystem, fs),
51+
)
52+
53+
const get = Effect.fn("cli.config.get")(function* () {
54+
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
55+
return Option.getOrElse(decode(yield* readJson()), () => empty)
56+
})
57+
58+
const update = Effect.fn("cli.config.update")((update: (draft: Draft<Info>) => void) =>
59+
lock
60+
.withPermits(1)(
61+
Effect.gen(function* () {
62+
yield* migrate
63+
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
64+
const next = produce(current, update)
65+
const edits = changes(current, next)
66+
if (!edits.length) return current
67+
const text = yield* fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("{}")))
68+
const updated = edits.reduce(
69+
(text, edit) =>
70+
applyEdits(
71+
text,
72+
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
73+
),
74+
text,
75+
)
76+
const errors: ParseError[] = []
77+
const config = Option.getOrUndefined(decode(parse(updated, errors, { allowTrailingComma: true })))
78+
if (errors.length || config === undefined) return yield* Effect.fail(new Error("Invalid CLI config update"))
79+
yield* write(updated.endsWith("\n") ? updated : updated + "\n")
80+
return config
81+
}),
82+
)
83+
.pipe(Effect.mapError((cause) => new Error("Failed to update CLI config", { cause }))),
84+
)
85+
86+
return Service.of({ path: file, get, update })
87+
}),
88+
)
89+
90+
type Edit = { readonly path: (string | number)[]; readonly value: any }
91+
92+
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
93+
if (Object.is(before, after)) return []
94+
if (
95+
before !== null &&
96+
after !== null &&
97+
typeof before === "object" &&
98+
typeof after === "object" &&
99+
!Array.isArray(before) &&
100+
!Array.isArray(after)
101+
) {
102+
return [...new Set([...Object.keys(before), ...Object.keys(after)])].flatMap((key) => {
103+
if (!(key in after)) return [{ path: [...path, key], value: undefined }]
104+
if (!(key in before)) return [{ path: [...path, key], value: after[key] }]
105+
return changes(before[key], after[key], [...path, key])
106+
})
107+
}
108+
return [{ path, value: after }]
109+
}

packages/cli/src/config/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * as Config from "./config"

packages/cli/src/config/migrate.ts

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
export * as ConfigMigration from "./migrate"
2+
3+
import { TuiConfigV1 } from "@opencode-ai/tui/config/v1"
4+
import { Effect, FileSystem, Option, Schema } from "effect"
5+
import { parse, type ParseError } from "jsonc-parser"
6+
import path from "path"
7+
import type { Info } from "./schema"
8+
9+
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
10+
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Any))
11+
12+
export const run = Effect.fn("cli.config.migrate")(function* (input: {
13+
readonly file: string
14+
readonly config: string
15+
readonly state: string
16+
}) {
17+
const fs = yield* FileSystem.FileSystem
18+
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
19+
20+
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
21+
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
22+
const kv = yield* readJson(path.join(input.state, "kv.json"))
23+
const migrated = migrateV1(legacy, kv ?? {})
24+
if (!Object.keys(migrated).length) return
25+
26+
const temp = input.file + ".tmp"
27+
yield* fs.makeDirectory(path.dirname(input.file), { recursive: true })
28+
yield* fs.writeFileString(temp, JSON.stringify(migrated, null, 2) + "\n", { mode: 0o600 })
29+
yield* fs.rename(temp, input.file)
30+
yield* Effect.logInfo("migrated cli config", {
31+
from: [
32+
legacyValue === undefined ? undefined : path.join(input.config, "tui.json"),
33+
kv === undefined ? undefined : path.join(input.state, "kv.json"),
34+
].filter(Boolean),
35+
to: input.file,
36+
})
37+
})
38+
39+
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
40+
const plugins = [
41+
...(legacy?.plugin?.map((plugin) =>
42+
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
43+
) ?? []),
44+
...Object.entries(legacy?.plugin_enabled ?? {}).map(([id, enabled]) => (enabled ? id : `-${id}`)),
45+
]
46+
const themeName = legacy?.theme ?? kv.theme
47+
const themeMode = kv.theme_mode_lock
48+
const attentionSoundPack = kv.attention_sound_pack
49+
const diffView = kv.diff_viewer_view ?? (legacy?.diff_style === "stacked" ? "unified" : undefined)
50+
const thinking =
51+
kv.thinking_mode ??
52+
(kv.thinking_visibility === undefined ? undefined : kv.thinking_visibility ? "show" : "hide")
53+
54+
return {
55+
...(themeName !== undefined || themeMode !== undefined
56+
? { theme: { ...(themeName === undefined ? {} : { name: themeName }), ...(themeMode === undefined ? {} : { mode: themeMode }) } }
57+
: {}),
58+
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
59+
...(plugins.length ? { plugins } : {}),
60+
...(legacy?.leader_timeout === undefined ? {} : { leader: { timeout: legacy.leader_timeout } }),
61+
...(legacy?.scroll_speed === undefined && legacy?.scroll_acceleration?.enabled === undefined
62+
? {}
63+
: {
64+
scroll: {
65+
...(legacy.scroll_speed === undefined ? {} : { speed: legacy.scroll_speed }),
66+
...(legacy.scroll_acceleration?.enabled === undefined
67+
? {}
68+
: { acceleration: legacy.scroll_acceleration.enabled }),
69+
},
70+
}),
71+
...(legacy?.attention === undefined && attentionSoundPack === undefined
72+
? {}
73+
: {
74+
attention: {
75+
...legacy?.attention,
76+
...(attentionSoundPack === undefined ? {} : { sound_pack: attentionSoundPack }),
77+
},
78+
}),
79+
...(legacy?.diff_style === undefined &&
80+
kv.diff_wrap_mode === undefined &&
81+
kv.diff_viewer_show_file_tree === undefined &&
82+
kv.diff_viewer_single_patch === undefined &&
83+
diffView === undefined
84+
? {}
85+
: {
86+
diffs: {
87+
...(kv.diff_wrap_mode === undefined ? {} : { wrap: kv.diff_wrap_mode }),
88+
...(kv.diff_viewer_show_file_tree === undefined ? {} : { tree: kv.diff_viewer_show_file_tree }),
89+
...(kv.diff_viewer_single_patch === undefined ? {} : { single: kv.diff_viewer_single_patch }),
90+
...(diffView === undefined ? {} : { view: diffView }),
91+
},
92+
}),
93+
...(kv.terminal_title_enabled === undefined ? {} : { terminal: { title: kv.terminal_title_enabled } }),
94+
...(kv.file_context_enabled === undefined && kv.paste_summary_enabled === undefined
95+
? {}
96+
: {
97+
prompt: {
98+
...(kv.file_context_enabled === undefined ? {} : { editor: kv.file_context_enabled }),
99+
...(kv.paste_summary_enabled === undefined
100+
? {}
101+
: { paste: kv.paste_summary_enabled ? ("compact" as const) : ("full" as const) }),
102+
},
103+
}),
104+
...(kv.sidebar === undefined &&
105+
kv.scrollbar_visible === undefined &&
106+
thinking === undefined &&
107+
kv.exploration_grouping === undefined
108+
? {}
109+
: {
110+
session: {
111+
...(kv.sidebar === undefined ? {} : { sidebar: kv.sidebar }),
112+
...(kv.scrollbar_visible === undefined ? {} : { scrollbar: kv.scrollbar_visible }),
113+
...(thinking === undefined ? {} : { thinking }),
114+
...(kv.exploration_grouping === undefined
115+
? {}
116+
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
117+
},
118+
}),
119+
...(kv.tips_hidden === undefined && kv.dismissed_getting_started === undefined
120+
? {}
121+
: {
122+
hints: {
123+
...(kv.tips_hidden === undefined ? {} : { tips: !kv.tips_hidden }),
124+
...(kv.dismissed_getting_started === undefined
125+
? {}
126+
: { onboarding: !kv.dismissed_getting_started }),
127+
},
128+
}),
129+
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
130+
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
131+
}
132+
}
133+
134+
const readJson = Effect.fnUntraced(function* (target: string) {
135+
const fs = yield* FileSystem.FileSystem
136+
const text = yield* fs.readFileString(target).pipe(Effect.catch(() => Effect.succeed(undefined)))
137+
if (text === undefined) return undefined
138+
const errors: ParseError[] = []
139+
const value: any = parse(text, errors, { allowTrailingComma: true })
140+
if (errors.length) return undefined
141+
return Option.getOrUndefined(decodeRecord(value))
142+
})

packages/cli/src/config/schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { TuiConfig } from "@opencode-ai/tui/config"
2+
import { Schema } from "effect"
3+
4+
export const Info = Schema.Struct({ ...TuiConfig.Info.fields })
5+
export type Info = Schema.Schema.Type<typeof Info>

packages/cli/src/framework/runtime.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Command } from "effect/unstable/cli"
33
import { Spec } from "./spec"
44
import { Global } from "@opencode-ai/core/global"
55
import { Updater } from "../services/updater"
6+
import { Config } from "../config"
67

78
export type Input<Value> =
89
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
@@ -13,18 +14,26 @@ export type Input<Value> =
1314

1415
type RuntimeHandler = (
1516
input: unknown,
16-
) => Effect.Effect<void, unknown, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
17+
) => Effect.Effect<
18+
void,
19+
unknown,
20+
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
21+
>
1722
type Loader<Node extends Spec.Any> = () => Promise<{
1823
default: (
1924
input: Input<Node>,
20-
) => Effect.Effect<void, any, FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope>
25+
) => Effect.Effect<
26+
void,
27+
any,
28+
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
29+
>
2130
}>
2231
type ProvidedCommand = Command.Command<
2332
string,
2433
unknown,
2534
unknown,
2635
unknown,
27-
FileSystem.FileSystem | Global.Service | Updater.Service | Scope.Scope
36+
FileSystem.FileSystem | Global.Service | Updater.Service | Config.Service | Scope.Scope
2837
>
2938

3039
export type Handlers<Node extends Spec.Any> = keyof Node["commands"] extends never

packages/cli/src/index.ts

Lines changed: 2 additions & 0 deletions

packages/cli/src/tui-config.ts

Lines changed: 0 additions & 24 deletions
This file was deleted.

0 commit comments

Comments
 (0)