2026-09-19 21:59:30 +03:00
"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).
*
2026-09-25 20:02:19 +03:00
* 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).
*
2026-09-19 21:59:30 +03:00
* 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" ;
2026-10-28 19:58:07 +02:00
import { CLAUDE_CODE_PROVIDER , fuzzyScore , subscriptionLabel } from "@/src/core" ;
2026-09-19 21:59:30 +03:00
import { formatTokens } from "@/components/usage-format" ;
2026-10-27 21:51:09 +02:00
import { SearchMenuList } from "./search-menu-list" ;
2026-09-19 21:59:30 +03:00
function priceLabel ( cost : ModelCatalogEntry [ "cost" ]) : string {
2026-10-28 19:58:07 +02:00
if ( ! cost ) return "Unpriced" ;
2026-09-19 21:59:30 +03:00
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 ,
2026-09-22 13:54:28 +03:00
effectiveProvider ,
2026-09-19 21:59:30 +03:00
onPick ,
footer ,
} : {
/** The full catalog — undefined while it loads. */
models : ModelCatalogEntry [] | undefined ;
/** Currently effective id (the row gets the quiet highlight). */
effectiveId : string | undefined ;
2026-09-22 13:54:28 +03:00
/** 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 ;
2026-09-19 21:59:30 +03:00
/** Receives the chosen entry. */
onPick : ( entry : ModelCatalogEntry ) => void ;
/** Quiet footer note under the list (context-specific). */
footer? : string ;
}) {
return (
<>
2026-10-27 21:51:09 +02:00
< SearchMenuList
items = { models }
itemKey = {( m ) => ` ${ 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 , "_" ) } ` }
2026-09-19 21:59:30 +03:00
placeholder = "Search models…"
2026-10-27 21:51:09 +02:00
loading = "Loading models…"
empty = "No models match."
renderItem = {( m ) => <>
< span className = "flex items-center justify-between gap-2" >
< span className = "truncate text-card-foreground" >{ m . name }</ span >
2026-10-28 19:58:07 +02:00
< span className = "shrink-0 tabular-nums text-muted-foreground" title = { m . provider === CLAUDE_CODE_PROVIDER ? "Billing is determined by your Claude Code configuration; LearnHarness does not estimate it." : subscriptionLabel ( ` ${ m . provider } : ${ m . id } ` ) ? "Subscription connection" : m . cost ? "USD per 1M tokens in/out" : "Price unavailable" }>
{ m . provider === CLAUDE_CODE_PROVIDER ? "CLI billing" : subscriptionLabel ( ` ${ m . provider } : ${ m . id } ` ) ?? priceLabel ( m . cost )}
2026-10-27 21:51:09 +02:00
</ span >
</ span >
< span className = "flex items-center justify-between gap-2 text-muted-foreground" >
< span className = "truncate" >{ subscriptionLabel ( ` ${ m . provider } : ${ m . id } ` ) ? ` ${ subscriptionLabel ( ` ${ m . provider } : ${ m . id } ` ) } (subscription)` : m . provider } · { m . id }</ span >
2026-10-28 20:36:42 +02:00
< span className = "shrink-0 tabular-nums" >{ m . contextWindow === null ? "—" : formatTokens ( m . contextWindow )}</ span >
2026-10-27 21:51:09 +02:00
</ span >
</>}
2026-09-19 21:59:30 +03:00
/>
{ footer && (
< div className = "mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground" >{ footer }</ div >
)}
</>
);
}
/** 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 ? (
< div className = "px-2 py-3 text-center text-muted-foreground" >{ empty ?? "No thinking levels." }</ div >
) : (
levels . map (( level ) => (
< button
key = { level }
type = "button"
data-testid = { `thinking-option- ${ level } ` }
onClick = {() => onPick ( level )}
className = { `flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
level === effectiveLevel ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
} ` }
>
< span >{ level }</ span >
{ level === effectiveLevel && < span aria - hidden > ✓ </ span >}
</ button >
))
)}
{ footer && (
< div className = "mt-1 border-t px-2 pt-1.5 text-xs text-muted-foreground" >{ footer }</ div >
)}
</>
);
2026-09-20 13:53:56 +03:00
}
/** 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 < number | null >( null );
const searchRef = useRef < HTMLInputElement >( null );
const listRef = useRef < HTMLUListElement >( 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 (
<>
< input
ref = { searchRef }
data-testid = "provider-search"
value = { query }
onChange = {( e ) => {
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 && (
< div className = "px-2 py-3 text-center text-muted-foreground" > No providers match .</ div >
)}
< ul ref = { listRef } data-testid = "provider-list" className = "flex max-h-72 flex-col overflow-y-auto px-1" >
{ results . map (( p , i ) => (
< li key = { p . id }>
< button
type = "button"
data-testid = { `provider-option- ${ p . id . replace ( /[^a-z0-9-]/gi , "_" ) } ` }
data-idx = { i }
onClick = {() => onPick ( p )}
className = { `flex w-full items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
} ` }
>
< span className = "truncate text-card-foreground" >{ p . label }</ span >
< span className = "shrink-0 font-mono text-[11px] text-muted-foreground" >{ p . envVar }</ span >
</ button >
</ li >
))}
</ ul >
</>
);
}
2026-09-25 20:02:19 +03:00
/** 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 < string | null > {
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 ;
}
2026-09-25 20:12:57 +03:00
/** The custom-family action row's decision (pure, 2026-09-26): a typed
* query that matches NO family becomes the "Use '<query>'" 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 '<query>'" 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. */
2026-09-25 20:02:19 +03:00
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 < number | null >( null );
const searchRef = useRef < HTMLInputElement >( null );
const listRef = useRef < HTMLUListElement >( 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 ?? []);
2026-09-25 20:12:57 +03:00
// 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 < string | null > = customFamily !== null ? [ "__custom__" ] : results ;
2026-09-25 20:02:19 +03:00
return (
<>
< input
ref = { searchRef }
data-testid = "font-search"
value = { query }
onChange = {( e ) => {
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 ();
2026-09-25 20:12:57 +03:00
if ( walkable . length === 0 ) return ;
2026-09-25 20:02:19 +03:00
setMenuIdx (( i ) =>
e . key === "ArrowDown"
2026-09-25 20:12:57 +03:00
? Math . min (( i ?? - 1 ) + 1 , walkable . length - 1 )
: Math . max (( i ?? walkable . length ) - 1 , 0 ),
2026-09-25 20:02:19 +03:00
);
} else if ( e . key === "Enter" ) {
2026-09-25 20:12:57 +03:00
const chosen = walkable [ menuIdx ?? 0 ];
2026-09-25 20:02:19 +03:00
if ( chosen !== undefined ) {
e . preventDefault ();
e . stopPropagation ();
2026-09-25 20:12:57 +03:00
onPick ( chosen === "__custom__" ? customFamily : chosen );
2026-09-25 20:02:19 +03:00
}
}
}}
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 && < div className = "px-2 py-3 text-center text-muted-foreground" > Loading fonts … </ div >}
< ul ref = { listRef } data-testid = "font-list" className = "flex max-h-72 flex-col overflow-y-auto px-1" >
{ families &&
results . map (( family , i ) => {
const isDefault = family === null ;
const effective = isDefault ? effectiveFamily === null : family === effectiveFamily ;
return (
< li key = { family ?? "__default" }>
< button
type = "button"
data-testid = { isDefault ? "font-option-default" : `font-option- ${ family . replace ( /[^a-z0-9-]/gi , "_" ) } ` }
data-idx = { i }
onClick = {() => onPick ( family )}
className = { `flex w-full items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}${ effective && i !== menuIdx ? " bg-muted/60" : "" } ` }
>
< span className = "truncate text-card-foreground" >{ family ?? "Source Sans 3" }</ span >
< span className = "shrink-0 text-muted-foreground" >{ isDefault ? "default" : effective && < span aria - hidden > ✓ </ span >}</ span >
</ button >
</ li >
);
})}
2026-09-25 20:12:57 +03:00
{ families && customFamily !== null && (
< li >
< button
type = "button"
data-testid = "font-option-custom"
data-idx = { 0 }
onClick = {() => onPick ( customFamily )}
className = { `flex w-full items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-left outline-none transition-colors hover:bg-muted ${
menuIdx === 0 ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
} ` }
>
< span className = "truncate text-card-foreground" > Use “ { customFamily } ” </ span >
< span className = "shrink-0 text-muted-foreground" > custom </ span >
</ button >
</ li >
)}
2026-09-25 20:02:19 +03:00
</ ul >
{ footer && < div className = "px-2 pt-1.5 text-xs text-muted-foreground" >{ footer }</ div >}
</>
);
}