mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
fix(tui): sync model favorites
This commit is contained in:
@@ -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) =>
|
||||
|
||||
@@ -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 }]
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<Info>
|
||||
readonly update: (update: (draft: any) => void) => Promise<Info>
|
||||
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 (
|
||||
<ConfigContext.Provider value={{ data: config, path: host?.path, update }}>{props.children}</ConfigContext.Provider>
|
||||
)
|
||||
|
||||
@@ -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<string, string | undefined>
|
||||
}>({
|
||||
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<string, unknown>
|
||||
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<string, string | undefined>)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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<typeof useConfig> | undefined
|
||||
|
||||
function Consumer() {
|
||||
context = useConfig()
|
||||
return <text>{context.data.models?.favorites?.join(",")}</text>
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<ConfigProvider config={config} service={service}>
|
||||
<Consumer />
|
||||
</ConfigProvider>
|
||||
))
|
||||
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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user