fix(tui): sync model favorites

This commit is contained in:
Dax Raad 2026-07-15 20:23:52 +00:00
commit fb8e06d6a4
7 changed files with 282 additions and 80 deletions

View file

@ -4,7 +4,7 @@ import { run } from "@opencode-ai/tui"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
import { Config } from "../../config"
import { Context, Effect, FileSystem, Option } from "effect"
import { Context, Effect, Fiber, FileSystem, Option, Stream } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
@ -58,6 +58,12 @@ export default Runtime.handler(Commands, (input) =>
path: config.path,
get: () => runPromise(config.get()),
update: (update) => runPromise(config.update(update)),
subscribe: (listener) => {
const fiber = runFork(config.changes.pipe(Stream.runForEach((info) => Effect.sync(() => listener(info)))))
return () => {
runFork(Fiber.interrupt(fiber))
}
},
},
packages: {
resolve: (spec) =>

View file

@ -1,7 +1,8 @@
export * as Config from "./config"
import { Global } from "@opencode-ai/core/global"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore } from "effect"
import { Flock } from "@opencode-ai/core/util/flock"
import { Context, Effect, FileSystem, Layer, Option, Schema, Semaphore, Stream } from "effect"
import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
@ -14,6 +15,7 @@ export interface Interface {
readonly path: string
readonly get: () => Effect.Effect<Info>
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, Error>
readonly changes: Stream.Stream<Info>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/config/Config") {}
@ -49,47 +51,63 @@ export const layer = Layer.effect(
const migrate = ConfigMigration.run({ file, config: global.config, state: global.state }).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
)
const withFileLock = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
Effect.scoped(Flock.effect("cli-config", { dir: path.join(global.state, "locks") }).pipe(Effect.andThen(effect)))
const get = Effect.fn("cli.config.get")(function* () {
yield* migrate.pipe(Effect.catchCause((cause) => Effect.logWarning("failed to migrate cli config", { cause })))
yield* withFileLock(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
}),
withFileLock(
Effect.gen(function* () {
yield* migrate
const current = Option.getOrElse(decode(yield* readJson()), () => empty)
const next = produce(current, update)
const edits = diff(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 })
const changes = fs.watch(path.dirname(file)).pipe(
Stream.filter((event) => path.resolve(path.dirname(file), event.path) === path.resolve(file)),
Stream.debounce("50 millis"),
Stream.mapEffect(() => get()),
Stream.catchCause((cause) =>
Stream.fromEffect(Effect.logWarning("failed to watch cli config", { cause })).pipe(Stream.drain),
),
)
return Service.of({ path: file, get, update, changes })
}),
)
type Edit = { readonly path: (string | number)[]; readonly value: any }
function changes(before: any, after: any, path: (string | number)[] = []): Edit[] {
function diff(before: any, after: any, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (
before !== null &&
@ -102,7 +120,7 @@ function changes(before: any, after: any, path: (string | number)[] = []): Edit[
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 diff(before[key], after[key], [...path, key])
})
}
return [{ path, value: after }]

View file

@ -2,7 +2,7 @@ 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 { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
import type { Info } from "./schema"
@ -15,27 +15,77 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
readonly state: string
}) {
const fs = yield* FileSystem.FileSystem
if (yield* fs.exists(input.file).pipe(Effect.orElseSucceed(() => false))) return
const modelFile = path.join(input.state, "model.json")
const model = yield* readJson(modelFile)
const favorites = legacyFavorites(model)
const cleanupModel = () => {
if (!model || !("favorite" in model)) return Effect.void
const next = { ...model }
delete next.favorite
return write(modelFile, JSON.stringify(next) + "\n")
}
const currentText = yield* fs.readFileString(input.file).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (currentText !== undefined) {
const current = yield* readJson(input.file)
if (!current) return
if (typeof current.models === "object" && current.models !== null && Array.isArray(current.models.favorites)) {
yield* cleanupModel()
return
}
if (!favorites.length) return
const updated = applyEdits(
currentText,
modify(currentText, ["models", "favorites"], favorites, {
formattingOptions: { tabSize: 2, insertSpaces: true },
}),
)
yield* write(input.file, updated.endsWith("\n") ? updated : updated + "\n")
yield* cleanupModel()
yield* Effect.logInfo("migrated model favorites to cli config", { from: modelFile, to: input.file })
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 ?? {})
const migrated = {
...migrateV1(legacy, kv ?? {}),
...(favorites.length ? { models: { favorites } } : {}),
}
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* write(input.file, JSON.stringify(migrated, null, 2) + "\n")
yield* cleanupModel()
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"),
favorites.length ? modelFile : undefined,
].filter(Boolean),
to: input.file,
})
function write(file: string, text: string) {
const temp = file + ".tmp"
return fs
.makeDirectory(path.dirname(file), { recursive: true })
.pipe(Effect.andThen(fs.writeFileString(temp, text, { mode: 0o600 })), Effect.andThen(fs.rename(temp, file)))
}
})
function legacyFavorites(value: Record<string, any> | undefined) {
if (!Array.isArray(value?.favorite)) return []
return [
...new Set(
value.favorite.flatMap((item: any) =>
typeof item?.providerID === "string" && typeof item?.modelID === "string"
? [`${item.providerID}/${item.modelID}`]
: [],
),
),
]
}
export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<string, any>): Info {
const plugins = [
...(legacy?.plugin?.map((plugin) =>
@ -48,12 +98,16 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
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")
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 }) } }
? {
theme: {
...(themeName === undefined ? {} : { name: themeName }),
...(themeMode === undefined ? {} : { mode: themeMode }),
},
}
: {}),
...(legacy?.keybinds === undefined ? {} : { keybinds: legacy.keybinds }),
...(plugins.length ? { plugins } : {}),

