diff --git a/CHANGELOG.md b/CHANGELOG.md index f3d344c2c..7acca143e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/). Versions ### TUI +- **Flat model picker.** Choosing a model is one type-to-filter list of + `provider / model` rows — no nested provider drill-down. Type to narrow, + Enter selects; Alt+F still toggles favorites when wired. - **Bottom breathing room.** The prompt box sits one blank row above the terminal's last line on terminals tall enough to spare it (`BOTTOM_MARGIN_ROWS`, collapsed below 24 rows), so the layout no longer diff --git a/docs/TUI.md b/docs/TUI.md index 54d6a0989..8665d7442 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -416,18 +416,14 @@ landing screen at, say, 23 rows gets an 8-row cap instead of 9. This is a known, accepted cost of the badge rather than an oversight — see `terminalForGeometry`'s doc comment in `shell.ts` for the exact mechanism. -The model/provider picker is provider-first -(`src/tui/product-host.ts:groupModelsForPicker`/`openLevel`): recent -and favorite provider+model pairs stay flat at the top of the list (already -single models, nothing to descend into); every other provider collapses into -one top-level group row. Selecting a provider group row descends into that -provider's models; selecting a model dispatches the switch. Escape at the -model level returns to the provider level rather than closing the picker -outright (`openLevel(group.rows, onCancel)` passes the parent `openModels` -reopen as the child level's `onCancel`); only Escape at the provider level -closes the picker. Recent/favorite rows and the active provider's group row -both get a `(current)` suffix when they match the session's live active -model. +The model/provider picker is one flat, type-to-filter list +(`src/tui/product-host.ts` + `openModelPickerOverlay({ typeToFilter: true })`): +recent and favorite provider+model pairs sit at the top, then every +`provider / model` leaf from the catalog — no nested provider pane. Typing +narrows the list in place (printable keys claimed by the filter row, same +pattern as the command palette); Enter selects. Escape closes the picker. +The row matching the session's live active model gets a `(current)` suffix. +Alt+F on a model row still toggles favorite when a favorite hook is wired. Onboarding (the standalone provider-setup screen, `provider-setup.ts`) and the satellite pickers used for session resume and session-mode selection diff --git a/src/tui/overlays.ts b/src/tui/overlays.ts index 2f62ad670..5aae171a0 100644 --- a/src/tui/overlays.ts +++ b/src/tui/overlays.ts @@ -186,10 +186,15 @@ export type OpenModelPickerOpts = { readonly onAccept?: (selection: OverlaySelection) => void /** Description-zone source, keyed by the focused row's id. */ readonly describe?: (itemId: string) => ItemDescription | null - /** Bare-key claim on the focused row (e.g. `f` to toggle favorite). */ + /** Bare-key claim on the focused row (e.g. Alt+F to toggle favorite). */ readonly onAction?: (itemId: string, key: KeyEvent) => boolean - /** Per-open Esc/dismiss — the provider-first picker steps back to the provider level instead of closing outright. */ + /** Per-open Esc/dismiss. */ readonly onCancel?: () => void + /** + * Claim printable keys for a `>` filter row so the flat model list narrows + * as you type. Off by default so other list overlays keep j/k. + */ + readonly typeToFilter?: boolean } export function openModelPickerOverlay( @@ -207,5 +212,9 @@ export function openModelPickerOverlay( ...(opts?.describe !== undefined ? { describe: opts.describe } : {}), ...(opts?.onAction !== undefined ? { onAction: opts.onAction } : {}), ...(opts?.onCancel !== undefined ? { onCancel: opts.onCancel } : {}), + ...(opts?.typeToFilter !== undefined + ? { typeToFilter: opts.typeToFilter } + : {}), }) } + diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index 106055e77..56d4380df 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -6,7 +6,7 @@ import { EventEmitter } from "node:events" import { describe, expect, test } from "bun:test" import type { PermissionRequest } from "../permission/types.js" import { createHarness } from "./harness.js" -import { acceptOverlaySelection, closeInsetOverlay, moveOverlaySelection } from "./shell.js" +import { acceptOverlaySelection, moveOverlaySelection } from "./shell.js" import { mountProductHost, operatorResultFromSelection, @@ -369,66 +369,65 @@ describe("provider-first model picker", () => { return { harness, host, selected } } - test("top level lists providers (one row per account), not one row per model", async () => { + test("opens a flat provider/model list (no nested provider drill)", async () => { const { harness, host } = await mountPicker() try { host.openModels?.() await harness.renderOnce() const frame = harness.captureCharFrame() - // Each codex account is its own row; the account name appears once, - // not once per model it exposes. - expect(frame).toContain("codex/abk-labs") - expect(frame).toContain("codex/dirtroad") - expect(frame).toContain("codex/fleur") - expect(frame).toContain("xai/thegreataxios") - // The favorite is a leaf row, reachable without descending — it, not - // its provider group, carries the model name at the top level. - expect(frame).toContain("gpt-5.5") + const items = host.shell.overlayItems + // Flat list: every model is a leaf row at the top level (assert the + // data, not the scrolled viewport — short harness heights clip later rows). + expect(items.some((label) => label.includes("gpt-5.5"))).toBe(true) + expect(items.some((label) => label.includes("grok-4.5"))).toBe(true) + expect(items.some((label) => label.includes("codex/abk-labs"))).toBe(true) + expect(items.some((label) => label.includes("xai/thegreataxios"))).toBe(true) + // No provider-group-only rows (those were `providerGroup:` ids with no model). + expect(items.every((label) => label.includes(" / ") || label.startsWith("("))).toBe(true) + // Filter row is present so the list can narrow without another pane. + expect(frame).toContain(">") } finally { host.dispose() harness.destroy() } }) - test("selecting a provider descends into its models; Escape returns to the provider level", async () => { - const { harness, host } = await mountPicker() + test("typing narrows the flat list; selecting a model applies the pick", async () => { + const { harness, host, selected } = await mountPicker() try { host.openModels?.() await harness.renderOnce() - const items = host.shell.overlayItems - const xaiIndex = items.findIndex((label) => label.includes("xai/thegreataxios")) - expect(xaiIndex).toBeGreaterThanOrEqual(0) - moveOverlaySelection(host.shell, xaiIndex) - acceptOverlaySelection(host.shell) + // Type "grok" into the filter row (printable keys claimed by type-to-filter). + for (const ch of "grok") { + harness.pressKey(ch) + } await harness.renderOnce() - const modelFrame = harness.captureCharFrame() - expect(modelFrame).toContain("grok-4.5") - expect(modelFrame).not.toContain("codex/abk-labs") + const items = host.shell.overlayItems + expect(items.some((label) => label.includes("grok-4.5"))).toBe(true) + expect(items.every((label) => label.includes("grok") || label === "(no matches)")).toBe(true) - closeInsetOverlay(host.shell) - await harness.renderOnce() - const backFrame = harness.captureCharFrame() - expect(backFrame).toContain("codex/abk-labs") - expect(host.shell.overlayList).not.toBeNull() + const grokIndex = items.findIndex((label) => label.includes("grok-4.5")) + expect(grokIndex).toBeGreaterThanOrEqual(0) + moveOverlaySelection(host.shell, grokIndex) + acceptOverlaySelection(host.shell) + expect(selected).toEqual(["xai/thegreataxios:grok-4.5"]) } finally { host.dispose() harness.destroy() } }) - test("selecting a model at the model level applies the pick", async () => { + test("selecting a model applies the pick without descending", async () => { const { harness, host, selected } = await mountPicker() try { host.openModels?.() await harness.renderOnce() const items = host.shell.overlayItems - const xaiIndex = items.findIndex((label) => label.includes("xai/thegreataxios")) - moveOverlaySelection(host.shell, xaiIndex) - acceptOverlaySelection(host.shell) - await harness.renderOnce() - + const grokIndex = items.findIndex((label) => label.includes("grok-4.5")) + expect(grokIndex).toBeGreaterThanOrEqual(0) + moveOverlaySelection(host.shell, grokIndex) acceptOverlaySelection(host.shell) expect(selected).toEqual(["xai/thegreataxios:grok-4.5"]) } finally { @@ -488,14 +487,7 @@ describe("provider-first model picker", () => { 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)") + expect(frame).toContain("gpt-5.5 (current)") } finally { host.dispose() harness.destroy() diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index 0581fc81c..a4ca90d9e 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -60,63 +60,6 @@ import type { StreamRow } from "./stream.js" import type { PendingImageAttachment } from "./image-attachments.js" -const PROVIDER_GROUP_PREFIX = "providerGroup:" - -function providerGroupRowId(provider: string): string { - return `${PROVIDER_GROUP_PREFIX}${provider}` -} - -function providerFromGroupRowId(id: string): string | null { - return id.startsWith(PROVIDER_GROUP_PREFIX) ? id.slice(PROVIDER_GROUP_PREFIX.length) : null -} - -/** Provider (account) segment of a `provider:model` row id. */ -function providerOfRowId(id: string): string { - const i = id.indexOf(":") - return i === -1 ? id : id.slice(0, i) -} - -/** Provider label segment of a `Provider Label / model` row label. */ -function providerLabelOfRow(label: string): string { - const i = label.indexOf(" / ") - return i === -1 ? label : label.slice(0, i) -} - -type ModelGroup = { - readonly label: string - readonly rows: ProductHostModelOption[] -} - -/** - * Split a flat, section-tagged models list into the provider-first picker's - * top level (recent/favorites/unconnected pass through flat; each distinct - * provider collapses into one group row, in first-seen order) plus the - * per-provider model rows reached by descending into a group. Rows with no - * `section` (a caller not using buildModelsFirstCatalog) pass through - * ungrouped, preserving today's single-level picker for that caller. - */ -function groupModelsForPicker( - models: readonly ProductHostModelOption[], -): { readonly top: ProductHostModelOption[]; readonly groups: ReadonlyMap } { - const top: ProductHostModelOption[] = [] - const groups = new Map() - for (const row of models) { - if (row.section !== "provider") { - top.push(row) - continue - } - const provider = providerOfRowId(row.id) - let group = groups.get(provider) - if (group === undefined) { - group = { label: providerLabelOfRow(row.label), rows: [] } - groups.set(provider, group) - top.push({ id: providerGroupRowId(provider), label: group.label, section: "provider" }) - } - group.rows.push(row) - } - return { top, groups } -} - /** Suffix the row matching `activeId` (if any) so it reads as the current pick. */ function annotateCurrent( rows: readonly ProductHostModelOption[], @@ -567,26 +510,14 @@ export async function mountProductHost( const onConnect = config.onConnectProvider const onFavoriteToggle = config.onFavoriteToggle - // Provider rows have no catalog entry of their own to describe; fall back - // to a plain model count so the description zone is never blank. - const describe = (itemId: string): ItemDescription | null => { - const groupProvider = providerFromGroupRowId(itemId) - if (groupProvider !== null) { - const { groups } = groupModelsForPicker(currentModels) - const count = groups.get(groupProvider)?.rows.length ?? 0 - return { - what: `${count} model${count === 1 ? "" : "s"} available.`, - impact: "Press Enter to see them.", - tone: "plain", - } - } - return currentDescribeModel?.(itemId) ?? null - } - - const openLevel = (items: readonly ProductHostModelOption[], onCancel?: () => void): void => { + openModels = (): void => { + const activeId = config.activeModelId?.() + const items = annotateCurrent(currentModels, activeId) openModelPickerOverlay(shell, { items: items.map((m) => m.label), itemIds: items.map((m) => m.id), + // Flat list: type to narrow rather than drill into a provider pane. + typeToFilter: true, onAccept: (sel) => { const id = sel.id ?? items[sel.index]?.id if (!id) return @@ -595,51 +526,23 @@ export async function mountProductHost( onConnect?.(providerName) return } - const groupProvider = providerFromGroupRowId(id) - if (groupProvider !== null) { - const { groups } = groupModelsForPicker(currentModels) - const group = groups.get(groupProvider) - if (group !== undefined) { - openLevel(annotateCurrent(group.rows, config.activeModelId?.()), openModels) - } - return - } onSelect(id) }, - describe, + describe: (itemId) => currentDescribeModel?.(itemId) ?? null, ...(onFavoriteToggle !== undefined ? { onAction: (itemId, key) => { - // Alt+F, never bare f — the palette filters as you type, so a - // bare letter narrows the list instead of toggling a favorite. + // Alt+F, never bare f — type-to-filter claims printable keys. const name = typeof key.name === "string" ? key.name.toLowerCase() : "" if (name !== "f" || key.ctrl || !(key.meta || key.option)) return false - if (itemId.startsWith("connect:") || providerFromGroupRowId(itemId) !== null) return false + if (itemId.startsWith("connect:")) return false onFavoriteToggle(itemId) return true }, } : {}), - ...(onCancel !== undefined ? { onCancel } : {}), }) } - - openModels = (): void => { - const { top, groups } = groupModelsForPicker(currentModels) - 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. - const activeGroupId = [...groups.entries()].find(([, g]) => - g.rows.some((r) => r.id === activeId), - )?.[0] - const withGroupMark = activeGroupId === undefined - ? top - : top.map((r) => - r.id === providerGroupRowId(activeGroupId) ? { ...r, label: `${r.label} (current)` } : r, - ) - openLevel(annotateCurrent(withGroupMark, activeId)) - } ;(shell as AppShell & { __openModels?: () => void }).__openModels = openModels } diff --git a/src/tui/runner-host.test.ts b/src/tui/runner-host.test.ts index ee2e8343e..d9ce3e378 100644 --- a/src/tui/runner-host.test.ts +++ b/src/tui/runner-host.test.ts @@ -323,9 +323,10 @@ describe("mountRunnerHost model picker", () => { [], ) expect(host.openSurface("models")).toBe(true) - // The connect row is gone and the provider now has its own group row - // (drilling into it would surface "gpt-5") instead of a stub message. - expect(host.shell.overlayItems).toContain("openai") + // Flat list: connect row is gone; the new provider appears as a leaf + // `provider / model` row, not a nested group to drill into. + expect(host.shell.overlayItems.some((label) => label.includes("openai"))).toBe(true) + expect(host.shell.overlayItems.some((label) => label.includes("gpt-5"))).toBe(true) expect(host.shell.overlayItems).not.toContain("OpenAI — connect →") } finally { host.dispose() @@ -353,9 +354,8 @@ describe("mountRunnerHost model picker", () => { }) try { expect(host.openSurface("models")).toBe(true) - // Single provider, single model: top level shows the "xai" provider - // group first — descend into it before the model row is focusable. - acceptOverlaySelection(host.shell) + // Flat list: the model row is already focusable at the top level — + // Alt+F toggles favorite without a nested provider drill. const fKey = { name: "f", ctrl: false, meta: false, option: true } as KeyEvent expect(runOverlayAction(host.shell, fKey)).toBe(true) expect(toggled).toEqual(["xai:grok-4"]) diff --git a/src/tui/shell.ts b/src/tui/shell.ts index fe0ced084..c3c5116ac 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -1911,6 +1911,8 @@ type ShellInternals = { | null /** Live filter state for the open palette, so typing can re-filter it. */ paletteFilter: PaletteFilterState | null + /** Live type-to-filter state for a non-palette list overlay (model picker). */ + listFilter: ListFilterState | null /** * Landing composition shown while the transcript has no content: the mark * above the prompt box, the disclosure and starters below it. Dropped (not @@ -3407,6 +3409,11 @@ export type OpenListOverlayOpts = { * server needs authorization. */ readonly echoChoice?: boolean + /** + * Claim printable keys for a `>` filter row so the list narrows as you type. + * Opt-in per open (model picker); other overlays keep j/k navigation. + */ + readonly typeToFilter?: boolean } /** @@ -3475,6 +3482,16 @@ export function openListOverlay( bag.overlayDescribe = opts?.describe ?? null bag.overlayOnAction = opts?.onAction ?? null bag.overlayOnCancel = opts?.onCancel ?? null + // Capture the full unfiltered set so typing can re-narrow in place. + bag.listFilter = + opts?.typeToFilter === true + ? { + query: "", + allItems: [...labels], + allItemIds: opts?.itemIds ? [...opts.itemIds] : [], + allItemValues: opts?.itemValues ? [...opts.itemValues] : [], + } + : null } else if (!bag.priorOverlay) { // Bare palette (no primary under it): no accept payload. bag.overlayItemIds = opts?.itemIds ? [...opts.itemIds] : [] @@ -3486,6 +3503,7 @@ export function openListOverlay( bag.overlayDescribe = opts?.describe ?? null bag.overlayOnAction = opts?.onAction ?? null bag.overlayOnCancel = opts?.onCancel ?? null + bag.listFilter = null } if (!isPalette) { bag.overlayAnswer = @@ -3501,7 +3519,12 @@ export function openListOverlay( } } - const bodyText = opts?.body ?? "" + // Type-to-filter list overlays paint a `>` query row; everything else uses + // the caller's body text (or empty). + const bodyText = + !isPalette && opts?.typeToFilter === true + ? `> ${bag?.listFilter?.query ?? ""}` + : (opts?.body ?? "") // Operator question and permission approval context get body lines; other // list-only overlays keep the body empty. applyOverlayBodyText(shell, bodyText, 0) @@ -3623,6 +3646,18 @@ type PaletteFilterState = { readonly typeToFilter: boolean } +/** + * Live type-to-filter state for a non-palette list overlay (model picker). + * Holds the full unfiltered row set so each keystroke can re-narrow in place + * without reopening the overlay (openListOverlay no-ops when one is already up). + */ +type ListFilterState = { + query: string + readonly allItems: readonly string[] + readonly allItemIds: readonly string[] + readonly allItemValues: readonly (string | undefined)[] +} + /** Re-open the palette against the current filter state (used on every keystroke). */ function repaintPalette(shell: AppShell): void { const state = internals.get(shell)?.paletteFilter @@ -3681,6 +3716,68 @@ export function handlePaletteFilterKey( return true } +/** + * Keys a type-to-filter list overlay claims while open, so the `>` row + * narrows as you type. Mirrors the palette filter, but updates the open + * list in place via setOverlayItems (openListOverlay is a no-op when a + * non-palette overlay is already up). + */ +export function handleListFilterKey( + shell: AppShell, + key: KeyEvent, +): boolean { + const bag = internals.get(shell) + const state = bag?.listFilter + if (!state || shell.overlayList === null) return false + if (shell.overlayKind === "palette") return false + if (key.ctrl || key.meta || key.option) return false + + if (key.name === "backspace") { + if (state.query.length === 0) return true + state.query = state.query.slice(0, -1) + repaintListFilter(shell) + return true + } + + const seq = typeof key.sequence === "string" ? key.sequence : "" + if (seq.length !== 1 || seq < " ") return false + + state.query += seq + repaintListFilter(shell) + return true +} + +function repaintListFilter(shell: AppShell): void { + const bag = internals.get(shell) + const state = bag?.listFilter + if (!state) return + const q = state.query.trim().toLowerCase() + const matched: { label: string; id: string; value: string | undefined }[] = [] + for (let i = 0; i < state.allItems.length; i++) { + const label = state.allItems[i] ?? "" + const id = state.allItemIds[i] ?? label + if (q.length > 0) { + const hay = `${label} ${id}`.toLowerCase() + if (!hay.includes(q)) continue + } + matched.push({ + label, + id, + value: state.allItemValues[i], + }) + } + const labels = matched.length > 0 ? matched.map((m) => m.label) : ["(no matches)"] + const ids = matched.length > 0 ? matched.map((m) => m.id) : [""] + const values = + state.allItemValues.length > 0 + ? matched.length > 0 + ? matched.map((m) => m.value) + : [undefined] + : undefined + setOverlayItems(shell, labels, ids, values) + setOverlayBody(shell, `> ${state.query}`) +} + /** * Move the open overlay's free-text field in or out of taking keystrokes. * Returns false when the overlay offers no such field. @@ -3815,13 +3912,12 @@ export function closeInsetOverlay(shell: AppShell): void { if (filterBag) filterBag.paletteFilter = null } const bag = internals.get(shell) + if (bag) bag.listFilter = null const prior = wasPalette ? bag?.priorOverlay ?? null : null // Permissions/operator overlays back a caller awaiting ev.resolve — Esc must // still settle that promise (as a deny/cancel) or the caller hangs forever. - // model_picker's onCancel is how the provider-first picker steps back to - // the provider level instead of closing outright; harmless no-op for a - // caller that never set one. Palette/mentions/copy have no awaited caller - // and no back-navigation, so they drop silently. + // model_picker onCancel is optional back-navigation for callers that set one; + // palette/mentions/copy have no awaited caller and drop silently. const cancelable = !prior && (shell.overlayKind === "permissions" || @@ -5376,6 +5472,12 @@ export function createAppShell( key.preventDefault() return } + // Same opt-in for list overlays (model picker): type-to-filter claims + // printables so a long flat catalog narrows without a nested pane. + if (handleListFilterKey(shell, key)) { + key.preventDefault() + return + } if (key.name === "up" || key.name === "k") { key.preventDefault() moveOverlaySelection(shell, -1) @@ -5884,6 +5986,7 @@ export function createAppShell( overlayClosedListeners: new Set(), paletteCatalog: paletteCatalogOpt, paletteFilter: null, + listFilter: null, landing: { above: landingAbove, below: landingBelow }, landingNotice: options?.telemetryNotice ?? null, landingDeferredRows: [],