From 7a6e03a3e20f0e4f4c02b0c876f317c2768d0b93 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 01:41:55 -0700 Subject: [PATCH 1/4] Add a failing test for the model picker's stale-recents bug The picker marks "(current)" on recents[0], which only moves on an explicit /model pick. A session that never switches models, with recents naming a different model, gets no marker on its real model and a wrong one on a stale recent. --- src/tui-opentui/product-host.test.ts | 43 +++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) 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 }) From 5ecfb4d3b23a2fbe48e0e5cdda0175eba99f795d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 01:42:25 -0700 Subject: [PATCH 2/4] Thread the live model into the picker instead of inferring it recentModels[0] only updates on an explicit /model pick, so a session running on defaultProvider from settings, having never switched via the picker, got the wrong row marked "(current)" (or none at all) whenever recents pointed elsewhere. Runner.ts now passes the session's live provider+model down through RunnerHostDeps.activeModel and ProductHostConfig.activeModelId, read fresh on every picker open. The recents-based guess is gone. --- src/tui-opentui/product-host.ts | 13 ++++++++----- src/tui-opentui/runner-host.ts | 11 +++++++++++ src/tui/runner.ts | 1 + 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index 1af9cc838..dfef11f4d 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 @@ -567,11 +574,7 @@ 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 + const activeModelId = (): string | undefined => config.activeModelId?.() openModels = (): void => { const { top, groups } = groupModelsForPicker(currentModels) 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) => { From b2d4762901de76cab27147e1ba840303982043ff Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 01:45:18 -0700 Subject: [PATCH 3/4] Update the runner-host marker test for the threaded live model The picker no longer infers "(current)" from recents[0]; the test now supplies activeModel explicitly, matching how runner.ts wires it. --- src/tui-opentui/runner-host.test.ts | 1 + 1 file changed, 1 insertion(+) 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: () => {}, From 1ce2839172ad3df7c60656ccfdc15662ba22f3cf Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 07:35:01 -0700 Subject: [PATCH 4/4] Inline the redundant activeModelId wrapper in product-host --- src/tui-opentui/product-host.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index dfef11f4d..f1e8de68d 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -550,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 } @@ -574,11 +574,9 @@ export async function mountProductHost( }) } - const activeModelId = (): string | undefined => config.activeModelId?.() - 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.