diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 7e6da553f47..1f23071ba8c 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -1,7 +1,7 @@ import { createStore } from "solid-js/store" import { dedupeWith } from "effect/Array" import { createSimpleContext } from "./helper" -import { batch, createMemo, onCleanup } from "solid-js" +import { batch, createMemo, createResource, onCleanup } from "solid-js" import { useEvent } from "./event" import path from "path" import { useTuiPaths } from "./runtime" @@ -32,6 +32,22 @@ export function parseModel(model: string) { } } +/** + * A session stored without a model runs on the server's default model, so the + * status line shows that effective model instead of claiming no provider is + * selected. "No provider selected" remains only when no usable default exists. + */ +export function withDefaultModelFallback(options: { + selection: (ModelPreferenceModel & { variant?: string }) | undefined + defaultModel: ModelPreferenceModel | undefined + isValid: (model: ModelPreferenceModel) => boolean + variantPreference: (model: ModelPreferenceModel) => string | undefined +}) { + if (options.selection) return options.selection + if (!options.defaultModel || !options.isValid(options.defaultModel)) return undefined + return { ...options.defaultModel, variant: normalizeModelVariant(options.variantPreference(options.defaultModel)) } +} + export function recentModels(model: ModelPreferenceModel, recent: ModelPreferenceModel[]) { const seen = new Set() return [model, ...recent] @@ -217,8 +233,30 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ ) }) + const [serverDefaultModel] = createResource( + () => { + const ref = location.ref ?? data.location.default() + // Refetch when the catalog changes, such as a provider connecting. + return JSON.stringify([ref.directory, ref.workspaceID, models()?.length ?? -1]) + }, + async () => { + const ref = location.ref ?? data.location.default() + const response = await client.api.model + .default({ location: { directory: ref.directory, workspace: ref.workspaceID } }) + .catch(() => undefined) + if (!response?.data) return undefined + return { providerID: response.data.providerID, modelID: response.data.id } + }, + ) + const currentSelection = createMemo(() => { - if (route.data.type === "session") return sessionSelection(route.data.sessionID) + if (route.data.type === "session") + return withDefaultModelFallback({ + selection: sessionSelection(route.data.sessionID), + defaultModel: serverDefaultModel(), + isValid: isModelValid, + variantPreference: (model) => preferences.variant[modelPreferenceKey(model)], + }) const model = newSessionModel() if (!model) return return { ...model, variant: normalizeModelVariant(preferences.variant[modelPreferenceKey(model)]) } diff --git a/packages/tui/test/context/local.test.ts b/packages/tui/test/context/local.test.ts index e2f1e45f75a..eb61d818acd 100644 --- a/packages/tui/test/context/local.test.ts +++ b/packages/tui/test/context/local.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { parseModel, recentModels } from "../../src/context/local" +import { parseModel, recentModels, withDefaultModelFallback } from "../../src/context/local" test("parses model IDs containing slashes", () => { expect(parseModel("provider/family/model")).toEqual({ @@ -20,3 +20,45 @@ test("moves a model to the front, deduplicates, and limits recents", () => { ...recent.slice(6, 10), ]) }) + +test("session selection wins over the default model", () => { + const selection = { providerID: "openai", modelID: "gpt", variant: "high" } + expect( + withDefaultModelFallback({ + selection, + defaultModel: { providerID: "opencode", modelID: "fable" }, + isValid: () => true, + variantPreference: () => undefined, + }), + ).toBe(selection) +}) + +test("sessions without a stored model fall back to the server default", () => { + expect( + withDefaultModelFallback({ + selection: undefined, + defaultModel: { providerID: "opencode", modelID: "fable" }, + isValid: () => true, + variantPreference: (model) => (model.modelID === "fable" ? "max" : undefined), + }), + ).toEqual({ providerID: "opencode", modelID: "fable", variant: "max" }) +}) + +test("no provider is reported only without a usable default", () => { + expect( + withDefaultModelFallback({ + selection: undefined, + defaultModel: undefined, + isValid: () => true, + variantPreference: () => undefined, + }), + ).toBeUndefined() + expect( + withDefaultModelFallback({ + selection: undefined, + defaultModel: { providerID: "gone", modelID: "model" }, + isValid: () => false, + variantPreference: () => undefined, + }), + ).toBeUndefined() +}) diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index 5fcb6f4bbd1..fd53c5beba3 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -152,6 +152,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType