Files
hermes-agent/web/src/components/ModelPickerDialog.tsx
teknium1 a2ae8f229d refactor(ts): one fuzzy + model-search-text helper in @hermes/shared; desktop picker ranks with fuzzyRank
Three byte-identical (modulo prettier and a "keep in sync" header comment)
copies of model-search-text.ts and two of fuzzy.ts collapse into one copy
each under apps/shared/src, exported from the package root and as the
subpaths `@hermes/shared/fuzzy` / `@hermes/shared/model-search-text` (the
TUI compiles with lib ES2023 and imports subpaths, never the DOM-typed
root). The vitest suites move with the code; no re-export shims remain.

Sites (path::symbol -> canonical):

  ui-tui/src/lib/fuzzy.ts::fuzzyScore/fuzzyScoreMulti/fuzzyRank   -> apps/shared/src/fuzzy.ts (moved)
  web/src/lib/fuzzy.ts::fuzzyScore/fuzzyScoreMulti/fuzzyRank      -> deleted
  ui-tui/src/lib/model-search-text.ts::modelSearchText            -> apps/shared/src/model-search-text.ts (moved)
  web/src/lib/model-search-text.ts::modelSearchText               -> deleted
  apps/desktop/src/lib/model-search-text.ts::modelSearchText      -> deleted
  ui-tui/src/lib/fuzzy.test.ts                                    -> apps/shared/src/fuzzy.test.ts (moved)
  ui-tui/src/lib/model-search-text.test.ts                        -> apps/shared/src/model-search-text.test.ts (moved)
  ui-tui/src/components/modelPicker.tsx::fuzzyRank, modelSearchText     -> @hermes/shared/fuzzy, @hermes/shared/model-search-text
  web/src/components/ModelPickerDialog.tsx::fuzzyRank, modelSearchText  -> @hermes/shared
  web/src/lib/model-picker-filter.ts::fuzzyScoreMulti                   -> @hermes/shared
  apps/desktop/src/components/model-picker.tsx::modelSearchText         -> @hermes/shared (+ fuzzyRank, see below)

The header comment now names only the cross-language twin
(hermes_cli/model_search.py) as the thing to keep in sync.

Behavior change (desktop only): the desktop model picker used to filter
model rows with `foldIncludes` substring matching and keep the curated
order; it now ranks them with the same `fuzzyRank(models, query,
modelSearchText)` the web and TUI pickers use. What a user sees
differently while typing a query:

  - subsequence queries match: "g4o" now finds "gpt-4o" (previously only
    a literal substring such as "gpt-4" or "4o" matched);
  - the best match floats to the top instead of rows staying in curated
    order (exact > prefix > word-boundary > contiguous > scattered);
  - a query that matches the provider name/slug still shows that
    provider's full curated list in order, exactly as before;
  - an empty query still shows the curated list verbatim.

The in-row highlight is unchanged (substring emphasis via HighlightMatches),
so a fuzzy-only hit renders without emphasis rather than mis-highlighting.

Tests: apps/desktop/src/components/model-picker.test.tsx::"orders model
rows exactly as the shared fuzzyRank does" asserts the rendered row order
equals the shared fuzzyRank order for the same inputs (fails on both the
old substring filter and a reversed ranking).
2026-09-13 06:50:57 -07:00

666 lines
21 KiB
TypeScript

