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:
kshitijk4poor
2026-06-01 16:58:58 -07:00
committed by Teknium
parent c45593ceae
commit 7527e7aeac
5 changed files with 695 additions and 54 deletions
+60 -16
View File
@@ -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>
),
)}
</>
);
}