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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 8 additions & 12 deletions docs/TUI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions src/tui/overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 }
: {}),
})
}

72 changes: 32 additions & 40 deletions src/tui/product-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down
113 changes: 8 additions & 105 deletions src/tui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ModelGroup> } {
const top: ProductHostModelOption[] = []
const groups = new Map<string, ModelGroup>()
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[],
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
12 changes: 6 additions & 6 deletions src/tui/runner-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"])
Expand Down
Loading
Loading