import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import type { GatewayClient } from "@/lib/gatewayClient";
import type { ModelOptionProvider, ModelOptionsResponse } from "@hermes/shared";
import { Check, RefreshCw, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn, themedBody } from "@/lib/utils";
import { queryMatchesProviderOnly } from "@/lib/model-picker-filter";
import { fuzzyRank, modelSearchText } from "@hermes/shared";
/**
* Two-stage model picker modal.
*
* Mirrors ui-tui/src/components/modelPicker.tsx:
* Stage 1: pick provider (authenticated providers only)
* Stage 2: pick model within that provider
*
* Two invocation modes:
*
* 1. Chat-session mode (ChatSidebar) — pass `gw` + `sessionId`. The picker
* loads options via `model.options` JSON-RPC and applies the choice via
* `config.set`, so expensive-model confirmation can happen before switch.
*
* 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and
* `onApply`. The picker fetches options via the REST endpoint and calls
* `onApply(provider, model, persistGlobal)` instead of emitting a slash
* command. This lets the Models page reuse the same UI without
* requiring an open chat PTY.
*/
interface ExpensiveModelConfirmResponse {
confirm_message?: string;
confirm_required?: boolean;
warning?: string;
}
interface ConfigSetResponse extends ExpensiveModelConfirmResponse {
value?: string;
}
interface PendingExpensiveConfirm {
message: string;
model: string;
persistGlobal: boolean;
provider: string;
}
interface Props {
/** Chat-mode: when present, picker emits a slash command via onSubmit. */
gw?: GatewayClient;
sessionId?: string;
onSubmit?(slashCommand: string): void;
/** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */
loader?(options?: { refresh?: boolean }): Promise<ModelOptionsResponse>;
onApply?(args: {
confirmExpensiveModel?: boolean;
provider: string;
model: string;
persistGlobal: boolean;
}):
| Promise<ExpensiveModelConfirmResponse | void>
| ExpensiveModelConfirmResponse
| void;
onClose(): void;
title?: string;
/** If true, hides "Persist globally" checkbox — always saves to config.yaml. */
alwaysGlobal?: boolean;
}
export function ModelPickerDialog(props: Props) {
const {
gw,
sessionId,
onSubmit,
loader,
onApply,
onClose,
title = "Switch Model",
alwaysGlobal = false,
} = props;
const standalone = !!loader && !!onApply;
const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
const [currentModel, setCurrentModel] = useState("");
const [currentProviderSlug, setCurrentProviderSlug] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedSlug, setSelectedSlug] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [query, setQuery] = useState("");
const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal);
const [applying, setApplying] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [pendingConfirm, setPendingConfirm] =
useState<PendingExpensiveConfirm | null>(null);
const closedRef = useRef(false);
const applyOptions = (r: ModelOptionsResponse) => {
const next = r?.providers ?? [];
setProviders(next);
setCurrentModel(String(r?.model ?? ""));
setCurrentProviderSlug(String(r?.provider ?? ""));
setSelectedSlug((prev) => {
if (prev && next.some((p) => p.slug === prev)) return prev;
return (next.find((p) => p.is_current) ?? next[0])?.slug ?? "";
});
setSelectedModel("");
};
const requestOptions = (refresh = false) =>
standalone
? (loader as (options?: { refresh?: boolean }) => Promise<ModelOptionsResponse>)({
refresh,
})
: (gw as GatewayClient).request<ModelOptionsResponse>(
"model.options",
{
...(sessionId ? { session_id: sessionId } : {}),
...(refresh ? { refresh: true } : {}),
// Dashboard picker mirrors the TUI: full provider universe with
// setup warnings. The backend now defaults to the configured
// subset (#56974), so opt into unconfigured rows explicitly.
include_unconfigured: true,
},
);
const refreshOptions = () => {
setError(null);
setRefreshing(true);
requestOptions(true)
.then((r) => {
if (closedRef.current) return;
applyOptions(r);
})
.catch((e) => {
if (closedRef.current) return;
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (closedRef.current) return;
setRefreshing(false);
});
};
// Load providers + models on open.
useEffect(() => {
closedRef.current = false;
requestOptions()
.then((r) => {
if (closedRef.current) return;
applyOptions(r);
})
.catch((e) => {
if (closedRef.current) return;
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => {
if (closedRef.current) return;
setLoading(false);
});
return () => {
closedRef.current = true;
};
// Deliberately omit props from deps — stable for the dialog's lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Esc closes.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const selectedProvider = useMemo(
() => providers.find((p) => p.slug === selectedSlug) ?? null,
[providers, selectedSlug],
);
const models = useMemo(
() => selectedProvider?.models ?? [],
[selectedProvider],
);
const trimmedQuery = query.trim();
// Fuzzy-ranked providers: match on name + slug + the provider's model ids so
// typing a model name surfaces its provider (preserves the prior behaviour
// where a model match also revealed its provider).
//
// With no query, float providers that actually have models to the top
// (stable within each group). A fresh install lists ~40 providers and only
// a couple are configured — burying "OpenRouter · 37 models" under a wall
// of "0 models" rows made the picker feel broken.
const filteredProviders = useMemo(() => {
const ranked = fuzzyRank(
providers,
trimmedQuery,
(p) => `${p.name} ${p.slug} ${(p.models ?? []).join(" ")}`,
).map((r) => r.item);
if (trimmedQuery) return ranked;
const withModels = ranked.filter((p) => (p.models ?? []).length > 0);
const withoutModels = ranked.filter((p) => (p.models ?? []).length === 0);
return [...withModels, ...withoutModels];
}, [providers, trimmedQuery]);
// A query that matched the SELECTED provider by name/slug (not its models)
// located that provider — it shouldn't also hide that provider's models
// just because their ids don't share a substring with the provider name
// (e.g. typing "aws" to find "AWS Build" then finding zero of its Claude
// model ids contain "aws"). Fall back to an unfiltered model list in that
// case; a query that also matches a model id keeps filtering normally.
const queryMatchesSelectedProviderOnly = useMemo(
() => queryMatchesProviderOnly(selectedProvider, models, trimmedQuery),
[trimmedQuery, selectedProvider, models],
);
// Fuzzy-ranked models carrying the matched character positions so the model
// list can highlight why each entry matched. modelSearchText adds aliases
// for brand-less wire ids (e.g. Kimi Coding `k3` ↔ search "kimi").
const filteredModels = useMemo(
() =>
fuzzyRank(
models,
queryMatchesSelectedProviderOnly ? "" : trimmedQuery,
modelSearchText,
).map((r) => ({
model: r.item,
// Positions may land in alias suffixes — keep only in-id highlights.
positions: r.positions.filter((i) => i >= 0 && i < r.item.length),
})),
[models, trimmedQuery, queryMatchesSelectedProviderOnly],
);
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
const applySelection = async (
confirmExpensiveModel = false,
forced?: PendingExpensiveConfirm,
) => {
const providerSlug = forced?.provider ?? selectedProvider?.slug ?? "";
const model = forced?.model ?? selectedModel;
const shouldPersistGlobal = forced?.persistGlobal ?? persistGlobal;
if (!providerSlug || !model || applying) return;
if (standalone && onApply) {
setApplying(true);
try {
const result = await onApply({
confirmExpensiveModel,
provider: providerSlug,
model,
persistGlobal: shouldPersistGlobal,
});
if (result?.confirm_required) {
setPendingConfirm({
provider: providerSlug,
model,
persistGlobal: shouldPersistGlobal,
message:
result.confirm_message ||
result.warning ||
"This model has unusually high known pricing.",
});
return;
}
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setApplying(false);
}
} else if (gw && sessionId) {
setApplying(true);
try {
const global = shouldPersistGlobal ? " --global" : "";
const result = await gw.request<ConfigSetResponse>("config.set", {
confirm_expensive_model: confirmExpensiveModel,
key: "model",
session_id: sessionId,
value: `${model} --provider ${providerSlug}${global}`,
});
if (result?.confirm_required) {
setPendingConfirm({
provider: providerSlug,
model,
persistGlobal: shouldPersistGlobal,
message:
result.confirm_message ||
result.warning ||
"This model has unusually high known pricing.",
});
return;
}
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setApplying(false);
}
} else if (onSubmit) {
const global = shouldPersistGlobal ? " --global" : "";
onSubmit(`/model ${model} --provider ${providerSlug}${global}`);
onClose();
}
};
const confirm = () => {
if (!canConfirm) return;
void applySelection();
};
// Portal to document.body: the main dashboard column in App.tsx is
// `relative z-2`, which creates a stacking context that traps fixed
// descendants below the app sidebar (z-50). Without the portal this
// modal's z-[100] is scoped to z-2 and the sidebar covers its left
// edge — visible especially in the Large theme variants where the
// larger root font widens the dialog into the sidebar's column. See
// Toast.tsx for the same pattern.
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="model-picker-title"
>
<div className={cn(themedBody, "relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col")}>
<Button
ghost
size="icon"
onClick={onClose}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="model-picker-title"
className="font-mondwest text-display text-base tracking-wider"
>
{title}
</h2>
<p className="text-xs text-muted-foreground mt-1 font-mono">
current: {currentModel || "(unknown)"}
{currentProviderSlug && ` · ${currentProviderSlug}`}
</p>
</header>
<div className="px-5 pt-3 pb-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
autoFocus
placeholder="Filter providers and models…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-7 h-8 text-sm"
/>
</div>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden">
<ProviderColumn
loading={loading}
error={error}
providers={filteredProviders}
total={providers.length}
selectedSlug={selectedSlug}
query={trimmedQuery}
onSelect={(slug) => {
setSelectedSlug(slug);
setSelectedModel("");
}}
/>
<ModelColumn
provider={selectedProvider}
models={filteredModels}
allModels={models}
selectedModel={selectedModel}
currentModel={currentModel}
currentProviderSlug={currentProviderSlug}
onSelect={setSelectedModel}
onConfirm={(m) => {
setSelectedModel(m);
void applySelection(false, {
provider: selectedProvider?.slug ?? "",
model: m,
persistGlobal,
message: "",
});
}}
/>
</div>
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
{alwaysGlobal ? (
<span className="text-xs text-muted-foreground">
Saves to config.yaml — applies to new sessions.
</span>
) : (
<div className="flex items-center gap-2">
<Checkbox
checked={persistGlobal}
id="model-picker-persist-global"
onCheckedChange={(checked) =>
setPersistGlobal(checked === true)
}
/>
<Label
className="font-mondwest normal-case tracking-normal text-xs text-muted-foreground cursor-pointer"
htmlFor="model-picker-persist-global"
>
Persist globally (otherwise this session only)
</Label>
</div>
)}
<div className="flex items-center gap-2 ml-auto">
<Button
outlined
onClick={refreshOptions}
disabled={applying || loading || refreshing}
>
{refreshing ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
Refresh Models
</Button>
<Button outlined onClick={onClose} disabled={applying}>
Cancel
</Button>
<Button onClick={confirm} disabled={!canConfirm}>
{applying ? <Spinner /> : "Switch"}
</Button>
</div>
</footer>
</div>
<ConfirmDialog
open={!!pendingConfirm}
title="Expensive Model Warning"
description={pendingConfirm?.message}
destructive
confirmLabel="Switch anyway"
cancelLabel="Cancel"
loading={applying}
onCancel={() => setPendingConfirm(null)}
onConfirm={() => {
const pending = pendingConfirm;
if (!pending) return;
setPendingConfirm(null);
void applySelection(true, pending);
}}
/>
</div>,
document.body,
);
}
/* ------------------------------------------------------------------ */
/* Provider column */
/* ------------------------------------------------------------------ */
function ProviderColumn({
loading,
error,
providers,
total,
selectedSlug,
query,
onSelect,
}: {
loading: boolean;
error: string | null;
providers: ModelOptionProvider[];
total: number;
selectedSlug: string;
query: string;
onSelect(slug: string): void;
}) {
return (
<div className="border-r border-border overflow-y-auto">
{loading && (
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
<Spinner className="text-xs" /> loading…
</div>
)}
{error && <div className="p-4 text-xs text-destructive">{error}</div>}
{!loading && !error && providers.length === 0 && (
<div className="p-4 text-xs text-muted-foreground italic">
{query
? "no matches"
: total === 0
? "no authenticated providers"
: "no matches"}
</div>
)}
{providers.map((p) => {
const active = p.slug === selectedSlug;
return (
<ListItem
key={p.slug}
active={active}
onClick={() => onSelect(p.slug)}
className={`items-start text-xs border-l-2 ${
active ? "border-l-primary" : "border-l-transparent"
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium truncate">{p.name}</span>
{p.is_current && <CurrentTag />}
</div>
<div className="text-xs text-text-secondary font-mono truncate">
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
</div>
</div>
</ListItem>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Model column */
/* ------------------------------------------------------------------ */
function ModelColumn({
provider,
models,
allModels,
selectedModel,
currentModel,
currentProviderSlug,
onSelect,
onConfirm,
}: {
provider: ModelOptionProvider | null;
models: { model: string; positions: number[] }[];
allModels: string[];
selectedModel: string;
currentModel: string;
currentProviderSlug: string;
onSelect(model: string): void;
onConfirm(model: string): void;
}) {
if (!provider) {
return (
<div className="overflow-y-auto">
<div className="p-4 text-xs text-muted-foreground italic">
pick a provider →
</div>
</div>
);
}
return (
<div className="overflow-y-auto">
{provider.warning && (
<div className="p-3 text-xs text-destructive border-b border-border">
{provider.warning}
</div>
)}
{models.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground italic">
{allModels.length
? "no models match your filter"
: "no models listed for this provider"}
</div>
) : (
models.map(({ model: m, positions }) => {
const active = m === selectedModel;
const isCurrent =
m === currentModel && provider.slug === currentProviderSlug;
return (
<ListItem
key={m}
active={active}
onClick={() => onSelect(m)}
onDoubleClick={() => onConfirm(m)}
className="px-3 py-1.5 text-xs font-mono"
>
<Check
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="flex-1 truncate">
<HighlightedText text={m} positions={positions} />
</span>
{isCurrent && <CurrentTag />}
</ListItem>
);
})
)}
</div>
);
}
function CurrentTag() {
return (
<span className="text-display text-xs tracking-wider text-primary shrink-0">
current
</span>
);
}
/**
* Render `text` with the characters at `positions` emphasised, so users can
* see which characters their fuzzy query matched. Positions are indices into
* `text`; out-of-range indices are ignored.
*/
function HighlightedText({
text,
positions,
}: {
text: string;
positions: number[];
}) {
if (!positions.length) {
return <>{text}</>;
}
const hit = new Set(positions);
return (
<>
{Array.from(text).map((ch, i) =>
hit.has(i) ? (
<mark
key={i}
className="bg-transparent text-primary font-semibold underline underline-offset-2"
>
{ch}
</mark>
) : (
<span key={i}>{ch}</span>
),
)}
</>
);
}