Skip to content

Commit 04d735e

Browse files
committed
Flatten model picker to one type-to-filter list (CL-5494)
1 parent d73f2f2 commit 04d735e

6 files changed

Lines changed: 170 additions & 164 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions
2121

2222
### TUI
2323

24+
- **Flat model picker.** Choosing a model is one type-to-filter list of
25+
`provider / model` rows — no nested provider drill-down. Type to narrow,
26+
Enter selects; Alt+F still toggles favorites when wired.
2427
- **Bottom breathing room.** The prompt box sits one blank row above the
2528
terminal's last line on terminals tall enough to spare it
2629
(`BOTTOM_MARGIN_ROWS`, collapsed below 24 rows), so the layout no longer

docs/TUI.md

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -416,18 +416,14 @@ landing screen at, say, 23 rows gets an 8-row cap instead of 9. This is a
416416
known, accepted cost of the badge rather than an oversight — see
417417
`terminalForGeometry`'s doc comment in `shell.ts` for the exact mechanism.
418418

419-
The model/provider picker is provider-first
420-
(`src/tui/product-host.ts:groupModelsForPicker`/`openLevel`): recent
421-
and favorite provider+model pairs stay flat at the top of the list (already
422-
single models, nothing to descend into); every other provider collapses into
423-
one top-level group row. Selecting a provider group row descends into that
424-
provider's models; selecting a model dispatches the switch. Escape at the
425-
model level returns to the provider level rather than closing the picker
426-
outright (`openLevel(group.rows, onCancel)` passes the parent `openModels`
427-
reopen as the child level's `onCancel`); only Escape at the provider level
428-
closes the picker. Recent/favorite rows and the active provider's group row
429-
both get a `(current)` suffix when they match the session's live active
430-
model.
419+
The model/provider picker is one flat, type-to-filter list
420+
(`src/tui/product-host.ts` + `openModelPickerOverlay({ typeToFilter: true })`):
421+
recent and favorite provider+model pairs sit at the top, then every
422+
`provider / model` leaf from the catalog — no nested provider pane. Typing
423+
narrows the list in place (printable keys claimed by the filter row, same
424+
pattern as the command palette); Enter selects. Escape closes the picker.
425+
The row matching the session's live active model gets a `(current)` suffix.
426+
Alt+F on a model row still toggles favorite when a favorite hook is wired.
431427

432428
Onboarding (the standalone provider-setup screen, `provider-setup.ts`) and
433429
the satellite pickers used for session resume and session-mode selection

src/tui/overlays.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,15 @@ export type OpenModelPickerOpts = {
186186
readonly onAccept?: (selection: OverlaySelection) => void
187187
/** Description-zone source, keyed by the focused row's id. */
188188
readonly describe?: (itemId: string) => ItemDescription | null
189-
/** Bare-key claim on the focused row (e.g. `f` to toggle favorite). */
189+
/** Bare-key claim on the focused row (e.g. Alt+F to toggle favorite). */
190190
readonly onAction?: (itemId: string, key: KeyEvent) => boolean
191-
/** Per-open Esc/dismiss — the provider-first picker steps back to the provider level instead of closing outright. */
191+
/** Per-open Esc/dismiss. */
192192
readonly onCancel?: () => void
193+
/**
194+
* Claim printable keys for a `>` filter row so the flat model list narrows
195+
* as you type. Off by default so other list overlays keep j/k.
196+
*/
197+
readonly typeToFilter?: boolean
193198
}
194199

195200
export function openModelPickerOverlay(
@@ -207,5 +212,9 @@ export function openModelPickerOverlay(
207212
...(opts?.describe !== undefined ? { describe: opts.describe } : {}),
208213
...(opts?.onAction !== undefined ? { onAction: opts.onAction } : {}),
209214
...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}),
215+
...(opts?.typeToFilter !== undefined
216+
? { typeToFilter: opts.typeToFilter }
217+
: {}),
210218
})
211219
}
220+

src/tui/product-host.test.ts

