fix(tui): restore agent and model selection parity (#47260)

This commit is contained in:
Shoubhit Dash 2026-09-04 18:16:04 +05:30 committed by GitHub
parent e26cbb935e
commit acb462a173
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 795 additions and 167 deletions

View file

@ -6,6 +6,7 @@
import type {
AgentInfo,
CommandInfo,
ConfigEntry,
FormCancelInput,
FormInfo,
FormReplyInput,
@ -84,6 +85,7 @@ type LocationData = {
vcs?: VcsInfo
agent?: AgentInfo[]
command?: CommandInfo[]
config?: ConfigEntry[]
integration?: IntegrationInfo[]
mcpServer?: McpServer[]
mcpResource?: McpResource[]
@ -1215,6 +1217,11 @@ export function createData(config: CreateDataInput) {
)
break
case "config.updated":
result.location.config.invalidate(location)
if (result.location.config.list(location) !== undefined || sync.has(`location.config:${locationKey(location)}`))
refresh(() => result.location.config.sync(location))
refresh(() => result.location.websearch.refresh(location))
break
case "websearch.updated":
refresh(() => result.location.websearch.refresh(location))
break
@ -1790,6 +1797,7 @@ export function createData(config: CreateDataInput) {
result.location.vcs.invalidate(location)
result.location.agent.invalidate(location)
result.location.command.invalidate(location)
result.location.config.invalidate(location)
result.location.integration.invalidate(location)
result.location.mcp.server.invalidate(location)
result.location.mcp.resource.invalidate(location)
@ -1803,6 +1811,10 @@ export function createData(config: CreateDataInput) {
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
agent: locationResource("agent", (location) => api().agent.list({ location })),
command: locationResource("command", (location) => api().command.list({ location })),
config: locationResource("config", async (location) => ({
location: { directory: location.directory, workspaceID: location.workspace },
data: await api().config.get({ location }),
})),
integration: locationResource("integration", (location) => api().integration.list({ location })),
mcp: {
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),

View file

@ -3,6 +3,56 @@ import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
test("config reads and update refreshes are opt-in", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: string[] = []
const location = { directory: "/project" }
let model = "provider/first"
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url.pathname)
if (url.pathname === "/api/location") return Response.json(location)
if (url.pathname === "/api/config") return Response.json([{ type: "document", info: { model } }])
if (url.pathname === "/api/mcp/resource")
return Response.json({ location, data: { resources: [], templates: [] } })
return Response.json({ location, data: [] })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: location.directory,
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
}),
dispose,
}))
const event: OpenCodeEvent = { id: "evt_config", created: 1, type: "config.updated", location, data: {} }
try {
await setup.data.location.sync()
listeners.forEach((listener) => listener({ name: event.type, details: event }))
expect(requests).not.toContain("/api/config")
await setup.data.location.config.sync()
expect(requests.filter((path) => path === "/api/config")).toHaveLength(1)
model = "provider/second"
listeners.forEach((listener) => listener({ name: event.type, details: event }))
await setup.data.location.config.sync()
expect(requests.filter((path) => path === "/api/config")).toHaveLength(2)
expect(setup.data.location.config.list()).toEqual([{ type: "document", info: { model } }])
} finally {
setup.dispose()
}
})
test("event refreshes report failures, remain retryable, and preserve explicit read errors", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const reported = Promise.withResolvers<unknown>()

View file

@ -737,6 +737,7 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
slash: { name: "new", aliases: ["clear"] },
run: () => {
const model = local.model.current()
const agent = local.agent.current()
const current =
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
@ -750,6 +751,7 @@ function App(props: { pair?: DialogPairCredentials; updater?: TuiInput["updater"
location.error?.location,
),
})
if (agent) local.agent.set(agent.id)
if (model) local.model.set(model)
dialog.clear()
},

View file

@ -7,22 +7,33 @@ export function DialogVariant() {
const local = useLocal()
const dialog = useDialog()
const options = createMemo(() =>
local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
const options = createMemo(() => [
{
value: "default",
title: "Default",
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
local.model.variant.set(undefined)
},
})),
)
},
...local.model.variant
.list()
.filter((variant) => variant !== "default")
.map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
})),
])
return (
<DialogSelect<string>
options={options()}
title={"Select variant"}
current={local.model.variant.current()}
current={local.model.variant.current() ?? "default"}
flat={true}
/>
)

View file

