From 2198880b481a395539e0257eee25d0afeceda235 Mon Sep 17 00:00:00 2001 From: CasualDeveloper <10153929+CasualDeveloper@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:16:41 +0800 Subject: [PATCH] feat: add agent default variant handling in TUI and desktop - Resolve configured variants through shared helpers used by app and TUI - Respect agent-configured variants unless the user selects an override or Default - Restore and hydrate TUI variant state without stale overrides or legacy default sentinels - Add variant tests for shared helpers and agent config Closes #22065 --- packages/app/src/context/local.tsx | 2 +- .../src/util}/model-variant.ts | 4 ++ .../test/util}/model-variant.test.ts | 15 ++++- .../cli/cmd/tui/component/dialog-model.tsx | 9 ++- .../cli/cmd/tui/component/dialog-variant.tsx | 2 +- .../cli/cmd/tui/component/prompt/index.tsx | 8 ++- .../src/cli/cmd/tui/context/local.tsx | 61 +++++++++++++------ packages/opencode/test/agent/agent.test.ts | 18 ++++++ 8 files changed, 95 insertions(+), 24 deletions(-) rename packages/{app/src/context => core/src/util}/model-variant.ts (84%) rename packages/{app/src/context => core/test/util}/model-variant.test.ts (76%) diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index f467e9034fe7..105d3c4f8ad1 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -6,7 +6,7 @@ import { createStore } from "solid-js/store" import { useModels } from "@/context/models" import { useProviders } from "@/hooks/use-providers" import { Persist, persisted } from "@/utils/persist" -import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" +import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@opencode-ai/core/util/model-variant" import { useSDK } from "./sdk" import { useSync } from "./sync" diff --git a/packages/app/src/context/model-variant.ts b/packages/core/src/util/model-variant.ts similarity index 84% rename from packages/app/src/context/model-variant.ts rename to packages/core/src/util/model-variant.ts index 525acbba3219..361d51b3222c 100644 --- a/packages/app/src/context/model-variant.ts +++ b/packages/core/src/util/model-variant.ts @@ -12,6 +12,9 @@ type Model = AgentModel & { variants?: Record } +// selected: string = user-chosen variant name +// selected: null = user explicitly chose "default" (clears any agent-configured variant) +// selected: undefined = no user choice yet (fall back to agent-configured variant) type VariantInput = { variants: string[] selected: string | null | undefined @@ -43,6 +46,7 @@ export function cycleModelVariant(input: VariantInput) { if (index === input.variants.length - 1) return undefined return input.variants[index + 1] } + // No explicit selection: start cycling from the agent-configured variant. if (input.configured && input.variants.includes(input.configured)) { const index = input.variants.indexOf(input.configured) if (index === input.variants.length - 1) return input.variants[0] diff --git a/packages/app/src/context/model-variant.test.ts b/packages/core/test/util/model-variant.test.ts similarity index 76% rename from packages/app/src/context/model-variant.test.ts rename to packages/core/test/util/model-variant.test.ts index 583bc5c3dc71..f79d60048bfa 100644 --- a/packages/app/src/context/model-variant.test.ts +++ b/packages/core/test/util/model-variant.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant" +import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "../../src/util/model-variant" describe("model variant", () => { test("resolves configured agent variant when model matches", () => { @@ -83,4 +83,17 @@ describe("model variant", () => { expect(value).toBe("low") }) + + test("cycles through all variants from explicit selection", () => { + const variants = ["low", "high", "xhigh"] + const first = cycleModelVariant({ variants, selected: undefined, configured: undefined }) + const second = cycleModelVariant({ variants, selected: first, configured: undefined }) + const third = cycleModelVariant({ variants, selected: second, configured: undefined }) + const fourth = cycleModelVariant({ variants, selected: third, configured: undefined }) + + expect(first).toBe("low") + expect(second).toBe("high") + expect(third).toBe("xhigh") + expect(fourth).toBeUndefined() + }) }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 06723f3c2bd3..cf6e8bd242fc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -135,8 +135,13 @@ export function DialogModel(props: { providerID?: string }) { function onSelect(providerID: string, modelID: string) { local.model.set({ providerID, modelID }, { recent: true }) const list = local.model.variant.list() - const cur = local.model.variant.selected() - if (cur === "default" || (cur && list.includes(cur))) { + const selected = local.model.variant.selected() + const current = local.model.variant.current() + if ( + selected === null || + (selected && list.includes(selected)) || + (!selected && current && list.includes(current)) + ) { dialog.clear() return } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx index 28ee1b28250b..1c16fb4af0c6 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx @@ -32,7 +32,7 @@ export function DialogVariant() { options={options()} title={"Select variant"} - current={local.model.variant.selected()} + current={local.model.variant.current() ?? "default"} flat={true} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 1f93a43947bb..00e38b15ad86 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -287,7 +287,13 @@ export function Prompt(props: PromptProps) { if (!args.agent) local.agent.set(msg.agent) if (msg.model) { local.model.set(msg.model) - local.model.variant.set(msg.model.variant) + const configured = local.model.variant.configured() + if (!msg.model.variant || msg.model.variant === configured) { + local.model.variant.clear() + } + if (msg.model.variant && msg.model.variant !== configured) { + local.model.variant.set(msg.model.variant) + } } } } diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 0b8c902c496f..f5f7bdebb606 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -12,6 +12,7 @@ import { useArgs } from "./args" import { useSDK } from "./sdk" import { RGBA } from "@opentui/core" import { Filesystem } from "@/util/filesystem" +import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@opencode-ai/core/util/model-variant" export function parseModel(model: string) { const [providerID, ...rest] = model.split("/") @@ -118,7 +119,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ providerID: string modelID: string }[] - variant: Record + variant: Record }>({ ready: false, model: {}, @@ -149,7 +150,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .then((x: any) => { if (Array.isArray(x.recent)) setModelStore("recent", x.recent) if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite) - if (typeof x.variant === "object" && x.variant !== null) setModelStore("variant", x.variant) + if (typeof x.variant === "object" && x.variant !== null) { + const variant = Object.fromEntries( + Object.entries(x.variant).map(([key, value]) => [ + key, + value === "default" ? null : typeof value === "string" || value === null ? value : undefined, + ]), + ) as Record + setModelStore("variant", variant) + if (Object.values(x.variant).includes("default")) state.pending = true + } }) .catch(() => {}) .finally(() => { @@ -334,6 +344,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) }, variant: { + configured() { + const a = agent.current() + const m = currentModel() + if (!m || !a) return undefined + const provider = sync.data.provider.find((x) => x.id === m.providerID) + const info = provider?.models[m.modelID] + return getConfiguredAgentVariant({ + agent: { model: a.model, variant: a.variant }, + model: { providerID: m.providerID, modelID: m.modelID, variants: info?.variants }, + }) + }, selected() { const m = currentModel() if (!m) return undefined @@ -341,10 +362,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return modelStore.variant[key] }, current() { - const v = this.selected() - if (!v) return undefined - if (!this.list().includes(v)) return undefined - return v + return resolveModelVariant({ + variants: this.list(), + selected: this.selected(), + configured: this.configured(), + }) }, list() { const m = currentModel() @@ -358,23 +380,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const m = currentModel() if (!m) return const key = `${m.providerID}/${m.modelID}` - setModelStore("variant", key, value ?? "default") + setModelStore("variant", key, value ?? null) + save() + }, + clear() { + const m = currentModel() + if (!m) return + const key = `${m.providerID}/${m.modelID}` + setModelStore("variant", key, undefined) save() }, cycle() { const variants = this.list() if (variants.length === 0) return - const current = this.current() - if (!current) { - this.set(variants[0]) - return - } - const index = variants.indexOf(current) - if (index === -1 || index === variants.length - 1) { - this.set(undefined) - return - } - this.set(variants[index + 1]) + this.set( + cycleModelVariant({ + variants, + selected: this.selected(), + configured: this.configured(), + }), + ) }, }, } diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 06bb103f068a..723f862d688e 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -740,3 +740,21 @@ test("defaultAgent throws when all primary agents are disabled", async () => { }, }) }) + +test("agent variant can be set from config", async () => { + await using tmp = await tmpdir({ + config: { + agent: { + build: { variant: "high" }, + }, + }, + }) + const build = await load(tmp.path, (svc) => svc.get("build")) + expect(build?.variant).toBe("high") +}) + +test("agent variant defaults to undefined when not set", async () => { + await using tmp = await tmpdir() + const build = await load(tmp.path, (svc) => svc.get("build")) + expect(build?.variant).toBeUndefined() +})