Lines changed: 32 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ 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, closeInsetOverlay, moveOverlaySelection } from "./shell.js"
9+
import { acceptOverlaySelection, moveOverlaySelection } from "./shell.js"
1010
import {
1111
mountProductHost,
1212
operatorResultFromSelection,
@@ -369,66 +369,65 @@ describe("provider-first model picker", () => {
369369
return { harness, host, selected }
370370
}
371371

372-
test("top level lists providers (one row per account), not one row per model", async () => {
372+
test("opens a flat provider/model list (no nested provider drill)", async () => {
373373
const { harness, host } = await mountPicker()
374374
try {
375375
host.openModels?.()
376376
await harness.renderOnce()
377377
const frame = harness.captureCharFrame()
378-
// Each codex account is its own row; the account name appears once,
379-
// not once per model it exposes.
380-
expect(frame).toContain("codex/abk-labs")
381-
expect(frame).toContain("codex/dirtroad")
382-
expect(frame).toContain("codex/fleur")
383-
expect(frame).toContain("xai/thegreataxios")
384-
// The favorite is a leaf row, reachable without descending — it, not
385-
// its provider group, carries the model name at the top level.
386-
expect(frame).toContain("gpt-5.5")
378+
const items = host.shell.overlayItems
379+
// Flat list: every model is a leaf row at the top level (assert the
380+
// data, not the scrolled viewport — short harness heights clip later rows).
381+
expect(items.some((label) => label.includes("gpt-5.5"))).toBe(true)
382+
expect(items.some((label) => label.includes("grok-4.5"))).toBe(true)
383+
expect(items.some((label) => label.includes("codex/abk-labs"))).toBe(true)
384+
expect(items.some((label) => label.includes("xai/thegreataxios"))).toBe(true)
385+
// No provider-group-only rows (those were `providerGroup:` ids with no model).
386+
expect(items.every((label) => label.includes(" / ") || label.startsWith("("))).toBe(true)
387+
// Filter row is present so the list can narrow without another pane.
388+
expect(frame).toContain(">")
387389
} finally {
388390
host.dispose()
389391
harness.destroy()
390392
}
391393
})
392394

393-
test("selecting a provider descends into its models; Escape returns to the provider level", async () => {
394-
const { harness, host } = await mountPicker()
395+
test("typing narrows the flat list; selecting a model applies the pick", async () => {
396+
const { harness, host, selected } = await mountPicker()
395397
try {
396398
host.openModels?.()
397399
await harness.renderOnce()
398400

399-
const items = host.shell.overlayItems
400-
const xaiIndex = items.findIndex((label) => label.includes("xai/thegreataxios"))
401-
expect(xaiIndex).toBeGreaterThanOrEqual(0)
402-
moveOverlaySelection(host.shell, xaiIndex)
403-
acceptOverlaySelection(host.shell)
401+
// Type "grok" into the filter row (printable keys claimed by type-to-filter).
402+
for (const ch of "grok") {
403+
harness.pressKey(ch)
404+
}
404405
await harness.renderOnce()
405406

406-
const modelFrame = harness.captureCharFrame()
407-
expect(modelFrame).toContain("grok-4.5")
408-
expect(modelFrame).not.toContain("codex/abk-labs")
407+
const items = host.shell.overlayItems
408+
expect(items.some((label) => label.includes("grok-4.5"))).toBe(true)
409+
expect(items.every((label) => label.includes("grok") || label === "(no matches)")).toBe(true)
409410

410-
closeInsetOverlay(host.shell)
411-
await harness.renderOnce()
412-
const backFrame = harness.captureCharFrame()
413-
expect(backFrame).toContain("codex/abk-labs")
414-
expect(host.shell.overlayList).not.toBeNull()
411+
const grokIndex = items.findIndex((label) => label.includes("grok-4.5"))
412+
expect(grokIndex).toBeGreaterThanOrEqual(0)
413+
moveOverlaySelection(host.shell, grokIndex)
414+
acceptOverlaySelection(host.shell)
415+
expect(selected).toEqual(["xai/thegreataxios:grok-4.5"])
415416
} finally {
416417
host.dispose()
417418
harness.destroy()
418419
}
419420
})
420421

421-
test("selecting a model at the model level applies the pick", async () => {
422+
test("selecting a model applies the pick without descending", async () => {
422423
const { harness, host, selected } = await mountPicker()
423424
try {
424425
host.openModels?.()
425426
await harness.renderOnce()
426427
const items = host.shell.overlayItems
427-
const xaiIndex = items.findIndex((label) => label.includes("xai/thegreataxios"))
428-
moveOverlaySelection(host.shell, xaiIndex)
429-
acceptOverlaySelection(host.shell)
430-
await harness.renderOnce()
431-
428+
const grokIndex = items.findIndex((label) => label.includes("grok-4.5"))
429+
expect(grokIndex).toBeGreaterThanOrEqual(0)
430+
moveOverlaySelection(host.shell, grokIndex)
432431
acceptOverlaySelection(host.shell)
433432
expect(selected).toEqual(["xai/thegreataxios:grok-4.5"])
434433
} finally {
@@ -488,14 +487,7 @@ describe("provider-first model picker", () => {
488487
await harness.renderOnce()
489488
const frame = harness.captureCharFrame()
490489
expect(frame).not.toContain("xai/thegreataxios / grok-4.5 (current)")
491-
const items = host.shell.overlayItems
492-
const codexIndex = items.findIndex((label) => label.includes("codex/abk-labs"))
493-
expect(codexIndex).toBeGreaterThanOrEqual(0)
494-
moveOverlaySelection(host.shell, codexIndex)
495-
acceptOverlaySelection(host.shell)
496-
await harness.renderOnce()
497-
const modelFrame = harness.captureCharFrame()
498-
expect(modelFrame).toContain("gpt-5.5 (current)")
490+
expect(frame).toContain("gpt-5.5 (current)")
499491
} finally {
500492
host.dispose()
501493
harness.destroy()

src/tui/product-host.ts

Lines changed: 8 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -60,63 +60,6 @@ import type { StreamRow } from "./stream.js"
6060

6161
import type { PendingImageAttachment } from "./image-attachments.js"
6262

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

570-
// Provider rows have no catalog entry of their own to describe; fall back
571-
// to a plain model count so the description zone is never blank.
572-
const describe = (itemId: string): ItemDescription | null => {
573-
const groupProvider = providerFromGroupRowId(itemId)
574-
if (groupProvider !== null) {
575-
const { groups } = groupModelsForPicker(currentModels)
576-
const count = groups.get(groupProvider)?.rows.length ?? 0
577-
return {
578-
what: `${count} model${count === 1 ? "" : "s"} available.`,
579-
impact: "Press Enter to see them.",
580-
tone: "plain",
581-
}
582-
}
583-
return currentDescribeModel?.(itemId) ?? null
584-
}
585-
586-
const openLevel = (items: readonly ProductHostModelOption[], onCancel?: () => void): void => {
513+
openModels = (): void => {
514+
const activeId = config.activeModelId?.()
515+
const items = annotateCurrent(currentModels, activeId)
587516
openModelPickerOverlay(shell, {
588517
items: items.map((m) => m.label),
589518
itemIds: items.map((m) => m.id),
519+
// Flat list: type to narrow rather than drill into a provider pane.
520+
typeToFilter: true,
590521
onAccept: (sel) => {
591522
const id = sel.id ?? items[sel.index]?.id
592523
if (!id) return
@@ -595,51 +526,23 @@ export async function mountProductHost(
595526
onConnect?.(providerName)
596527
return
597528
}
598-
const groupProvider = providerFromGroupRowId(id)
599-
if (groupProvider !== null) {
600-
const { groups } = groupModelsForPicker(currentModels)
601-
const group = groups.get(groupProvider)
602-
if (group !== undefined) {
603-
openLevel(annotateCurrent(group.rows, config.activeModelId?.()), openModels)
604-
}
605-
return
606-
}
607529
onSelect(id)
608530
},
609-
describe,
531+
describe: (itemId) => currentDescribeModel?.(itemId) ?? null,
610532
...(onFavoriteToggle !== undefined
611533
? {
612534
onAction: (itemId, key) => {
613-
// Alt+F, never bare f — the palette filters as you type, so a
614-
// bare letter narrows the list instead of toggling a favorite.
535+
// Alt+F, never bare f — type-to-filter claims printable keys.
615536
const name = typeof key.name === "string" ? key.name.toLowerCase() : ""
616537
if (name !== "f" || key.ctrl || !(key.meta || key.option)) return false
617-
if (itemId.startsWith("connect:") || providerFromGroupRowId(itemId) !== null) return false
538+
if (itemId.startsWith("connect:")) return false
618539
onFavoriteToggle(itemId)
619540
return true
620541
},
621542
}
622543
: {}),
623-
...(onCancel !== undefined ? { onCancel } : {}),
624544
})
625545
}
626-
627-
openModels = (): void => {
628-
const { top, groups } = groupModelsForPicker(currentModels)
629-
const activeId = config.activeModelId?.()
630-
// The active model's own row already reads "(current)" via annotateCurrent
631-
// below; when it lives inside a provider group, mark the group row too
632-
// so the pick is visible without descending into it.
633-
const activeGroupId = [...groups.entries()].find(([, g]) =>
634-
g.rows.some((r) => r.id === activeId),
635-
)?.[0]
636-
const withGroupMark = activeGroupId === undefined
637-
? top
638-
: top.map((r) =>
639-
r.id === providerGroupRowId(activeGroupId) ? { ...r, label: `${r.label} (current)` } : r,
640-
)
641-
openLevel(annotateCurrent(withGroupMark, activeId))
642-
}
643546
;(shell as AppShell & { __openModels?: () => void }).__openModels =
644547
openModels
645548
}

0 commit comments

Comments
 (0)