Skip to content

Commit 4bb4d34

Browse files
committed
Pick a provider first, then a model
The picker was one flat list of every provider-account-model combination — 30+ rows, the same models repeated once per account, providers interleaved. Group it: the top level now shows one row per provider (each account is already its own catalog entry, e.g. codex/abk-labs vs codex/dirtroad, so no extra grouping key is needed), selecting one descends into that provider's models, and Escape at the model level steps back to the provider level instead of closing the picker. Recent and favorite picks stay flat at the top so the default model is reachable without descending, same as before. The active model's row (and its provider group, when the row itself is not already surfaced via Recent) reads "(current)" so the pick is identifiable at a glance. Both levels reuse the same scrolling list overlay the picker already used, so a short terminal scrolls within the picker rather than overflowing it. Escape only stepped back for permissions/operator overlays before — extended to model_picker so the provider level can be restored via the same dismiss path, harmless no-op for a caller that sets no onCancel. The description-zone fallback for a provider row shows its model count so the zone is never blank before descending.
1 parent f53dc6a commit 4bb4d34

5 files changed

Lines changed: 305 additions & 11 deletions

File tree

src/tui-opentui/overlays.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,8 @@ export type OpenModelPickerOpts = {
170170
readonly describe?: (itemId: string) => ItemDescription | null
171171
/** Bare-key claim on the focused row (e.g. `f` to toggle favorite). */
172172
readonly onAction?: (itemId: string, key: KeyEvent) => boolean
173+
/** Per-open Esc/dismiss — the provider-first picker steps back to the provider level instead of closing outright. */
174+
readonly onCancel?: () => void
173175
}
174176

175177
export function openModelPickerOverlay(
@@ -186,5 +188,6 @@ export function openModelPickerOverlay(
186188
...(opts?.onAccept !== undefined ? { onAccept: opts.onAccept } : {}),
187189
...(opts?.describe !== undefined ? { describe: opts.describe } : {}),
188190
...(opts?.onAction !== undefined ? { onAction: opts.onAction } : {}),
191+
...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}),
189192
})
190193
}

src/tui-opentui/product-host.test.ts

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import { EventEmitter } from "node:events"
66
import { describe, expect, test } from "bun:test"
77
import type { PermissionRequest } from "../permission/types.js"
88
import { createHarness } from "./harness.js"
9-
import { acceptOverlaySelection } from "./shell.js"
9+
import { acceptOverlaySelection, closeInsetOverlay, moveOverlaySelection } from "./shell.js"
1010
import {
1111
mountProductHost,
1212
operatorResultFromSelection,
1313
permissionChoices,
1414
type ProductHostConfig,
1515
} from "./product-host.js"
16+
import { buildModelsFirstCatalog } from "./model-catalog.js"
1617

1718
function makeFakeSessionPort(): {
1819
readonly sends: string[]
@@ -271,6 +272,162 @@ describe("mountProductHost", () => {
271272
})
272273
})
273274

