"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 } from "@/src/core"; import { formatTokens } from "@/components/usage-format"; 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; }) { const [query, setQuery] = useState(""); const [menuIdx, setMenuIdx] = useState(null); const searchRef = useRef(null); const listRef = useRef(null); useEffect(() => { searchRef.current?.focus(); }, []); // Keep the arrow-walked result visible in the scrolling list. useEffect(() => { if (menuIdx === null) return; listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" }); }, [menuIdx]); // The filtered, ranked model list. const results = (models ?? []) .map((m) => ({ m, s: fuzzyScore(query, m.provider, m.id, m.name) })) .filter((r) => r.s !== null) .sort((a, b) => b.s! - a.s! || a.m.id.localeCompare(b.m.id)) .map((r) => r.m); 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 (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 models…" 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" /> {!models &&
Loading models…
} {models && results.length === 0 && (
No models match.
)}
    {results.map((m, i) => (
  • ))}
{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 reading-font dropdown body — search + list + the custom-family * escape (the old free-text input lives here as the last resort: any * family name the enumeration didn't surface). `families === * undefined` is the enumerating state (desktop's queryLocalFonts); the * curated fallback list 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 [custom, setCustom] = useState(""); 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 ?? []); const commitCustom = () => { const value = custom.trim(); if (value === "") return; onPick(value); }; 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 (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 !== undefined) { e.preventDefault(); e.stopPropagation(); onPick(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.length === 0 && (
No fonts match — type a family below.
)}
    {families && results.map((family, i) => { const isDefault = family === null; const effective = isDefault ? effectiveFamily === null : family === effectiveFamily; return (
  • ); })}
{/* The manual escape path — the old free-text input, living inside the dropdown as its last row. Any family name the enumeration didn't surface commits verbatim. */}
setCustom(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); commitCustom(); } }} placeholder="Type a family name…" aria-label="Custom font family" className="lh-input min-w-0 flex-1 rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring" />
{footer &&
{footer}
} ); }