Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion src/tui-opentui/product-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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 {
Expand All @@ -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 })
Expand Down
17 changes: 9 additions & 8 deletions src/tui-opentui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/tui-opentui/runner-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => {},
Expand Down
11 changes: 11 additions & 0 deletions src/tui-opentui/runner-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { chromeFromSession, type ChromeSessionInput } from "./chrome-state.js"
import {
buildModelsFirstCatalog,
describeModelCatalogOption,
modelOptionId,
type ModelCatalogOption,
type ModelCatalogProvidersInput,
type ModelCatalogRef,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -233,6 +240,10 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
? { onFavoriteToggle: deps.onFavoriteToggle }
: {}),
models: catalog,
activeModelId: () => {
const active = deps.activeModel?.()
return active ? modelOptionId(active.provider, active.model) : undefined
},
onModelSelect,
describeModel,
commands: buildCommandCatalog(deps.commands),
Expand Down
1 change: 1 addition & 0 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1932,6 +1932,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
model: config.model,
...(config.reasoningEffort !== undefined ? { effort: config.reasoningEffort } : {}),
}),
activeModel: () => ({ provider: config.providerName, model: config.model }),
readCostSummary: () => commandContext.getCostSummary?.(),
showPromptCost: () => liveShowPromptCost,
onModelSelect: (id) => {
Expand Down
Loading