diff --git a/src/tui-opentui/product-host.test.ts b/src/tui-opentui/product-host.test.ts index daa4c08cf..9740b24a9 100644 --- a/src/tui-opentui/product-host.test.ts +++ b/src/tui-opentui/product-host.test.ts @@ -13,7 +13,7 @@ import { permissionChoices, type ProductHostConfig, } from "./product-host.js" -import { buildModelsFirstCatalog } from "./model-catalog.js" +import { buildModelsFirstCatalog, modelOptionId } from "./model-catalog.js" function makeFakeSessionPort(): { readonly sends: string[] @@ -383,6 +383,7 @@ describe("provider-first model picker", () => { interrupt: port.interrupt, createRenderer: async () => harness.renderer, models: catalog, + activeModelId: () => modelOptionId("xai/thegreataxios", "grok-4.5"), onModelSelect: () => {}, }) try { @@ -396,6 +397,46 @@ describe("provider-first model picker", () => { } }) + test("stale recents pointing at a different model do not steal the (current) marker", async () => { + // Recents still name the model a *previous* session last switched to; + // this session has run codex/abk-labs / gpt-5.5 all along without ever + // touching the picker. The live model, not the recents list, decides + // which row reads "(current)". + const harness = await createHarness({ width: 80, height: 24 }) + const port = makeFakeSessionPort() + const catalog = buildModelsFirstCatalog({ + providers, + recent: [{ provider: "xai/thegreataxios", model: "grok-4.5" }], + }) + const host = await mountProductHost({ + title: "test-session", + eventEmitter: new EventEmitter(), + send: port.send, + interrupt: port.interrupt, + createRenderer: async () => harness.renderer, + models: catalog, + activeModelId: () => modelOptionId("codex/abk-labs", "gpt-5.5"), + onModelSelect: () => {}, + }) + try { + host.openModels?.() + await harness.renderOnce() + const frame = harness.captureCharFrame() + expect(frame).not.toContain("xai/thegreataxios / grok-4.5 (current)") + const items = host.shell.overlayItems + const codexIndex = items.findIndex((label) => label.includes("codex/abk-labs")) + expect(codexIndex).toBeGreaterThanOrEqual(0) + moveOverlaySelection(host.shell, codexIndex) + acceptOverlaySelection(host.shell) + await harness.renderOnce() + const modelFrame = harness.captureCharFrame() + expect(modelFrame).toContain("gpt-5.5 (current)") + } finally { + host.dispose() + harness.destroy() + } + }) + test("fits and scrolls within a short terminal instead of overflowing it", async () => { const port = makeFakeSessionPort() const harness = await createHarness({ width: 80, height: 10 }) diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index 1af9cc838..f1e8de68d 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -160,6 +160,13 @@ export type ProductHostConfig = { readonly deliver?: ProductHostDeliver /** Model/provider rows for the picker (id applied on select). */ readonly models?: readonly ProductHostModelOption[] + /** + * Row id (`provider:model`) of the model the session is actually running, + * read live on every picker open so it tracks selections made outside the + * picker (e.g. `defaultProvider` at startup). Marks that row "(current)" + * instead of guessing from the recents list. + */ + readonly activeModelId?: () => string | undefined readonly onModelSelect?: (id: string) => void /** Description-zone source for the model picker, keyed by row id. */ readonly describeModel?: (itemId: string) => ItemDescription | null @@ -543,7 +550,7 @@ export async function mountProductHost( const { groups } = groupModelsForPicker(currentModels) const group = groups.get(groupProvider) if (group !== undefined) { - openLevel(annotateCurrent(group.rows, activeModelId()), openModels) + openLevel(annotateCurrent(group.rows, config.activeModelId?.()), openModels) } return } @@ -567,15 +574,9 @@ export async function mountProductHost( }) } - // Recent's first row (if any) is the model just switched to — the closest - // thing to a live "current model" id without threading one through from - // the runner. Used only to mark that row "(current)" wherever it appears. - const activeModelId = (): string | undefined => - currentModels.find((r) => r.section === "recent")?.id - openModels = (): void => { const { top, groups } = groupModelsForPicker(currentModels) - const activeId = activeModelId() + const activeId = config.activeModelId?.() // The active model's own row already reads "(current)" via annotateCurrent // below; when it lives inside a provider group, mark the group row too // so the pick is visible without descending into it. diff --git a/src/tui-opentui/runner-host.test.ts b/src/tui-opentui/runner-host.test.ts index e9ccd5dcf..3395e8d76 100644 --- a/src/tui-opentui/runner-host.test.ts +++ b/src/tui-opentui/runner-host.test.ts @@ -208,6 +208,7 @@ describe("mountRunnerHost model picker", () => { send: () => {}, interrupt: () => {}, providers: { xai: { models: ["grok-4", "grok-3"] } }, + activeModel: () => ({ provider: "xai", model: "grok-4" }), onModelSelect: () => {}, commands: [], onCommand: () => {}, diff --git a/src/tui-opentui/runner-host.ts b/src/tui-opentui/runner-host.ts index 888e45d15..10636c914 100644 --- a/src/tui-opentui/runner-host.ts +++ b/src/tui-opentui/runner-host.ts @@ -21,6 +21,7 @@ import { chromeFromSession, type ChromeSessionInput } from "./chrome-state.js" import { buildModelsFirstCatalog, describeModelCatalogOption, + modelOptionId, type ModelCatalogOption, type ModelCatalogProvidersInput, type ModelCatalogRef, @@ -67,6 +68,12 @@ export type RunnerHostDeps = { readonly favoriteModels?: readonly ModelCatalogRef[] /** Known providers with no stored credentials yet — rendered as "connect →" rows. */ readonly unconnectedProviders?: readonly ModelCatalogUnconnectedProvider[] + /** + * Provider+model the session is actually running, read live on every + * picker open. Marks that row "(current)" — independent of recents, which + * only move on an explicit `/model` pick and can go stale. + */ + readonly activeModel?: () => ModelCatalogRef | undefined readonly onModelSelect: (id: string) => void /** Selecting a "connect →" row; runner owns the actual connect flow. */ readonly onConnectProvider?: (providerName: string) => void @@ -233,6 +240,10 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise ? { onFavoriteToggle: deps.onFavoriteToggle } : {}), models: catalog, + activeModelId: () => { + const active = deps.activeModel?.() + return active ? modelOptionId(active.provider, active.model) : undefined + }, onModelSelect, describeModel, commands: buildCommandCatalog(deps.commands), diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 38b0bb26c..3fcbb53a0 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -1932,6 +1932,7 @@ export async function runTUI(initialConfig: Config): Promise { model: config.model, ...(config.reasoningEffort !== undefined ? { effort: config.reasoningEffort } : {}), }), + activeModel: () => ({ provider: config.providerName, model: config.model }), readCostSummary: () => commandContext.getCostSummary?.(), showPromptCost: () => liveShowPromptCost, onModelSelect: (id) => {