From ba65426f1950e3be577abd1239f8ba7c1758468e Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sat, 4 Jul 2026 21:40:15 +0200 Subject: [PATCH] run: show selected model in footer Resolve the catalog default after the interface paints so the footer shows the model that will handle the prompt. Preserve resumed session model and variant selection to keep the displayed state consistent. --- .../src/cli/cmd/run/catalog.shared.ts | 20 ++- packages/opencode/src/cli/cmd/run/footer.ts | 4 + .../opencode/src/cli/cmd/run/runtime.boot.ts | 4 +- .../src/cli/cmd/run/runtime.lifecycle.ts | 10 +- packages/opencode/src/cli/cmd/run/runtime.ts | 65 ++++++++-- .../src/cli/cmd/run/session.shared.ts | 13 ++ packages/opencode/src/cli/cmd/run/types.ts | 1 + .../test/cli/run/catalog.shared.test.ts | 27 +++- .../opencode/test/cli/run/runtime.test.ts | 118 ++++++++++++++++-- .../test/cli/run/session.shared.test.ts | 2 + 10 files changed, 232 insertions(+), 32 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/catalog.shared.ts b/packages/opencode/src/cli/cmd/run/catalog.shared.ts index e4c8277fd9..e873071f69 100644 --- a/packages/opencode/src/cli/cmd/run/catalog.shared.ts +++ b/packages/opencode/src/cli/cmd/run/catalog.shared.ts @@ -4,7 +4,9 @@ import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types" type CurrentAgent = NonNullable>["data"]>["data"][number] type CurrentCommand = NonNullable>["data"]>["data"][number] type CurrentSkill = NonNullable>["data"]>["data"][number] -type CurrentProvider = NonNullable>["data"]>["data"][number] +type CurrentProvider = NonNullable< + Awaited>["data"] +>["data"][number] type CurrentModel = NonNullable>["data"]>["data"][number] function location(directory: string) { @@ -105,6 +107,22 @@ export async function waitForCatalogReady(input: { } } +export async function waitForDefaultModel(input: { + sdk: OpencodeClient + directory: string + timeoutMs?: number +}): Promise<{ providerID: string; modelID: string } | undefined> { + const deadline = Date.now() + (input.timeoutMs ?? 5_000) + while (Date.now() < deadline) { + const model = await input.sdk.v2.model + .default(location(input.directory), { throwOnError: true }) + .then((result) => result.data?.data) + .catch(() => undefined) + if (model) return { providerID: model.providerID, modelID: model.id } + await new Promise((resolve) => setTimeout(resolve, 25)) + } +} + export async function loadRunAgents(sdk: OpencodeClient, directory: string): Promise { const result = await sdk.v2.agent.list(location(directory), { throwOnError: true }) return (result.data?.data ?? []).map(runAgent) diff --git a/packages/opencode/src/cli/cmd/run/footer.ts b/packages/opencode/src/cli/cmd/run/footer.ts index 6a4c03e797..2432c29689 100644 --- a/packages/opencode/src/cli/cmd/run/footer.ts +++ b/packages/opencode/src/cli/cmd/run/footer.ts @@ -390,6 +390,10 @@ export class RunFooter implements FooterApi { } public event(next: FooterEvent): void { + if (next.type === "model") { + this.setCurrentModel(next.selection) + } + if (next.type === "turn.duration") { const current = this.currentModel() this.flush() diff --git a/packages/opencode/src/cli/cmd/run/runtime.boot.ts b/packages/opencode/src/cli/cmd/run/runtime.boot.ts index 2933ce5fdd..eae1db77e8 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.boot.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.boot.ts @@ -26,6 +26,7 @@ export type ModelInfo = { export type SessionInfo = { first: boolean history: RunPrompt[] + model?: NonNullable variant: string | undefined } @@ -141,7 +142,8 @@ const layer = Layer.effect( return { first: session.first, history: sessionHistory(session), - variant: pickVariant(model, session), + model: session.model, + variant: pickVariant(model ?? session.model, session), } }) diff --git a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts index a25b293883..d9dab9f309 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.lifecycle.ts @@ -124,17 +124,9 @@ function splashInfo(title: string | undefined, history: RunPrompt[]) { function footerLabels(input: Pick): FooterLabels { const agentLabel = Locale.titlecase(input.agent ?? "build") - - if (!input.model) { - return { - agentLabel, - modelLabel: "Model default", - } - } - return { agentLabel, - modelLabel: formatModelLabel(input.model, input.variant), + modelLabel: input.model ? formatModelLabel(input.model, input.variant) : "", } } diff --git a/packages/opencode/src/cli/cmd/run/runtime.ts b/packages/opencode/src/cli/cmd/run/runtime.ts index 5c6ede1582..092bda3096 100644 --- a/packages/opencode/src/cli/cmd/run/runtime.ts +++ b/packages/opencode/src/cli/cmd/run/runtime.ts @@ -15,7 +15,7 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { Flag } from "@opencode-ai/core/flag/flag" import { MessageID } from "@/session/schema" -import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared" +import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared" import { createRunDemo } from "./demo" import { resolveModelInfo, resolveModelInfoStrict, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot" import { createRuntimeLifecycle } from "./runtime.lifecycle" @@ -181,13 +181,13 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep const log = trace() const tuiConfigTask = resolveRunTuiConfig() const ctx = await input.boot() - const modelTask = resolveModelInfo(ctx.sdk, ctx.directory, ctx.model) const sessionTask = ctx.resume === true ? resolveSessionInfo(ctx.sdk, ctx.sessionID, ctx.model) : Promise.resolve({ first: true, history: [], + model: undefined, variant: undefined, }) const savedTask = resolveSavedVariant(ctx.model) @@ -195,7 +195,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep const state: RuntimeState = { shown: !session.first, aborting: false, - model: ctx.model, + model: ctx.model ?? session.model, providers: [], variants: [], limits: {}, @@ -206,6 +206,38 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep sessionTitle: ctx.sessionTitle, agent: ctx.agent, } + const modelTask = (async () => { + if (state.model) { + return { + model: state.model, + savedVariant, + boot: true, + info: await resolveModelInfo(ctx.sdk, ctx.directory, state.model), + } + } + + const model = await waitForDefaultModel({ sdk: ctx.sdk, directory: ctx.directory }) + const [fallbackSavedVariant, info] = await Promise.all([ + resolveSavedVariant(model), + resolveModelInfo(ctx.sdk, ctx.directory, model), + ]) + if (!model || state.model) { + return { + model: state.model, + savedVariant: undefined, + boot: false, + info, + } + } + + state.model = model + return { + model, + savedVariant: fallbackSavedVariant, + boot: true, + info, + } + })() const ensureSession = () => { if (!input.resolveSession || state.sessionID) { return Promise.resolve() @@ -345,9 +377,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep } state.aborting = true - void (state.stream - ? state.stream.then((item) => item.handle.interruptActiveTurn()) - : ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID })) + void ( + state.stream + ? state.stream.then((item) => item.handle.interruptActiveTurn()) + : ctx.sdk.v2.session.interrupt({ sessionID: state.sessionID }) + ) .catch(() => {}) .finally(() => { state.aborting = false @@ -417,12 +451,13 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep info: Awaited>, current: string | undefined, boot = false, + saved = savedVariant, ) => { state.providers = info.providers state.variants = variantsFor(state.providers, state.model) state.limits = info.limits state.activeVariant = boot - ? resolveVariant(ctx.variant, current, savedVariant, state.variants) + ? resolveVariant(ctx.variant, current, saved, state.variants) : current && !state.variants.includes(current) ? undefined : current @@ -430,7 +465,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep footer.event({ type: "models", providers: info.providers }) footer.event({ type: "variants", variants: state.variants, current: state.activeVariant }) if (state.model) - footer.event({ type: "model", model: formatModelLabel(state.model, state.activeVariant, state.providers) }) + footer.event({ + type: "model", + model: formatModelLabel(state.model, state.activeVariant, state.providers), + selection: state.model, + }) } let catalogRefresh: Promise | undefined @@ -485,7 +524,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep void Promise.resolve(input.afterPaint(ctx)).catch(() => {}) } - void modelTask.then((info) => applyModelInfo(info, session.variant, true)) + void modelTask.then((result) => { + const current = state.model + const boot = + result.boot && + !!current && + current.providerID === result.model?.providerID && + current.modelID === result.model.modelID + applyModelInfo(result.info, boot ? session.variant : state.activeVariant, boot, result.savedVariant) + }) const streamTask = deps.streamTransport ?? import("./stream-v2.transport") const ensureStream = () => { diff --git a/packages/opencode/src/cli/cmd/run/session.shared.ts b/packages/opencode/src/cli/cmd/run/session.shared.ts index 49dece5a89..62d27ab37d 100644 --- a/packages/opencode/src/cli/cmd/run/session.shared.ts +++ b/packages/opencode/src/cli/cmd/run/session.shared.ts @@ -20,6 +20,8 @@ type Turn = { export type RunSession = { first: boolean turns: Turn[] + model?: NonNullable + variant?: string } function fileName(url: string, filename?: string) { @@ -197,6 +199,13 @@ export async function resolveCurrentSession( }, ] }), + ...(session.data.data.model && { + model: { + providerID: session.data.data.model.providerID, + modelID: session.data.data.model.id, + }, + variant: session.data.data.model.variant, + }), } } @@ -223,6 +232,10 @@ export function sessionVariant(session: RunSession, model: RunInput["model"]): s return undefined } + if (session.model?.providerID === model.providerID && session.model.modelID === model.modelID) { + return session.variant + } + for (let idx = session.turns.length - 1; idx >= 0; idx -= 1) { const turn = session.turns[idx] if (turn.provider !== model.providerID || turn.model !== model.modelID) { diff --git a/packages/opencode/src/cli/cmd/run/types.ts b/packages/opencode/src/cli/cmd/run/types.ts index f311febb26..2c489e2626 100644 --- a/packages/opencode/src/cli/cmd/run/types.ts +++ b/packages/opencode/src/cli/cmd/run/types.ts @@ -312,6 +312,7 @@ export type FooterEvent = | { type: "model" model: string + selection: NonNullable } | { type: "turn.send" diff --git a/packages/opencode/test/cli/run/catalog.shared.test.ts b/packages/opencode/test/cli/run/catalog.shared.test.ts index bdf57ef7d1..847eceebec 100644 --- a/packages/opencode/test/cli/run/catalog.shared.test.ts +++ b/packages/opencode/test/cli/run/catalog.shared.test.ts @@ -1,12 +1,37 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test" import { OpencodeClient } from "@opencode-ai/sdk/v2" -import { loadRunReferences, runProviders } from "@/cli/cmd/run/catalog.shared" +import { loadRunReferences, runProviders, waitForDefaultModel } from "@/cli/cmd/run/catalog.shared" afterEach(() => { mock.restore() }) describe("run catalog shared", () => { + test("resolves the catalog-selected model for the footer", async () => { + const client = new OpencodeClient() + const selected = spyOn(client.v2.model, "default").mockImplementation( + () => + Promise.resolve({ + data: { + location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } }, + data: { + id: "gpt-5", + providerID: "openai", + }, + }, + error: undefined, + request: new Request("https://opencode.test"), + response: new Response(), + }) as never, + ) + + await expect(waitForDefaultModel({ sdk: client, directory: "/tmp" })).resolves.toEqual({ + providerID: "openai", + modelID: "gpt-5", + }) + expect(selected).toHaveBeenCalledWith({ location: { directory: "/tmp" } }, { throwOnError: true }) + }) + test("loads visible project references from the current reference catalog", async () => { const client = new OpencodeClient() const list = spyOn(client.v2.reference, "list").mockImplementation( diff --git a/packages/opencode/test/cli/run/runtime.test.ts b/packages/opencode/test/cli/run/runtime.test.ts index 702d82d769..0a933c0a5f 100644 --- a/packages/opencode/test/cli/run/runtime.test.ts +++ b/packages/opencode/test/cli/run/runtime.test.ts @@ -113,6 +113,7 @@ describe("run interactive runtime", () => { test("waits for provider metadata before eager replay transport bootstrap", async () => { const providersStarted = defer() const providers = defer() + const lifecycleModels: unknown[] = [] const sdk = new OpencodeClient() const legacyProviders = spyOn(sdk.config, "providers").mockRejectedValue(new Error("legacy providers should stay unused")) @@ -249,23 +250,23 @@ describe("run interactive runtime", () => { replay: true, replayLimit: 100, agent: "build", - model: { - providerID: "openai", - modelID: "gpt-5", - }, + model: undefined, variant: undefined, files: [], thinking: true, backgroundSubagents: false, }, { - createRuntimeLifecycle: async () => ({ - footer: footer(), - onResize: () => () => {}, - refreshTheme: () => {}, - resetForReplay: () => Promise.resolve(), - close: () => Promise.resolve(), - }), + createRuntimeLifecycle: async (input) => { + lifecycleModels.push(input.model) + return { + footer: footer(), + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, streamTransport: Promise.resolve({ createSessionTransport: async (input: { providers?: () => RunProvider[]; footer: FooterApi }) => { transportProviders.push(input.providers?.() ?? []) @@ -293,12 +294,107 @@ describe("run interactive runtime", () => { await task + expect(lifecycleModels).toEqual([{ providerID: "openai", modelID: "gpt-5" }]) expect(transportProviders).toEqual([[provider]]) expect(legacyProviders).not.toHaveBeenCalled() expect(legacyAgents).not.toHaveBeenCalled() expect(legacyCommands).not.toHaveBeenCalled() }) + test("paints before resolving the catalog-selected model", async () => { + const sdk = new OpencodeClient() + const defaultStarted = defer() + const releaseDefault = defer() + const lifecycleStarted = defer() + const modelShown = defer() + const events: FooterEvent[] = [] + const api = footer(events) + const event = api.event + api.event = (value) => { + event(value) + if (value.type !== "model") return + modelShown.resolve() + api.close() + } + + spyOn(sdk.v2.model, "default").mockImplementation(async () => { + defaultStarted.resolve() + await releaseDefault.promise + return ok({ + location: { directory: "/tmp" }, + data: { id: "gpt-5", providerID: "openai" }, + }) as never + }) + spyOn(sdk.v2.provider, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + spyOn(sdk.v2.model, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + spyOn(sdk.v2.agent, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + spyOn(sdk.v2.reference, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + spyOn(sdk.v2.command, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + spyOn(sdk.v2.skill, "list").mockImplementation(() => + ok({ location: { directory: "/tmp" }, data: [] }) as never, + ) + + const task = runInteractiveMode( + { + sdk, + directory: "/tmp", + sessionID: "ses-fresh", + resume: false, + agent: "build", + model: undefined, + variant: undefined, + files: [], + thinking: false, + backgroundSubagents: false, + }, + { + createRuntimeLifecycle: async (input) => { + expect(input.model).toBeUndefined() + lifecycleStarted.resolve() + return { + footer: api, + onResize: () => () => {}, + refreshTheme: () => {}, + resetForReplay: () => Promise.resolve(), + close: () => Promise.resolve(), + } + }, + streamTransport: Promise.resolve({ + createSessionTransport: async () => ({ + runPromptTurn: async () => {}, + interruptActiveTurn: async () => {}, + selectSubagent: () => {}, + replayOnResize: async () => false, + close: async () => {}, + }), + formatUnknownError: (error: unknown) => (error instanceof Error ? error.message : String(error)), + }), + }, + ) + + await defaultStarted.promise + await lifecycleStarted.promise + releaseDefault.resolve() + await modelShown.promise + await task + + expect(events.find((event) => event.type === "model")).toEqual({ + type: "model", + model: "gpt-5 ยท openai", + selection: { providerID: "openai", modelID: "gpt-5" }, + }) + }) + test("retains last-known-good state across failed coalesced refreshes and retries later", async () => { const sdk = new OpencodeClient() const refreshGate = defer() diff --git a/packages/opencode/test/cli/run/session.shared.test.ts b/packages/opencode/test/cli/run/session.shared.test.ts index a470f51014..102a53ba50 100644 --- a/packages/opencode/test/cli/run/session.shared.test.ts +++ b/packages/opencode/test/cli/run/session.shared.test.ts @@ -303,6 +303,8 @@ describe("run session shared", () => { const out = await resolveCurrentSession(client, "ses_1") + expect(out.model).toEqual({ providerID: "openai", modelID: "gpt-5" }) + expect(out.variant).toBe("high") expect(out.turns[0]?.prompt).toEqual({ text: "Review @note.ts", parts: [