275+
describe("provider-first model picker", () => {
276+
// Mirrors the bug-report shape: several providers, one (codex) with three
277+
// accounts, plus a favorite so the top level has a reachable-without-descending pick.
278+
const providers = {
279+
"codex/abk-labs": { models: ["gpt-5.5", "gpt-5.6-sol"] },
280+
"codex/dirtroad": { models: ["gpt-5.5", "gpt-5.6-sol"] },
281+
"codex/fleur": { models: ["gpt-5.5", "gpt-5.6-sol"] },
282+
"xai/thegreataxios": { models: ["grok-4.5"] },
283+
"Z.AI": { models: ["glm-5", "glm-5-turbo", "glm-5.2"] },
284+
}
285+
286+
async function mountPicker(overrides: Partial<ProductHostConfig> = {}) {
287+
const harness = await createHarness({ width: 80, height: 24 })
288+
const port = makeFakeSessionPort()
289+
const catalog = buildModelsFirstCatalog({
290+
providers,
291+
favorites: [{ provider: "codex/abk-labs", model: "gpt-5.5" }],
292+
})
293+
const selected: string[] = []
294+
const host = await mountProductHost({
295+
title: "test-session",
296+
eventEmitter: new EventEmitter(),
297+
send: port.send,
298+
interrupt: port.interrupt,
299+
createRenderer: async () => harness.renderer,
300+
models: catalog,
301+
onModelSelect: (id) => selected.push(id),
302+
...overrides,
303+
})
304+
return { harness, host, selected }
305+
}
306+
307+
test("top level lists providers (one row per account), not one row per model", async () => {
308+
const { harness, host } = await mountPicker()
309+
try {
310+
host.openModels?.()
311+
await harness.renderOnce()
312+
const frame = harness.captureCharFrame()
313+
// Each codex account is its own row; the account name appears once,
314+
// not once per model it exposes.
315+
expect(frame).toContain("codex/abk-labs")
316+
expect(frame).toContain("codex/dirtroad")
317+
expect(frame).toContain("codex/fleur")
318+
expect(frame).toContain("xai/thegreataxios")
319+
// The favorite is a leaf row, reachable without descending — it, not
320+
// its provider group, carries the model name at the top level.
321+
expect(frame).toContain("gpt-5.5")
322+
} finally {
323+
host.dispose()
324+
harness.destroy()
325+
}
326+
})
327+
328+
test("selecting a provider descends into its models; Escape returns to the provider level", async () => {
329+
const { harness, host } = await mountPicker()
330+
try {
331+
host.openModels?.()
332+
await harness.renderOnce()
333+
334+
const items = host.shell.overlayItems
335+
const xaiIndex = items.findIndex((label) => label.includes("xai/thegreataxios"))
336+
expect(xaiIndex).toBeGreaterThanOrEqual(0)
337+
moveOverlaySelection(host.shell, xaiIndex)
338+
acceptOverlaySelection(host.shell)
339+
await harness.renderOnce()
340+
341+
const modelFrame = harness.captureCharFrame()
342+
expect(modelFrame).toContain("grok-4.5")
343+
expect(modelFrame).not.toContain("codex/abk-labs")
344+
345+
closeInsetOverlay(host.shell)
346+
await harness.renderOnce()
347+
const backFrame = harness.captureCharFrame()
348+
expect(backFrame).toContain("codex/abk-labs")
349+
expect(host.shell.overlayList).not.toBeNull()
350+
} finally {
351+
host.dispose()
352+
harness.destroy()
353+
}
354+
})
355+
356+
test("selecting a model at the model level applies the pick", async () => {
357+
const { harness, host, selected } = await mountPicker()
358+
try {
359+
host.openModels?.()
360+
await harness.renderOnce()
361+
const items = host.shell.overlayItems
362+
const xaiIndex = items.findIndex((label) => label.includes("xai/thegreataxios"))
363+
moveOverlaySelection(host.shell, xaiIndex)
364+
acceptOverlaySelection(host.shell)
365+
await harness.renderOnce()
366+
367+
acceptOverlaySelection(host.shell)
368+
expect(selected).toEqual(["xai/thegreataxios:grok-4.5"])
369+
} finally {
370+
host.dispose()
371+
harness.destroy()
372+
}
373+
})
374+
375+
test("the current model's row reads \"(current)\" at a glance", async () => {
376+
const harness = await createHarness({ width: 80, height: 24 })
377+
const port = makeFakeSessionPort()
378+
const catalog = buildModelsFirstCatalog({ providers, recent: [{ provider: "xai/thegreataxios", model: "grok-4.5" }] })
379+
const host = await mountProductHost({
380+
title: "test-session",
381+
eventEmitter: new EventEmitter(),
382+
send: port.send,
383+
interrupt: port.interrupt,
384+
createRenderer: async () => harness.renderer,
385+
models: catalog,
386+
onModelSelect: () => {},
387+
})
388+
try {
389+
host.openModels?.()
390+
await harness.renderOnce()
391+
const frame = harness.captureCharFrame()
392+
expect(frame).toContain("xai/thegreataxios / grok-4.5 (current)")
393+
} finally {
394+
host.dispose()
395+
harness.destroy()
396+
}
397+
})
398+
399+
test("fits and scrolls within a short terminal instead of overflowing it", async () => {
400+
const port = makeFakeSessionPort()
401+
const harness = await createHarness({ width: 80, height: 10 })
402+
try {
403+
const catalog = buildModelsFirstCatalog({ providers })
404+
const host = await mountProductHost({
405+
title: "test-session",
406+
eventEmitter: new EventEmitter(),
407+
send: port.send,
408+
interrupt: port.interrupt,
409+
createRenderer: async () => harness.renderer,
410+
models: catalog,
411+
onModelSelect: () => {},
412+
})
413+
try {
414+
host.openModels?.()
415+
await harness.renderOnce()
416+
const frame = harness.captureCharFrame()
417+
// Five provider rows do not all fit a 10-row terminal alongside the
418+
// overlay chrome; the picker renders without throwing and the frame
419+
// stays within the terminal's own line count.
420+
expect(frame.replace(/\n$/, "").split("\n").length).toBeLessThanOrEqual(10)
421+
expect(host.shell.overlayList).not.toBeNull()
422+
} finally {
423+
host.dispose()
424+
}
425+
} finally {
426+
harness.destroy()
427+
}
428+
})
429+
})
430+
274431
describe("mount failure", () => {
275432
test("destroys the renderer when gate wiring throws", async () => {
276433
const harness = await createHarness({ width: 80, height: 24 })

src/tui-opentui/product-host.ts

Lines changed: 132 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,72 @@ import type { StreamRow } from "./stream.js"
5858

5959
import type { PendingImageAttachment } from "../tui/image-attachments.js"
6060

61+
const PROVIDER_GROUP_PREFIX = "providerGroup:"
62+
63+
function providerGroupRowId(provider: string): string {
64+
return `${PROVIDER_GROUP_PREFIX}${provider}`
65+
}
66+
67+
function providerFromGroupRowId(id: string): string | null {
68+
return id.startsWith(PROVIDER_GROUP_PREFIX) ? id.slice(PROVIDER_GROUP_PREFIX.length) : null
69+
}
70+
71+
/** Provider (account) segment of a `provider:model` row id. */
72+
function providerOfRowId(id: string): string {
73+
const i = id.indexOf(":")
74+
return i === -1 ? id : id.slice(0, i)
75+
}
76+
77+
/** Provider label segment of a `Provider Label / model` row label. */
78+
function providerLabelOfRow(label: string): string {
79+
const i = label.indexOf(" / ")
80+
return i === -1 ? label : label.slice(0, i)
81+
}
82+
83+
type ModelGroup = {
84+
readonly label: string
85+
readonly rows: ProductHostModelOption[]
86+
}
87+
88+
/**
89+
* Split a flat, section-tagged models list into the provider-first picker's
90+
* top level (recent/favorites/unconnected pass through flat; each distinct
91+
* provider collapses into one group row, in first-seen order) plus the
92+
* per-provider model rows reached by descending into a group. Rows with no
93+
* `section` (a caller not using buildModelsFirstCatalog) pass through
94+
* ungrouped, preserving today's single-level picker for that caller.
95+
*/
96+
function groupModelsForPicker(
97+
models: readonly ProductHostModelOption[],
98+
): { readonly top: ProductHostModelOption[]; readonly groups: ReadonlyMap<string, ModelGroup> } {
99+
const top: ProductHostModelOption[] = []
100+
const groups = new Map<string, ModelGroup>()
101+
for (const row of models) {
102+
if (row.section !== "provider") {
103+
top.push(row)
104+
continue
105+
}
106+
const provider = providerOfRowId(row.id)
107+
let group = groups.get(provider)
108+
if (group === undefined) {
109+
group = { label: providerLabelOfRow(row.label), rows: [] }
110+
groups.set(provider, group)
111+
top.push({ id: providerGroupRowId(provider), label: group.label, section: "provider" })
112+
}
113+
group.rows.push(row)
114+
}
115+
return { top, groups }
116+
}
117+
118+
/** Suffix the row matching `activeId` (if any) so it reads as the current pick. */
119+
function annotateCurrent(
120+
rows: readonly ProductHostModelOption[],
121+
activeId: string | undefined,
122+
): ProductHostModelOption[] {
123+
if (activeId === undefined) return [...rows]
124+
return rows.map((r) => (r.id === activeId ? { ...r, label: `${r.label} (current)` } : r))
125+
}
126+
61127
export type ProductHostSend = (
62128
text: string,
63129
attachments?: readonly PendingImageAttachment[],
@@ -69,9 +135,19 @@ export type ProductHostDeliver = (
69135
attachments?: readonly PendingImageAttachment[],
70136
) => void
71137

138+
/**
139+
* `section` groups rows for the provider-first picker: "recent" and
140+
* "favorites" stay flat at the top (already single models, reachable without
141+
* descending); "provider" rows are grouped into one top-level entry per
142+
* provider (or per account, since each configured provider entry is already
143+
* account-scoped — `codex/abk-labs`, `codex/dirtroad`); "unconnected" stays
144+
* flat as a "connect →" row. Omitted (from a caller not using
145+
* buildModelsFirstCatalog) falls back to one flat list, unwrapped.
146+
*/
72147
export type ProductHostModelOption = {
73148
readonly id: string
74149
readonly label: string
150+
readonly section?: "recent" | "favorites" | "provider" | "unconnected"
75151
}
76152

77153
export type ProductHostConfig = {
@@ -433,36 +509,86 @@ export async function mountProductHost(
433509
const onSelect = config.onModelSelect
434510
const onConnect = config.onConnectProvider
435511
const onFavoriteToggle = config.onFavoriteToggle
436-
openModels = (): void => {
512+
513+
// Provider rows have no catalog entry of their own to describe; fall back
514+
// to a plain model count so the description zone is never blank.
515+
const describe = (itemId: string): ItemDescription | null => {
516+
const groupProvider = providerFromGroupRowId(itemId)
517+
if (groupProvider !== null) {
518+
const { groups } = groupModelsForPicker(currentModels)
519+
const count = groups.get(groupProvider)?.rows.length ?? 0
520+
return {
521+
what: `${count} model${count === 1 ? "" : "s"} available.`,
522+
impact: "Press Enter to see them.",
523+
tone: "plain",
524+
}
525+
}
526+
return currentDescribeModel?.(itemId) ?? null
527+
}
528+
529+
const openLevel = (items: readonly ProductHostModelOption[], onCancel?: () => void): void => {
437530
openModelPickerOverlay(shell, {
438-
items: currentModels.map((m) => m.label),
439-
itemIds: currentModels.map((m) => m.id),
531+
items: items.map((m) => m.label),
532+
itemIds: items.map((m) => m.id),
440533
onAccept: (sel) => {
441-
const id = sel.id ?? currentModels[sel.index]?.id
534+
const id = sel.id ?? items[sel.index]?.id
442535
if (!id) return
443536
const providerName = id.startsWith("connect:") ? id.slice("connect:".length) : null
444537
if (providerName !== null) {
445538
onConnect?.(providerName)
446539
return
447540
}
541+
const groupProvider = providerFromGroupRowId(id)
542+
if (groupProvider !== null) {
543+
const { groups } = groupModelsForPicker(currentModels)
544+
const group = groups.get(groupProvider)
545+
if (group !== undefined) {
546+
openLevel(annotateCurrent(group.rows, activeModelId()), openModels)
547+
}
548+
return
549+
}
448550
onSelect(id)
449551
},
450-
...(currentDescribeModel !== undefined ? { describe: currentDescribeModel } : {}),
552+
describe,
451553
...(onFavoriteToggle !== undefined
452554
? {
453555
onAction: (itemId, key) => {
454556
// Alt+F, never bare f — the palette filters as you type, so a
455557
// bare letter narrows the list instead of toggling a favorite.
456558
const name = typeof key.name === "string" ? key.name.toLowerCase() : ""
457559
if (name !== "f" || key.ctrl || !(key.meta || key.option)) return false
458-
if (itemId.startsWith("connect:")) return false
560+
if (itemId.startsWith("connect:") || providerFromGroupRowId(itemId) !== null) return false
459561
onFavoriteToggle(itemId)
460562
return true
461563
},
462564
}
463565
: {}),
566+
...(onCancel !== undefined ? { onCancel } : {}),
464567
})
465568
}
569+
570+
// Recent's first row (if any) is the model just switched to — the closest
571+
// thing to a live "current model" id without threading one through from
572+
// the runner. Used only to mark that row "(current)" wherever it appears.
573+
const activeModelId = (): string | undefined =>
574+
currentModels.find((r) => r.section === "recent")?.id
575+
576+
openModels = (): void => {
577+
const { top, groups } = groupModelsForPicker(currentModels)
578+
const activeId = activeModelId()
579+
// The active model's own row already reads "(current)" via annotateCurrent
580+
// below; when it lives inside a provider group, mark the group row too
581+
// so the pick is visible without descending into it.
582+
const activeGroupId = [...groups.entries()].find(([, g]) =>
583+
g.rows.some((r) => r.id === activeId),
584+
)?.[0]
585+
const withGroupMark = activeGroupId === undefined
586+
? top
587+
: top.map((r) =>
588+
r.id === providerGroupRowId(activeGroupId) ? { ...r, label: `${r.label} (current)` } : r,
589+
)
590+
openLevel(annotateCurrent(withGroupMark, activeId))
591+
}
466592
;(shell as AppShell & { __openModels?: () => void }).__openModels =
467593
openModels
468594
}

0 commit comments

Comments
 (0)