"use client"; /** * The model menu (extracted 2026-09-20 from model-picker.tsx, owner ask: * the agent settings' role selects use the EXACT same dropdown as the * chat model selector — same look, same machine). The fuzzy search + * arrow-walk + Enter pick machine lives here once; each mount owns only * the popover shell (positioning, outside-click, Esc — the chat control * opens UP from the bottom bar, the settings rows open DOWN inside the * dialog). * * The appearance wave (2026-09-26) added the reading-font picker over the * same machine: FontMenuList (families instead of models, plus the * always-offerable DEFAULT row and a custom-family escape input). * * Keyboard machine (owner polish, 2026-09-17): focus STAYS in the search * field — ↑/↓ walk the filtered results (clamped, no wrap), Enter picks * the walked row (or the first result when nothing is walked). * stopPropagation keeps these keys out of every other listener while the * menu is open. */ import { useEffect, useRef, useState } from "react"; import type { ModelCatalogEntry } from "@/src/core"; import { fuzzyScore, subscriptionLabel } from "@/src/core"; import { formatTokens } from "@/components/usage-format"; import { SearchMenuList } from "./search-menu-list"; function priceLabel(cost: ModelCatalogEntry["cost"]): string { const fmt = (n: number) => `$${n < 1 ? n.toFixed(2).replace(/0$/, "") : n.toFixed(2)}`; return `${fmt(cost.input)}/${fmt(cost.output)}`; } /** The model dropdown body — fuzzy search + ranked list. `models === * undefined` is the catalog-loading state; an empty result list gets * its own quiet line. */ export function ModelMenuList({ models, effectiveId, effectiveProvider, onPick, footer, }: { /** The full catalog — undefined while it loads. */ models: ModelCatalogEntry[] | undefined; /** Currently effective id (the row gets the quiet highlight). */ effectiveId: string | undefined; /** The effective id's provider (model-slots wave: the catalog * federates multiple providers and ids can collide across them — * the highlight must match BOTH coordinates or it lands on the * wrong row). */ effectiveProvider?: string; /** Receives the chosen entry. */ onPick: (entry: ModelCatalogEntry) => void; /** Quiet footer note under the list (context-specific). */ footer?: string; }) { return ( <> `${m.provider}/${m.id}`} searchTerms={(m) => [m.provider, m.id, m.name]} compare={(a, b) => a.id.localeCompare(b.id)} isSelected={(m) => m.id === effectiveId && m.provider === (effectiveProvider ?? m.provider)} onPick={onPick} searchTestid="model-search" listTestid="model-list" optionTestid={(m) => `model-option-${m.id.replace(/[^a-z0-9-]/gi, "_")}`} placeholder="Search models…" loading="Loading models…" empty="No models match." renderItem={(m) => <> {m.name} {subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)} {subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id} {formatTokens(m.contextWindow)} } /> {footer && (
{footer}
)} ); } /** The thinking-level dropdown body — the levels this model actually * supports, ✓ on the effective one. Same shape as the chat selector's. */ export function ThinkingMenuList({ levels, effectiveLevel, onPick, empty, footer, }: { levels: string[]; effectiveLevel: string | undefined; onPick: (level: string) => void; /** Shown instead of the list when there are no levels. */ empty?: string; footer?: string; }) { return ( <> {levels.length === 0 ? (
{empty ?? "No thinking levels."}
) : ( levels.map((level) => ( )) )} {footer && (
{footer}
)} ); } /** The provider-picker dropdown body (keys wave-5 polish, 2026-09-21: * the owner wanted the Add-provider control to match the app's dropdown * language) — the same fuzzy-search + arrow-walk + Enter machine over * the pi-ai provider table. Rows read: provider label, its env var in * quiet mono on the right. */ export function ProviderMenuList({ providers, onPick, }: { providers: { id: string; envVar: string; label: string }[]; onPick: (provider: { id: string; envVar: string; label: string }) => void; }) { const [query, setQuery] = useState(""); const [menuIdx, setMenuIdx] = useState(null); const searchRef = useRef(null); const listRef = useRef(null); useEffect(() => { searchRef.current?.focus(); }, []); useEffect(() => { if (menuIdx === null) return; listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" }); }, [menuIdx]); const results = providers .map((p) => ({ p, s: fuzzyScore(query, p.id, p.envVar, p.label) })) .filter((r) => r.s !== null) .sort((a, b) => b.s! - a.s! || a.p.id.localeCompare(b.p.id)) .map((r) => r.p); return ( <> { setQuery(e.target.value); setMenuIdx(null); }} onKeyDown={(e) => { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); if (results.length === 0) return; setMenuIdx((i) => e.key === "ArrowDown" ? Math.min((i ?? -1) + 1, results.length - 1) : Math.max((i ?? results.length) - 1, 0), ); } else if (e.key === "Enter") { const chosen = results[menuIdx ?? 0]; if (chosen) { e.preventDefault(); e.stopPropagation(); onPick(chosen); } } }} placeholder="Search providers…" className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" /> {results.length === 0 && (
No providers match.
)}
    {results.map((p, i) => (
  • ))}
); } /** The reading-font dropdown's row set — the SAME fuzzy + arrow-walk + * Enter machine over font families (appearance wave, 2026-09-26). The * DEFAULT row (null) is a candidate like any family: it matches empty * and "source"/"sans"/"default" queries, so an unfiltered list opens * with the default first and a searched list drops it honestly — Enter * can never pick a row that isn't on screen. Pure — exported for * tests. */ export function fontMenuResults(query: string, families: string[]): Array { const scored = families .map((f) => ({ f, s: fuzzyScore(query, f) })) .filter((r) => r.s !== null) .sort((a, b) => b.s! - a.s! || a.f.localeCompare(b.f)) .map((r) => r.f); // The default row's searchable identity: the factory family + the // word "default" (the suffix it renders with). const defaultScore = fuzzyScore(query, "Source Sans 3", "default"); if (query.trim() === "" || defaultScore !== null) return [null, ...scored]; return scored; } /** The custom-family action row's decision (pure, 2026-09-26): a typed * query that matches NO family becomes the "Use ''" custom pick — * a matching family is the better pick, so the action row only exists * when there is nothing else to pick. Exported for tests. */ export function customFamilyFor(query: string, families: string[]): string | null { const trimmed = query.trim(); if (trimmed === "") return null; return fontMenuResults(query, families).length === 0 ? trimmed : null; } /** The reading-font dropdown body — ONE search field (owner ask * 2026-09-26: "why two?"): the fuzzy machine filters the families, and * when nothing matches, a "Use ''" action row commits the typed * text as a custom family — the old separate custom input is gone, the * search field absorbed its job. `families === undefined` is the * enumerating state; the curated fallback arrives as plain families with * a footer that says so honestly. */ export function FontMenuList({ families, effectiveFamily, onPick, footer, }: { /** Enumerated font families (already deduped/sorted by the caller); * undefined while the enumeration runs. */ families: string[] | undefined; /** The stored override (null = the factory Source Sans 3). */ effectiveFamily: string | null; /** Receives the pick: a family string, or null for the default. */ onPick: (family: string | null) => void; /** Quiet footer note under the list. */ footer?: string; }) { const [query, setQuery] = useState(""); const [menuIdx, setMenuIdx] = useState(null); const searchRef = useRef(null); const listRef = useRef(null); useEffect(() => { searchRef.current?.focus(); }, []); useEffect(() => { if (menuIdx === null) return; listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" }); }, [menuIdx]); const results = fontMenuResults(query, families ?? []); // The custom-family action row: only when a typed query matches // nothing (a matching family is the better pick). Enter picks it. const customFamily = customFamilyFor(query, families ?? []); const walkable: Array = customFamily !== null ? ["__custom__"] : results; return ( <> { setQuery(e.target.value); setMenuIdx(null); // new query → the walk restarts on Enter/↓ }} onKeyDown={(e) => { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); if (walkable.length === 0) return; setMenuIdx((i) => e.key === "ArrowDown" ? Math.min((i ?? -1) + 1, walkable.length - 1) : Math.max((i ?? walkable.length) - 1, 0), ); } else if (e.key === "Enter") { const chosen = walkable[menuIdx ?? 0]; if (chosen !== undefined) { e.preventDefault(); e.stopPropagation(); onPick(chosen === "__custom__" ? customFamily : chosen); } } }} placeholder="Search fonts…" className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" /> {!families &&
Loading fonts…
}
    {families && results.map((family, i) => { const isDefault = family === null; const effective = isDefault ? effectiveFamily === null : family === effectiveFamily; return (
  • ); })} {families && customFamily !== null && (
  • )}
{footer &&
{footer}
} ); }