Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 64 additions & 33 deletions packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import {
cycleModelVariant,
getConfiguredAgentVariant,
migrateVariantSelection,
resolveModelVariant,
} from "./model-variant"

export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
Expand Down Expand Up @@ -118,7 +124,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
providerID: string
modelID: string
}[]
variant: Record<string, string | undefined>
variant: Record<string, string | null | undefined>
}>({
ready: false,
model: {},
Expand Down Expand Up @@ -149,7 +155,10 @@ 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 agentNames = sync.data.agent.map((x) => x.name)
setModelStore("variant", migrateVariantSelection(x.variant, agentNames.length ? agentNames : undefined))
}
})
.catch(() => {})
.finally(() => {
Expand Down Expand Up @@ -208,6 +217,38 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
})

const info = () => {
const item = currentModel()
if (!item) return undefined
const provider = sync.data.provider.find((x) => x.id === item.providerID)
const model = provider?.models[item.modelID]
if (!model) return undefined
return {
providerID: item.providerID,
modelID: item.modelID,
variants: model.variants,
}
}

const variants = () => {
const item = info()
if (!item?.variants) return []
return Object.keys(item.variants)
}

const configured = () => {
return getConfiguredAgentVariant({
agent: agent.current(),
model: info(),
})
}

const stored = () => {
const currentAgent = agent.current()
if (!currentAgent) return undefined
return modelStore.variant[currentAgent.name]
}

return {
current: currentModel,
get ready() {
Expand Down Expand Up @@ -335,46 +376,36 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
},
variant: {
selected() {
const m = currentModel()
if (!m) return undefined
const key = `${m.providerID}/${m.modelID}`
return modelStore.variant[key]
const value = stored()
if (value === null) return "default"
return value
},
current() {
const v = this.selected()
if (!v) return undefined
if (!this.list().includes(v)) return undefined
return v
return resolveModelVariant({
variants: variants(),
selected: stored(),
configured: configured(),
})
},
list() {
const m = currentModel()
if (!m) return []
const provider = sync.data.provider.find((x) => x.id === m.providerID)
const info = provider?.models[m.modelID]
if (!info?.variants) return []
return Object.keys(info.variants)
return variants()
},
set(value: string | undefined) {
const m = currentModel()
if (!m) return
const key = `${m.providerID}/${m.modelID}`
setModelStore("variant", key, value ?? "default")
const currentAgent = agent.current()
if (!currentAgent) return
setModelStore("variant", currentAgent.name, value ?? null)
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])
const items = variants()
if (items.length === 0) return
this.set(
cycleModelVariant({
variants: items,
selected: stored(),
configured: configured(),
}),
)
},
},
}
Expand Down
68 changes: 68 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/model-variant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
type AgentModel = {
providerID: string
modelID: string
}

type Agent = {
model?: AgentModel
variant?: string
}

type Model = AgentModel & {
variants?: Record<string, unknown>
}

type VariantInput = {
variants: string[]
selected: string | null | undefined
configured: string | undefined
}

export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
if (!input.agent?.variant) return undefined
if (!input.agent.model) return undefined
if (!input.model?.variants) return undefined
if (input.agent.model.providerID !== input.model.providerID) return undefined
if (input.agent.model.modelID !== input.model.modelID) return undefined
if (!(input.agent.variant in input.model.variants)) return undefined
return input.agent.variant
}

export function resolveModelVariant(input: VariantInput) {
if (input.selected === null) return undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
}

export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined
if (input.selected === null) return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
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]
return input.variants[index + 1]
}
return input.variants[0]
}

export function migrateVariantSelection(input: unknown, agents?: Iterable<string>) {
if (!input || typeof input !== "object") return {}

const knownAgents = agents ? new Set(agents) : undefined
const result: Record<string, string | null | undefined> = {}

for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
if (knownAgents && knownAgents.size > 0 && !knownAgents.has(key)) continue
if (!knownAgents && key.includes("/")) continue
if (typeof value !== "string" && value !== null && value !== undefined) continue
result[key] = value
}

return result
}
121 changes: 121 additions & 0 deletions packages/opencode/test/cli/tui/model-variant.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, test } from "bun:test"
import {
cycleModelVariant,
getConfiguredAgentVariant,
migrateVariantSelection,
resolveModelVariant,
} from "../../../src/cli/cmd/tui/context/model-variant"

describe("tui model variant", () => {
test("resolves configured agent variant when model matches", () => {
const value = getConfiguredAgentVariant({
agent: {
model: { providerID: "openai", modelID: "gpt-5.2" },
variant: "xhigh",
},
model: {
providerID: "openai",
modelID: "gpt-5.2",
variants: { low: {}, high: {}, xhigh: {} },
},
})

expect(value).toBe("xhigh")
})

test("ignores configured variant when model does not match", () => {
const value = getConfiguredAgentVariant({
agent: {
model: { providerID: "openai", modelID: "gpt-5.2" },
variant: "xhigh",
},
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4",
variants: { low: {}, high: {}, xhigh: {} },
},
})

expect(value).toBeUndefined()
})

test("prefers selected variant over configured variant", () => {
const value = resolveModelVariant({
variants: ["low", "high", "xhigh"],
selected: "high",
configured: "xhigh",
})

expect(value).toBe("high")
})

test("lets an explicit default override the configured variant", () => {
const value = resolveModelVariant({
variants: ["low", "high", "xhigh"],
selected: null,
configured: "xhigh",
})

expect(value).toBeUndefined()
})

test("cycles from configured variant to next", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "high",
})

expect(value).toBe("xhigh")
})

test("cycles from configured last variant to first", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "xhigh",
})

expect(value).toBe("low")
})

test("cycles from an explicit default to the first variant", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: null,
configured: "xhigh",
})

expect(value).toBe("low")
})

test("ignores legacy model-keyed variant cache entries", () => {
const value = migrateVariantSelection(
{
build: "high",
big: null,
"openai/gpt-5.4": "xhigh",
},
["build", "big", "plan"],
)

expect(value).toEqual({
build: "high",
big: null,
})
})

test("keeps agent names with slashes when they are known", () => {
const value = migrateVariantSelection(
{
"custom/build": "high",
"openai/gpt-5.4": "xhigh",
},
["custom/build"],
)

expect(value).toEqual({
"custom/build": "high",
})
})
})
Loading