From fb8e06d6a497aaf2bac1003a2b481c8597e6c8aa Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 15 Jul 2026 20:23:52 +0000 Subject: [PATCH] fix(tui): sync model favorites --- packages/cli/src/commands/handlers/default.ts | 8 +- packages/cli/src/config/config.ts | 70 +++++++++----- packages/cli/src/config/migrate.ts | 74 +++++++++++++-- packages/cli/test/config.test.ts | 95 ++++++++++++++++++- packages/tui/src/config/index.tsx | 14 ++- packages/tui/src/context/local.tsx | 59 ++++++------ packages/tui/test/config-v2.test.tsx | 42 ++++++-- 7 files changed, 282 insertions(+), 80 deletions(-) diff --git a/packages/cli/src/commands/handlers/default.ts b/packages/cli/src/commands/handlers/default.ts index 1f1a441571..2c138c448b 100644 --- a/packages/cli/src/commands/handlers/default.ts +++ b/packages/cli/src/commands/handlers/default.ts @@ -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) => diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index d9f01b3fce..d660abae8b 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -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 readonly update: (update: (draft: Draft) => void) => Effect.Effect + readonly changes: Stream.Stream } export class Service extends Context.Service()("@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 = (effect: Effect.Effect) => + 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) => 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 }] diff --git a/packages/cli/src/config/migrate.ts b/packages/cli/src/config/migrate.ts index ca8070e25c..88b57eb105 100644 --- a/packages/cli/src/config/migrate.ts +++ b/packages/cli/src/config/migrate.ts @@ -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 | 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): Info { const plugins = [ ...(legacy?.plugin?.map((plugin) => @@ -48,12 +98,16 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record - 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}` + } +}) diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 46c649fff1..4ddaecfeb3 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -2,7 +2,7 @@ export * as Config from "." import { createBindingLookup } from "@opentui/keymap/extras" import { Schema } from "effect" -import { createContext, type JSX, useContext } from "solid-js" +import { createContext, onCleanup, onMount, type JSX, useContext } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { TuiKeybind } from "./keybind" @@ -10,6 +10,7 @@ export interface Interface { readonly path?: string readonly get: () => Promise readonly update: (update: (draft: any) => void) => Promise + readonly subscribe?: (listener: (info: Info) => void) => () => void } export const AttentionSoundName = Schema.Literals([ @@ -126,6 +127,11 @@ export const Info = Schema.Struct({ onboarding: Schema.optional(Schema.Boolean).annotate({ description: "Show getting-started guidance" }), }), ).annotate({ description: "In-product guidance settings" }), + models: Schema.optional( + Schema.Struct({ + favorites: Schema.optional(Schema.Array(Schema.String)).annotate({ description: "Favorite model IDs" }), + }), + ).annotate({ description: "Model picker settings" }), animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }), mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable terminal mouse capture" }), }) @@ -196,6 +202,12 @@ export function ConfigProvider(props: { setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true }))) return info } + onMount(() => { + const unsubscribe = host?.subscribe?.((info) => { + setConfig(reconcile(resolve(info, props.options ?? { terminalSuspend: true }))) + }) + if (unsubscribe) onCleanup(unsubscribe) + }) return ( {props.children} ) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 56655646ec..f5dccb03ff 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -13,6 +13,7 @@ import { useToast } from "../ui/toast" import { useRoute } from "./route" import { useData } from "./data" import { usePermission } from "./permission" +import { useConfig } from "../config" export type LocalTheme = { secondary: RGBA @@ -60,6 +61,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const args = useArgs() const event = useEvent() const permission = usePermission() + const config = useConfig() function isModelValid(model: { providerID: string; modelID: string }) { return !!data.location.model @@ -151,16 +153,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ providerID: string modelID: string }[] - favorite: { - providerID: string - modelID: string - }[] variant: Record }>({ ready: false, model: {}, recent: [], - favorite: [], variant: {}, }) @@ -177,7 +174,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ state.pending = false void writeJsonAtomic(filePath, { recent: modelStore.recent, - favorite: modelStore.favorite, variant: modelStore.variant, }) } @@ -187,7 +183,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (!x || typeof x !== "object") return const value = x as Record if (Array.isArray(value.recent)) setModelStore("recent", value.recent) - if (Array.isArray(value.favorite)) setModelStore("favorite", value.favorite) if (typeof value.variant === "object" && value.variant !== null) setModelStore("variant", value.variant as Record) }) @@ -242,7 +237,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return modelStore.recent }, favorite() { - return modelStore.favorite + return (config.data.models?.favorites ?? []).map(parseModel) }, parsed: createMemo(() => { const value = currentModel() @@ -279,7 +274,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ setModelStore("model", a.id, { ...val }) }, cycleFavorite(direction: 1 | -1) { - const favorites = modelStore.favorite.filter((item) => isModelValid(item)) + const favorites = (config.data.models?.favorites ?? []).map(parseModel).filter((item) => isModelValid(item)) if (!favorites.length) { toast.show({ variant: "info", @@ -328,27 +323,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) }, toggleFavorite(model: { providerID: string; modelID: string }) { - batch(() => { - if (!isModelValid(model)) { - toast.show({ - message: `Model ${model.providerID}/${model.modelID} is not valid`, - variant: "warning", - duration: 3000, - }) - return - } - const exists = modelStore.favorite.some( - (x) => x.providerID === model.providerID && x.modelID === model.modelID, - ) - const next = exists - ? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID) - : [model, ...modelStore.favorite] - setModelStore( - "favorite", - next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })), - ) - save() - }) + if (!isModelValid(model)) { + toast.show({ + message: `Model ${model.providerID}/${model.modelID} is not valid`, + variant: "warning", + duration: 3000, + }) + return + } + const key = `${model.providerID}/${model.modelID}` + void config + .update((draft) => { + draft.models ??= {} + const favorites: string[] = draft.models.favorites ?? [] + draft.models.favorites = favorites.includes(key) + ? favorites.filter((favorite) => favorite !== key) + : [key, ...favorites] + }) + .catch(() => {}) }, variant: { selected() { @@ -441,7 +433,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const slots = createMemo(() => { - const existing = new Set(data.session.list().filter((x) => x.parentID === undefined).map((x) => x.id)) + const existing = new Set( + data.session + .list() + .filter((x) => x.parentID === undefined) + .map((x) => x.id), + ) return sessionStore.pinned.filter((id) => existing.has(id)).slice(0, 9) }) diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index c8c36f89ac..ef2e4c1841 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -1,12 +1,7 @@ /** @jsxImportSource @opentui/solid */ import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" -import { - resolve, - ConfigProvider, - useConfig, - type Interface, -} from "../src/config" +import { resolve, ConfigProvider, useConfig, type Interface } from "../src/config" test("resolves nested config and keybind defaults", () => { const config = resolve( @@ -63,3 +58,38 @@ test("provides config and its host interface", async () => { app.renderer.destroy() } }) + +test("reconciles config updates from another process", async () => { + const config = resolve({ models: { favorites: ["anthropic/old"] } }, { terminalSuspend: true }) + let listener: ((info: { models?: { favorites?: string[] } }) => void) | undefined + let unsubscribed = false + const service: Interface = { + get: async () => ({}), + update: async () => ({}), + subscribe: (next) => { + listener = next + return () => { + unsubscribed = true + } + }, + } + let context: ReturnType | undefined + + function Consumer() { + context = useConfig() + return {context.data.models?.favorites?.join(",")} + } + + const app = await testRender(() => ( + + + + )) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("anthropic/old") + listener?.({ models: { favorites: ["anthropic/new"] } }) + await app.renderOnce() + expect(app.captureCharFrame()).toContain("anthropic/new") + app.renderer.destroy() + expect(unsubscribed).toBe(true) +})