View file

@ -1,6 +1,6 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Global } from "@opencode-ai/core/global"
import { Effect } from "effect"
import { Effect, Fiber, Option, Stream } from "effect"
import { expect, test } from "bun:test"
import path from "path"
import { Config } from "../src/config"
@ -102,9 +102,7 @@ test("migrates before the first update and does not remigrate afterward", async
draft.animations = false
draft.mouse = false
})
yield* Effect.promise(() =>
Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" })),
)
yield* Effect.promise(() => Bun.write(path.join(directory, "tui.json"), JSON.stringify({ theme: "changed" })))
return yield* service.get()
}),
)
@ -122,7 +120,7 @@ test("migrates before the first update and does not remigrate afterward", async
test("updates a config draft while preserving JSONC comments", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), "{\n // Keep this comment\n \"animations\": true\n}\n")
await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n')
try {
const config = await run(
@ -141,3 +139,90 @@ test("updates a config draft while preserving JSONC comments", async () => {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates model favorites into an existing cli config", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), '{\n // Keep this comment\n "animations": true\n}\n')
await Bun.write(
path.join(directory, "model.json"),
JSON.stringify({
recent: [{ providerID: "anthropic", modelID: "recent" }],
favorite: [
{ providerID: "anthropic", modelID: "claude-sonnet" },
{ providerID: "openai", modelID: "gpt/favorite" },
],
}),
)
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.get()
}),
)
expect(config.models?.favorites).toEqual(["anthropic/claude-sonnet", "openai/gpt/favorite"])
expect(await Bun.file(path.join(directory, "cli.json")).text()).toContain("// Keep this comment")
const model = await Bun.file(path.join(directory, "model.json")).json()
expect(model).toHaveProperty("recent")
expect(model).not.toHaveProperty("favorite")
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("emits config changes written by another process", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), JSON.stringify({ animations: true }))
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
const update = yield* service.changes.pipe(Stream.runHead, Effect.forkChild)
yield* Effect.sleep("100 millis")
yield* Effect.promise(() => Bun.write(path.join(directory, "cli.json"), JSON.stringify({ animations: false })))
return yield* Fiber.join(update).pipe(Effect.timeout("5 seconds"))
}),
)
expect(Option.getOrThrow(config).animations).toBe(false)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("serializes updates from separate config services", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(path.join(directory, "cli.json"), "{}")
try {
await Promise.all([
run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
yield* service.update((draft) => {
draft.animations = false
})
}),
),
run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
yield* service.update((draft) => {
draft.mouse = false
})
}),
),
])
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({ animations: false, mouse: false })
} finally {
await Bun.$`rm -rf ${directory}`
}
})