@ -48,7 +48,6 @@ import { useConnected } from "../use-connected"
import { useToast } from "../../ui/toast"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
import { resolvePastedAttachments } from "./local-attachment"
@ -190,7 +189,6 @@ export function Prompt(props: PromptProps) {
const leader = Keymap.useLeaderActive()
const muted = () => leader() || props.muted
const local = useLocal()
const args = useArgs()
const paths = useTuiPaths()
const terminalEnvironment = useTuiTerminalEnvironment()
const clipboard = useClipboard()
@ -412,18 +410,6 @@ export function Prompt(props: PromptProps) {
),
)
// Initialize agent/model/variant from the durable V2 Session state.
let syncedSessionID: string | undefined
createEffect(() => {
const sessionID = props.sessionID
if (!sessionID || sessionID === syncedSessionID || !local.model.ready) return
const session = data.session.get(sessionID)
if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id)
syncedSessionID = sessionID
})
const promptCommands = createMemo(() =>
[
{
@ -1265,6 +1251,23 @@ export function Prompt(props: PromptProps) {
}
const target = sessionID
const prepareAgent = async () => {
if (!session) {
await data.session.sync(target)
session = data.session.get(target)
}
if (session?.agent !== agent.id) {
await client.api.session.switchAgent({ sessionID: target, agent: agent.id })
}
}
const commitModel = () => {
const model = { providerID: selection.providerID, id: selection.modelID, variant }
const cancelCommit = local.model.trackSessionCommit(target, model, agent.id)
return client.api.session.switchModel({ sessionID: target, model }).catch((error) => {
cancelCommit()
throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error })
})
}
history.append(entry)
const dispatch = (send: () => Promise<unknown>) => {
const setup = newSession
@ -1276,8 +1279,12 @@ export function Prompt(props: PromptProps) {
dispatch(() => client.api.session.shell({ sessionID: target, command: inputText }))
setStore("mode", "normal")
} else if (slashHead && isCommand) {
const send = () =>
client.api.session.command({
const send = async () => {
await prepareAgent()
// Commands inherit the composer selection; command-specific overrides
// remain server-owned and run after this preparation.
await commitModel()
return client.api.session.command({
sessionID: target,
command: slashHead.name,
text: slashHead.arguments,
@ -1286,6 +1293,7 @@ export function Prompt(props: PromptProps) {
skills: entry.skills?.length ? entry.skills : undefined,
delivery,
})
}
const setup = newSession
void (setup ? setup.gate.then(send) : send()).catch((error) => {
if (setup) return setup.recover(error)
@ -1298,19 +1306,12 @@ export function Prompt(props: PromptProps) {
} else {
move.startSubmit()
try {
if (!session) {
await data.session.sync(target)
session = data.session.get(target)
}
if (session?.agent !== agent.id) {
await client.api.session.switchAgent({ sessionID: target, agent: agent.id })
}
await prepareAgent()
} catch (error) {
toast.show({ title: "Failed to prepare session", message: errorMessage(error), variant: "error" })
restoreEntry()
return true
}
const model = { providerID: selection.providerID, id: selection.modelID, variant }
if (session?.revert) {
const error = await client.api.session.revert.commit({ sessionID: target }).then(
() => undefined,
@ -1359,16 +1360,10 @@ export function Prompt(props: PromptProps) {
skills: entry.skills?.length ? entry.skills : undefined,
delivery,
gate: newSession?.gate,
prepare: () => {
// Commit the captured selection after earlier admissions, including
// compaction setup. Cached state may still precede their SSE echoes;
// the server makes an unchanged selection a no-op.
const cancelCommit = local.model.trackSessionCommit(target, model)
return client.api.session.switchModel({ sessionID: target, model }).catch((error) => {
cancelCommit()
throw new Error(`Failed to switch model: ${errorMessage(error)}`, { cause: error })
})
},
// Commit the captured selection after earlier admissions, including
// compaction setup. Cached state may still precede their SSE echoes;
// the server makes an unchanged selection a no-op.
prepare: commitModel,
})
.catch((error) => {
if (newSession) return newSession.recover(error)

View file

@ -1,4 +1,5 @@
import { createData } from "@opencode-ai/client/solid"
import type { LocationRef } from "@opencode-ai/client"
import type { Plugin } from "@opencode-ai/plugin/tui"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "./helper"
@ -21,6 +22,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const [generatingTitles, setGeneratingTitles] = createStore<Record<string, boolean | undefined>>({})
return {
...data,
location: {
...data.location,
async sync(ref?: LocationRef) {
await data.location.syncInfo(ref)
await Promise.all([data.location.sync(ref), data.location.config.sync(ref)])
},
},
session: {
...data.session,
title: {

View file

@ -6,7 +6,6 @@ import { useEvent } from "./event"
import path from "path"
import { useTuiPaths } from "./runtime"
import { useArgs } from "./args"
import { useClient } from "./client"
import { RGBA } from "@opentui/core"
import { readJson, writeJsonAtomic } from "../util/persistence"
import {
@ -23,14 +22,7 @@ import { useRoute } from "./route"
import { useData } from "./data"
import { usePermission } from "./permission"
import { useLocation } from "./location"
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
return {
providerID: providerID,
modelID: rest.join("/"),
}
}
import { parse } from "../util/model"
export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) {
const seen = new Set<string>()
@ -49,7 +41,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
const data = useData()
const client = useClient()
const toast = useToast()
const theme = useTheme()
const { mode } = useThemes()
@ -83,7 +74,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
draftBySession: {} as Record<string, { agent?: string } | undefined>,
})
onCleanup(event.on("session.deleted", (evt) => setAgentStore("draftBySession", evt.data.sessionID, undefined)))
onCleanup(
event.on("session.agent.selected", (evt) => {
// Keep an entry after acknowledgment: CLI defaults must not override a user's later choice.
if (agentStore.draftBySession[evt.data.sessionID]?.agent === evt.data.agent)
setAgentStore("draftBySession", evt.data.sessionID, { agent: undefined })
}),
)
const colors = createMemo(() => {
const step = mode() === "light" ? 800 : 200
return dedupeWith(
@ -96,7 +96,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return agents()
},
current() {
return agents().find((agent) => agent.id === agentStore.current) ?? agents().at(0)
const draft = route.data.type === "session" ? agentStore.draftBySession[route.data.sessionID] : undefined
const selected =
route.data.type === "session"
? (draft?.agent ??
(draft ? undefined : args.agent) ??
data.session.get(route.data.sessionID)?.agent ??
agentStore.current)
: agentStore.current
return agents().find((agent) => agent.id === selected) ?? agents().at(0)
},
set(id: string) {
if (!agents().some((agent) => agent.id === id))
@ -105,7 +113,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
message: `Agent not found: ${id}`,
duration: 3000,
})
setAgentStore("current", id)
batch(() => {
const changed = this.current()?.id !== id
if (changed) model.remember()
setAgentStore("current", id)
if (route.data.type === "session") setAgentStore("draftBySession", route.data.sessionID, { agent: id })
// Retain both selections while agent and model commits arrive separately.
const selected = changed && route.data.type === "session" ? model.current() : undefined
if (selected) model.set(selected)
})
},
move(direction: 1 | -1) {
batch(() => {
@ -115,7 +131,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (next < 0) next = agents().length - 1
if (next >= agents().length) next = 0
const value = agents()[next]
setAgentStore("current", value.id)
this.set(value.id)
})
},
color(id: string) {
@ -141,14 +157,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const [selectionState, setSelectionState] = createStore<{
newSessionModelByLocationAgent: Record<string, ModelPreferenceModel | undefined>
draftBySession: Record<string, ModelSelection | undefined>
selectionBySessionAgent: Record<string, Record<string, ModelSelection | undefined> | undefined>
}>({
newSessionModelByLocationAgent: {},
draftBySession: {},
selectionBySessionAgent: {},
})
const repository = createModelPreferenceRepository(path.join(paths.state, "model.json"))
const pendingSelectionCommits = new Map<string, string>()
const pendingSelectionCommits = new Map<string, { agentID: string; selection: string }>()
const selectionKey = (value: ModelSelection) =>
`${modelPreferenceKey(value)}:${normalizeModelVariant(value.variant) ?? "default"}`
const saveState = {
@ -183,9 +199,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (saveState.pending) savePreferences()
})
const configuredModel = createMemo(() => {
const entry = data.location.config
.list(location.ref)
?.findLast((entry) => entry.type === "document" && entry.info.model !== undefined)
const configured = entry?.type === "document" ? entry.info.model : undefined
if (!configured) return
return typeof configured === "string"
? { ...parse(configured), variant: undefined }
: { providerID: configured.providerID, modelID: configured.model, variant: configured.variant }
})
const fallbackModel = createMemo(() => {
if (args.model) {
const { providerID, modelID } = parseModel(args.model)
const { providerID, modelID } = parse(args.model)
if (isModelValid({ providerID, modelID })) {
return {
providerID,
@ -194,6 +221,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const configured = configuredModel()
if (configured && isModelValid(configured)) return configured
for (const item of preferences.recent) {
if (isModelValid(item)) {
return item
@ -221,7 +251,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (route.data.type === "session") return sessionSelection(route.data.sessionID)
const model = newSessionModel()
if (!model) return
return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) }
return preferredSelection(model)
})
const currentModel = createMemo(() => {
@ -235,6 +265,23 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return `${JSON.stringify([ref.directory, ref.workspaceID])}:${agentID}`
}
function preferredSelection(model: ModelPreferenceModel): ModelSelection {
const configured = agent.current()?.model
const fallback = configuredModel()
const preferred = preferences.variant[modelPreferenceKey(model)]
const variant = normalizeModelVariant(
preferred ??
(configured?.providerID === model.providerID && configured.id === model.modelID
? configured.variant
: undefined) ??
(fallback?.providerID === model.providerID && fallback.modelID === model.modelID
? fallback.variant
: undefined),
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
return { ...model, variant: info?.variants.some((item) => item.id === variant) ? variant : undefined }
}
function durableSelection(sessionID: string): ModelSelection | undefined {
const model = data.session.get(sessionID)?.model
if (!model) return
@ -246,15 +293,44 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
function sessionSelection(sessionID: string) {
return selectionState.draftBySession[sessionID] ?? durableSelection(sessionID)
const current = agent.current()
if (!current) return
const session = data.session.get(sessionID)
const selected = [
selectionState.selectionBySessionAgent[sessionID]?.[current.id],
!session?.agent || session.agent === current.id ? durableSelection(sessionID) : undefined,
].find((selection) => selection && isModelValid(selection))
if (selected) {
const info = models()?.find((item) => item.providerID === selected.providerID && item.id === selected.modelID)
return {
...selected,
variant: info?.variants.some((variant) => variant.id === selected.variant) ? selected.variant : undefined,
}
}
const model = newSessionModel()
return model && preferredSelection(model)
}
function setSessionSelection(sessionID: string, agentID: string, selection: ModelSelection | undefined) {
setSelectionState("selectionBySessionAgent", sessionID, {
...selectionState.selectionBySessionAgent[sessionID],
[agentID]: selection,
})
}
function setSessionDraft(sessionID: string, selection: ModelSelection) {
const current = agent.current()
if (!current) return
const durable = durableSelection(sessionID)
setSelectionState(
"draftBySession",
const session = data.session.get(sessionID)
setSessionSelection(
sessionID,
durable && selectionKey(durable) === selectionKey(selection) ? undefined : selection,
current.id,
(!session?.agent || session.agent === current.id) &&
durable &&
selectionKey(durable) === selectionKey(selection)
? undefined
: selection,
)
}
@ -262,14 +338,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (route.data.type === "session") {
const sessionID = route.data.sessionID
const current = sessionSelection(sessionID)
const preferred = normalizeModelVariant(
setSessionDraft(
sessionID,
current?.providerID === model.providerID && current.modelID === model.modelID
? current.variant
: preferences.variant[modelPreferenceKey(model)],
? current
: preferredSelection(model),
)
const info = models()?.find((item) => item.providerID === model.providerID && item.id === model.modelID)
const variant = preferred && info?.variants?.some((item) => item.id === preferred) ? preferred : undefined
setSessionDraft(sessionID, { ...model, variant })
return true
}
const current = agent.current()
@ -278,33 +352,43 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return true
}
onCleanup(
event.on("session.model.selected", (evt) => {
const expected = pendingSelectionCommits.get(evt.data.sessionID)
if (!expected) return
const committed = selectionKey({
providerID: evt.data.model.providerID,
modelID: evt.data.model.id,
variant: evt.data.model.variant,
})
if (committed !== expected) return
pendingSelectionCommits.delete(evt.data.sessionID)
const draft = selectionState.draftBySession[evt.data.sessionID]
if (draft && selectionKey(draft) === committed)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
}),
)
function reconcileSessionSelection(sessionID: string) {
const expected = pendingSelectionCommits.get(sessionID)
const durable = durableSelection(sessionID)
if (!expected || !durable || data.session.get(sessionID)?.agent !== expected.agentID) return
if (selectionKey(durable) !== expected.selection) return
pendingSelectionCommits.delete(sessionID)
// Inactive agents keep their remembered choices after another agent commits.
if (
route.data.type !== "session" ||
route.data.sessionID !== sessionID ||
agent.current()?.id !== expected.agentID
)
return
const draft = selectionState.selectionBySessionAgent[sessionID]?.[expected.agentID]
if (draft && selectionKey(draft) === expected.selection)
setSessionSelection(sessionID, expected.agentID, undefined)
}
onCleanup(event.on("session.model.selected", (evt) => reconcileSessionSelection(evt.data.sessionID)))
onCleanup(event.on("session.agent.selected", (evt) => reconcileSessionSelection(evt.data.sessionID)))
onCleanup(
event.on("session.deleted", (evt) => {
pendingSelectionCommits.delete(evt.data.sessionID)
setSelectionState("draftBySession", evt.data.sessionID, undefined)
setSelectionState("selectionBySessionAgent", evt.data.sessionID, undefined)
}),
)
return {
current: currentModel,
selection: currentSelection,
remember() {
const current = agent.current()
const selection = currentSelection()
if (route.data.type !== "session" || !current || !selection) return
setSessionSelection(route.data.sessionID, current.id, { ...selection })
},
available(model = currentModel()) {
return model ? isModelValid(model) : false
},
@ -315,9 +399,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
id: string
variant?: string
},
agentID: string,
) {
const committed = selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant })
const committed = {
agentID,
selection: selectionKey({ providerID: value.providerID, modelID: value.id, variant: value.variant }),
}
pendingSelectionCommits.set(sessionID, committed)
// An unchanged model emits no event; the agent may be the only durable change.
reconcileSessionSelection(sessionID)
return () => {
if (pendingSelectionCommits.get(sessionID) === committed) pendingSelectionCommits.delete(sessionID)
}
@ -354,7 +444,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
cycle(direction: 1 | -1) {
const current = currentSelection()
if (!current) return
const recent = recentModels(current, preferences.recent).filter(isModelValid)
const recent = preferences.recent.filter(isModelValid)
const index = recent.findIndex((x) => x.providerID === current.providerID && x.modelID === current.modelID)
let next = index === -1 ? (direction === 1 ? 0 : recent.length - 1) : index + direction
if (next < 0) next = recent.length - 1
@ -422,9 +512,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return currentSelection()?.variant
},
current() {
const v = this.selected()
if (v && this.list().includes(v)) return v
return undefined
return this.selected()
},
list() {
const m = currentSelection()
@ -438,7 +526,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (route.data.type === "session") {
setSessionDraft(route.data.sessionID, { ...m, variant: normalizeModelVariant(value) })
}
setPreferences("variant", modelPreferenceKey(m), normalizeModelVariant(value))
setPreferences("variant", modelPreferenceKey(m), value ?? "default")
savePreferences()
},
cycle() {

View file

@ -29,8 +29,8 @@ function variants(value: unknown) {
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => {
if (key.length === 0 || typeof item !== "string" || item.length === 0) return []
const variant = normalizeModelVariant(item)
return variant === undefined ? [] : ([[key, variant]] as const)
// Preserve explicit "default" so it can override an agent's configured variant.
return [[key, item]] as const
}),
)
}
@ -108,17 +108,12 @@ export function createModelPreferenceRepository(filePath: string) {
return update(() => value)
},
async resolveVariant(model: ModelPreferenceModel) {
return (await load()).variant[modelPreferenceKey(model)]
return normalizeModelVariant((await load()).variant[modelPreferenceKey(model)])
},
saveVariant(model: ModelPreferenceModel, value: string | undefined) {
const key = modelPreferenceKey(model)
const next = normalizeModelVariant(value)
return update((current) => {
const variant = { ...current.variant }
if (next === undefined) delete variant[key]
if (next !== undefined) variant[key] = next
return { variant }
})
const next = normalizeModelVariant(value) ?? "default"
return update((current) => ({ variant: { ...current.variant, [key]: next } }))
},
}
}

View file

@ -5,10 +5,9 @@ import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import path from "node:path"
import { createEventStream, createFetch, directory, json, type FetchHandler } from "./fixture/tui-client"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import type { TuiInput } from "../src/app"
import type { Config } from "../src/config"
import { createAppFixture } from "./fixture/app"
import type { PluginInfo } from "@opencode-ai/client"
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
@ -1530,54 +1529,3 @@ test("server plugin failures share one notice and use source names before an ID
expect(setup.captureCharFrame()).toContain("/fixture/broken.ts")
expect(setup.captureCharFrame()).toContain("Open plugins")
})
async function createAppFixture(
input: {
width?: number
height?: number
state?: string
config?: Config.Info
args?: TuiInput["args"]
fetch?: FetchHandler
} = {},
) {
const { run } = await import("../src/app")
const setup = await createTestRenderer({
width: input.width ?? 100,
height: input.height ?? 30,
useThread: false,
kittyKeyboard: true,
})
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(input.fetch, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: input.args ?? {},
log: () => {},
}).pipe(
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
Effect.provide(FileSystem.layerNoop({})),
),
)
return {
...setup,
events,
ready: ready.promise,
async [Symbol.asyncDispose]() {
try {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task
} finally {
await server.stop()
}
},
}
}

View file

@ -0,0 +1,108 @@
import { expect, test } from "bun:test"
import { InputRenderable, TextareaRenderable } from "@opentui/core"
import { directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
import { createAppFixture } from "./fixture/app"
test("custom commands commit the captured agent, model and variant before execution", async () => {
await using state = await tmpdir()
const agent = Promise.withResolvers<Response>()
const model = Promise.withResolvers<Response>()
const mutations: { type: string; body: unknown }[] = []
const location = { directory, project: { id: "project", directory, canonical: directory } }
const session = {
id: `ses_${crypto.randomUUID()}`,
projectID: "project",
title: "Command selection fixture",
agent: "build",
model: { providerID: "demo", id: "first" },
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
await using setup = await createAppFixture({
state: state.path,
config: {
animations: false,
keybinds: { "agent.cycle": "f6", "variant.cycle": "f7", "model.list": "f8" },
},
args: { sessionID: session.id },
fetch: async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/agent")
return json({
location,
data: ["build", "plan"].map((id) => ({ id, mode: "primary", hidden: false, permissions: [] })),
})
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
if (url.pathname === "/api/model")
return json({
location,
data: ["first", "second"].map((id) => ({
id,
providerID: "demo",
name: `${id} model`,
variants: [{ id: "low" }, { id: "high" }],
cost: [],
time: { released: 0 },
})),
})
if (url.pathname === "/api/command")
return json({ location, data: [{ name: "review", description: "Review the input" }] })
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
const type = url.pathname.match(/^\/api\/session\/[^/]+\/(agent|model|command)$/)?.[1]
if (!type) return
mutations.push({ type, body: await request.json() })
return type === "agent" ? agent.promise : type === "model" ? model.promise : new Response(null, { status: 204 })
},
})
try {
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Build · first model"))
setup.mockInput.pressKey("F6")
await setup.waitForFrame((frame) => frame.includes("Plan ·"))
setup.mockInput.pressKey("F8")
await setup.waitForFrame(
(frame) => frame.includes("Select model") && setup.renderer.currentFocusedRenderable instanceof InputRenderable,
)
await setup.mockInput.typeText("second")
await setup.renderOnce()
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Select variant") && frame.includes("low"))
await setup.mockInput.typeText("low")
await setup.renderOnce()
setup.mockInput.pressEnter()
await setup.waitForFrame(
(frame) =>
frame.includes("Plan · second model Demo · low") &&
setup.renderer.currentFocusedRenderable instanceof TextareaRenderable,
)
await setup.mockInput.typeText("/review selected input")
setup.mockInput.pressEscape()
setup.mockInput.pressEnter()
await setup.waitFor(() => mutations.length > 0)
expect(mutations).toEqual([{ type: "agent", body: { agent: "plan" } }])
// A later local edit must not change the in-flight command's selection.
setup.mockInput.pressKey("F7")
await setup.waitForFrame((frame) => frame.includes("high"))
agent.resolve(new Response(null, { status: 204 }))
await setup.waitFor(() => mutations.length === 2)
expect(mutations[1]).toEqual({
type: "model",
body: { model: { providerID: "demo", id: "second", variant: "low" } },
})
model.resolve(new Response(null, { status: 204 }))
await setup.waitFor(() => mutations.length === 3)
expect(mutations[2]).toEqual({
type: "command",
body: { command: "review", text: "selected input", files: [], agents: [], delivery: "steer" },
})
} finally {
agent.resolve(new Response(null, { status: 204 }))
model.resolve(new Response(null, { status: 204 }))
}
})

View file

@ -0,0 +1,18 @@
import { expect, test } from "bun:test"
import { DialogVariant } from "../../src/component/dialog-variant"
import { agent, model, renderLocal } from "../fixture/local"
test("variant picker can explicitly reset an agent variant", async () => {
await using setup = await renderLocal({
models: [model("first", ["low", "high"])],
agents: [agent("build", { providerID: "provider", id: "first", variant: "high" })],
})
expect(setup.local.model.variant.current()).toBe("high")
setup.dialog.replace(() => <DialogVariant />)
await setup.waitForFrame((frame) => frame.includes("Select variant") && frame.includes("Default"))
await setup.mockInput.typeText("Default")
await setup.renderOnce()
setup.mockInput.pressEnter()
await setup.waitFor(() => setup.dialog.stack.length === 0)
expect(setup.local.model.variant.current()).toBeUndefined()
})

View file

@ -0,0 +1,189 @@
import { expect, test } from "bun:test"
import { agent, model, renderLocal, session } from "../fixture/local"
import { json } from "../fixture/tui-client"
test("cycles all recent models in a stable order in both directions", async () => {
await using setup = await renderLocal({
models: [model("first"), model("second"), model("third")],
preferences: { recent: ["first", "second", "third"].map((modelID) => ({ providerID: "provider", modelID })) },
})
expect(setup.local.model.current()?.modelID).toBe("first")
for (const id of ["second", "third", "first"]) {
setup.local.model.cycle(1)
expect(setup.local.model.current()?.modelID).toBe(id)
}
for (const id of ["third", "second", "first"]) {
setup.local.model.cycle(-1)
expect(setup.local.model.current()?.modelID).toBe(id)
}
})
test("uses the last configured model and variant ahead of recents", async () => {
await using setup = await renderLocal({
models: [model("first"), model("second", ["low", "high"]), model("third")],
preferences: { recent: [{ providerID: "provider", modelID: "third" }] },
fetch: (url) => {
if (url.pathname === "/api/config")
return json([
{ type: "document", info: { model: "provider/first" } },
{ type: "document", info: { model: { providerID: "provider", model: "second", variant: "high" } } },
{ type: "document", info: {} },
])
},
})
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "second", variant: "high" })
})
test("switching agents restores their model and variant within the session", async () => {
await using setup = await renderLocal({
models: [model("first", ["low", "high"]), model("second", ["low", "high"]), model("third", ["low", "high"])],
agents: [
agent("build", { providerID: "provider", id: "first", variant: "high" }),
agent("plan", { providerID: "provider", id: "second", variant: "low" }),
],
sessions: [session("ses_first", { providerID: "provider", id: "first", variant: "low" })],
})
await setup.data.session.sync("ses_first")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
setup.local.agent.move(1)
expect(setup.local.agent.current()?.id).toBe("plan")
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "second", variant: "low" })
setup.local.model.set({ providerID: "provider", modelID: "third" })
setup.local.model.variant.set("high")
setup.local.agent.move(-1)
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
setup.local.agent.set("plan")
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "third", variant: "high" })
})
test("agent and model drafts are isolated across sessions and survive navigation", async () => {
await using setup = await renderLocal({
models: [model("first", ["low", "high"]), model("second", ["low", "high"])],
agents: [agent("build"), agent("plan", { providerID: "provider", id: "second" })],
sessions: [
session("ses_first", { providerID: "provider", id: "first", variant: "low" }),
session("ses_second", { providerID: "provider", id: "second", variant: "high" }, "plan"),
],
})
await Promise.all([setup.data.session.sync("ses_first"), setup.data.session.sync("ses_second")])
setup.route.navigate({ type: "session", sessionID: "ses_first" })
setup.local.agent.set("plan")
setup.local.model.variant.set("low")
setup.route.navigate({ type: "session", sessionID: "ses_second" })
expect(setup.local.agent.current()?.id).toBe("plan")
expect(setup.local.model.variant.current()).toBe("high")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
expect(setup.local.agent.current()?.id).toBe("plan")
expect(setup.local.model.variant.current()).toBe("low")
setup.local.agent.set("build")
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
})
test("falls back from an unavailable session model without changing durable state", async () => {
const selected = { providerID: "provider", id: "missing", variant: "high" }
await using setup = await renderLocal({
models: [model("first", ["low", "high"]), model("second")],
agents: [agent("build", { providerID: "provider", id: "first", variant: "low" })],
sessions: [session("ses_first", selected)],
})
await setup.data.session.sync("ses_first")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
expect(setup.local.model.selection()).toEqual({ providerID: "provider", modelID: "first", variant: "low" })
expect(setup.local.model.available()).toBe(true)
expect(setup.data.session.get("ses_first")?.model).toEqual(selected)
})
test("a manual agent switch supersedes the CLI agent after its commit", async () => {
await using setup = await renderLocal({
args: { agent: "build" },
agents: [agent("build"), agent("plan")],
sessions: [session("ses_first", { providerID: "provider", id: "first" })],
fetch: selectionMessage,
})
await setup.data.session.sync("ses_first")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
setup.local.agent.set("plan")
await publishSelection(setup, "plan", "first")
expect(setup.local.agent.current()?.id).toBe("plan")
setup.route.navigate({ type: "home" })
setup.route.navigate({ type: "session", sessionID: "ses_first" })
expect(setup.local.agent.current()?.id).toBe("plan")
})
test("a late inactive-agent acknowledgment preserves its choice after the active agent commits", async () => {
await using setup = await renderLocal({
models: [model("first"), model("second"), model("third")],
agents: [agent("build"), agent("plan", { providerID: "provider", id: "second" })],
sessions: [session("ses_first", { providerID: "provider", id: "first" })],
fetch: selectionMessage,
})
await setup.data.session.sync("ses_first")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
setup.local.agent.set("plan")
setup.local.model.set({ providerID: "provider", modelID: "third" })
setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "third" }, "plan")
setup.local.agent.set("build")
await publishSelection(setup, "plan", "third")
setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "first" }, "build")
await publishSelection(setup, "build", "first")
setup.local.agent.set("plan")
expect(setup.local.model.current()?.modelID).toBe("third")
})
test("same-model agent switches clear drafts without a model acknowledgment", async () => {
await using setup = await renderLocal({
models: [model("first"), model("second")],
agents: [agent("build"), agent("plan")],
sessions: [session("ses_first", { providerID: "provider", id: "first" })],
fetch: selectionMessage,
})
await setup.data.session.sync("ses_first")
setup.route.navigate({ type: "session", sessionID: "ses_first" })
setup.local.agent.set("plan")
setup.local.model.trackSessionCommit("ses_first", { providerID: "provider", id: "first" }, "plan")
await publishSelection(setup, "plan", "first", false)
await publishSelection(setup, "plan", "second")
expect(setup.local.model.current()?.modelID).toBe("second")
})
async function publishSelection(
setup: Awaited<ReturnType<typeof renderLocal>>,
agent: string,
modelID: string,
changed = true,
) {
setup.events.emit({
id: `evt_${crypto.randomUUID()}`,
type: "session.agent.selected",
created: 1,
durable: { aggregateID: "ses_first", seq: 1, version: 1 },
data: { sessionID: "ses_first", agent },
})
if (changed)
setup.events.emit({
id: `evt_${crypto.randomUUID()}_${modelID}`,
type: "session.model.selected",
created: 2,
durable: { aggregateID: "ses_first", seq: 2, version: 1 },
data: { sessionID: "ses_first", model: { providerID: "provider", id: modelID } },
})
await setup.waitFor(async () => {
await Bun.sleep(10)
const session = setup.data.session.get("ses_first")
return session?.agent === agent && session.model?.id === modelID
})
}
function selectionMessage(url: URL) {
if (!url.pathname.includes("/message/")) return
const id = url.pathname.split("/").at(-1)!
return json({
data: {
id,
type: "model-switched",
model: { providerID: "provider", id: id.split("_").at(-1) },
time: { created: 2 },
},
})
}

View file

@ -1,12 +1,5 @@
import { expect, test } from "bun:test"
import { parseModel, recentModels } from "../../src/context/local"
test("parses model IDs containing slashes", () => {
expect(parseModel("provider/family/model")).toEqual({
providerID: "provider",
modelID: "family/model",
})
})
import { recentModels } from "../../src/context/local"
test("moves a model to the front, deduplicates, and limits recents", () => {
const recent = Array.from({ length: 12 }, (_, index) => ({

View file

@ -0,0 +1,58 @@
import { createTestRenderer } from "@opentui/core/testing"
import { Effect, FileSystem } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global"
import type { TuiInput } from "../../src/app"
import type { Config } from "../../src/config"
import { createEventStream, createFetch, type FetchHandler } from "./tui-client"
export async function createAppFixture(
input: {
width?: number
height?: number
state?: string
config?: Config.Info
args?: TuiInput["args"]
fetch?: FetchHandler
} = {},
) {
const { run } = await import("../../src/app")
const setup = await createTestRenderer({
width: input.width ?? 100,
height: input.height ?? 30,
useThread: false,
kittyKeyboard: true,
})
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const events = createEventStream()
const calls = createFetch(input.fetch, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => input.config ?? { animations: false }, update: async () => ({}) },
packages: { prepare: async () => ({ directory: "" }) },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: input.args ?? {},
log: () => {},
}).pipe(
Effect.provide(input.state ? Global.layerWith({ state: input.state }) : AppNodeBuilder.build(Global.node)),
Effect.provide(FileSystem.layerNoop({})),
),
)
return {
...setup,
events,
ready: ready.promise,
async [Symbol.asyncDispose]() {
try {
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await task
} finally {
await server.stop()
}
},
}
}

View file

@ -0,0 +1,149 @@
import { testRender } from "@opentui/solid"
import type { AgentInfo, ModelInfo, SessionInfo } from "@opencode-ai/client"
import path from "node:path"
import { ConfigProvider } from "../../src/config"
import { ArgsProvider, type Args } from "../../src/context/args"
import { ClientProvider } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { LocalProvider, useLocal } from "../../src/context/local"
import { Keymap } from "../../src/context/keymap"
import { LocationProvider, useLocation } from "../../src/context/location"
import { PermissionProvider } from "../../src/context/permission"
import { RouteProvider, useRoute } from "../../src/context/route"
import { ThemeProvider } from "../../src/context/theme"
import { ToastProvider } from "../../src/ui/toast"
import { DialogProvider, useDialog } from "../../src/ui/dialog"
import type { ModelPreference } from "../../src/model-preference"
import { tmpdir } from "./fixture"
import { createApi, createEventStream, createFetch, directory, json, type FetchHandler } from "./tui-client"
import { TestTuiContexts } from "./tui-environment"
import { createTuiResolvedConfig } from "./tui-runtime"
export async function renderLocal(
input: {
models?: ModelInfo[]
agents?: AgentInfo[]
sessions?: SessionInfo[]
preferences?: Partial<ModelPreference>
args?: Args
fetch?: FetchHandler
} = {},
) {
const temporary = await tmpdir()
await Bun.write(path.join(temporary.path, "model.json"), JSON.stringify(input.preferences ?? {}))
const events = createEventStream()
const calls = createFetch(async (url, request) => {
const response = await input.fetch?.(url, request)
if (response) return response
const location = { directory: url.searchParams.get("location[directory]") ?? directory }
if (url.pathname === "/api/agent") return json({ location, data: input.agents ?? [agent("build")] })
if (url.pathname === "/api/model") return json({ location, data: input.models ?? [model("first")] })
const session = input.sessions?.find((session) => url.pathname === `/api/session/${session.id}`)
if (session) return json({ data: session })
}, events)
let local!: ReturnType<typeof useLocal>
let route!: ReturnType<typeof useRoute>
let data!: ReturnType<typeof useData>
let location!: ReturnType<typeof useLocation>
let dialog!: ReturnType<typeof useDialog>
function Probe() {
local = useLocal()
route = useRoute()
data = useData()
location = useLocation()
dialog = useDialog()
return <box />
}
const setup = await testRender(
() => (
<TestTuiContexts paths={{ state: temporary.path }}>
<ArgsProvider {...input.args}>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={{ discover: async () => ({}) }}>
<ToastProvider>
<RouteProvider initialRoute={{ type: "home" }}>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider directory={directory}>
<LocationProvider>
<PermissionProvider>
<LocalProvider>
<DialogProvider>
<Probe />
</DialogProvider>
</LocalProvider>
</PermissionProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</ArgsProvider>
</TestTuiContexts>
),
{ width: 100, height: 30, kittyKeyboard: true },
)
await setup.waitFor(() => local !== undefined && local.model.ready)
await data.location.sync()
return {
...setup,
local,
route,
data,
location,
dialog,
events,
state: temporary.path,
async [Symbol.asyncDispose]() {
setup.renderer.destroy()
await temporary[Symbol.asyncDispose]()
},
}
}
export function model(id: string, variants: string[] = []): ModelInfo {
return {
id,
modelID: id,
providerID: "provider",
name: id,
status: "active",
enabled: true,
capabilities: { input: ["text"], output: ["text"], tools: true },
cost: [],
limit: { context: 10000, output: 1000 },
time: { released: 0 },
variants: variants.map((id) => ({ id })),
}
}
export function agent(id: string, selected?: AgentInfo["model"]): AgentInfo {
return {
id,
name: id,
model: selected,
mode: "primary",
hidden: false,
permissions: [],
request: { settings: {}, headers: {}, body: {} },
}
}
export function session(id: string, selected?: SessionInfo["model"], agent = "build"): SessionInfo {
return {
id,
agent,
model: selected,
title: id,
location: { directory },
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
}

View file

@ -134,6 +134,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
data: { resources: [], templates: [] },
})
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
if (url.pathname === "/api/config") return json([])
if (url.pathname === "/api/session/active") return json({ data: {} })
if (request.method === "POST" && /^\/api\/session\/[^/]+\/model$/.test(url.pathname))
return new Response(null, { status: 204 })

View file

@ -15,7 +15,7 @@ test("repairs known model preferences and preserves unrelated fields", () => {
unrelated: { keep: true },
recent: [{ providerID: "openai", modelID: "gpt-5" }],
favorite: [],
variant: { "openai/gpt-5": "high" },
variant: { "openai/gpt-5": "high", default: "default" },
})
})
@ -41,5 +41,8 @@ test("atomically serializes patches and variant updates", async () => {
await repository.saveVariant(openai, "default")
expect(await repository.resolveVariant(openai)).toBeUndefined()
expect((await Bun.file(file).json()).variant).toEqual({ "anthropic/claude/sonnet": "low" })
expect((await Bun.file(file).json()).variant).toEqual({
"openai/org/gpt-5": "default",
"anthropic/claude/sonnet": "low",
})
})