feat: fuzzy search for the model picker (WebUI + TUI)
Adds fuzzy subsequence matching with quality ranking to the model pickers, replacing the WebUI's exact-substring filter and giving the TUI a search where it previously had none. - New fuzzy scorer (ui-tui/src/lib/fuzzy.ts + an identical copy at web/src/lib/fuzzy.ts, since the two are separate TS packages with no shared module). Matches a query as an ordered subsequence (so `g4o` matches `gpt-4o`), scores by quality (exact > prefix > word-boundary > contiguous > scattered) and returns matched character positions for highlighting. Multi-token AND semantics (`clad snnt` -> claude-sonnet). 15 vitest tests cover the algorithm. - WebUI ModelPickerDialog: ranked fuzzy filter on providers + models; matched characters in model rows are highlighted via <mark>. - TUI modelPicker: type-to-filter on the provider and model stages with live ranking. Backspace edits the filter, Ctrl+U clears it, Esc clears a non-empty filter before navigating back. Persist-global / disconnect shortcuts moved from g/d to Ctrl+G / Ctrl+D so letters feed the filter. Closes #30849
This commit is contained in:
@@ -9,6 +9,7 @@ import { Check, Search, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn, themedBody } from "@/lib/utils";
|
||||
import { fuzzyRank } from "@/lib/fuzzy";
|
||||
|
||||
/**
|
||||
* Two-stage model picker modal.
|
||||
@@ -150,25 +151,30 @@ export function ModelPickerDialog(props: Props) {
|
||||
[selectedProvider],
|
||||
);
|
||||
|
||||
const needle = query.trim().toLowerCase();
|
||||
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).
|
||||
const filteredProviders = useMemo(
|
||||
() =>
|
||||
!needle
|
||||
? providers
|
||||
: providers.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(needle) ||
|
||||
p.slug.toLowerCase().includes(needle) ||
|
||||
(p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
|
||||
),
|
||||
[providers, needle],
|
||||
fuzzyRank(
|
||||
providers,
|
||||
trimmedQuery,
|
||||
(p) => `${p.name} ${p.slug} ${(p.models ?? []).join(" ")}`,
|
||||
).map((r) => r.item),
|
||||
[providers, trimmedQuery],
|
||||
);
|
||||
|
||||
// Fuzzy-ranked models carrying the matched character positions so the model
|
||||
// list can highlight why each entry matched.
|
||||
const filteredModels = useMemo(
|
||||
() =>
|
||||
!needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
|
||||
[models, needle],
|
||||
fuzzyRank(models, trimmedQuery, (m) => m).map((r) => ({
|
||||
model: r.item,
|
||||
positions: r.positions,
|
||||
})),
|
||||
[models, trimmedQuery],
|
||||
);
|
||||
|
||||
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
|
||||
@@ -257,7 +263,7 @@ export function ModelPickerDialog(props: Props) {
|
||||
providers={filteredProviders}
|
||||
total={providers.length}
|
||||
selectedSlug={selectedSlug}
|
||||
query={needle}
|
||||
query={trimmedQuery}
|
||||
onSelect={(slug) => {
|
||||
setSelectedSlug(slug);
|
||||
setSelectedModel("");
|
||||
@@ -402,7 +408,7 @@ function ModelColumn({
|
||||
onConfirm,
|
||||
}: {
|
||||
provider: ModelOptionProvider | null;
|
||||
models: string[];
|
||||
models: { model: string; positions: number[] }[];
|
||||
allModels: string[];
|
||||
selectedModel: string;
|
||||
currentModel: string;
|
||||
@@ -435,7 +441,7 @@ function ModelColumn({
|
||||
: "no models listed for this provider"}
|
||||
</div>
|
||||
) : (
|
||||
models.map((m) => {
|
||||
models.map(({ model: m, positions }) => {
|
||||
const active = m === selectedModel;
|
||||
const isCurrent =
|
||||
m === currentModel && provider.slug === currentProviderSlug;
|
||||
@@ -451,7 +457,9 @@ function ModelColumn({
|
||||
<Check
|
||||
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
|
||||
/>
|
||||
<span className="flex-1 truncate">{m}</span>
|
||||
<span className="flex-1 truncate">
|
||||
<HighlightedText text={m} positions={positions} />
|
||||
</span>
|
||||
{isCurrent && <CurrentTag />}
|
||||
</ListItem>
|
||||
);
|
||||
@@ -468,3 +476,39 @@ function CurrentTag() {
|
||||
</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>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user