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>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Lightweight fuzzy subsequence scorer for picker filtering.
|
||||
//
|
||||
// Matches a query as an ordered subsequence of the target (so `g4o` matches
|
||||
// `gpt-4o`) and scores by match quality so callers can rank results. Higher
|
||||
// score is a better match. Returns the matched character indices so callers
|
||||
// can highlight them.
|
||||
//
|
||||
// The scoring favours, in rough order: exact full match, prefix match, matches
|
||||
// that start on a word boundary (after `-`, `_`, `/`, `.`, space, or a
|
||||
// lower→upper case transition), contiguous runs, and earlier matches. This is
|
||||
// intentionally simple — no external dependency — but good enough to make
|
||||
// `son4` rank `claude-sonnet-4` above an incidental scattered hit.
|
||||
//
|
||||
// This is a logically identical copy of ui-tui/src/lib/fuzzy.ts (only prettier
|
||||
// formatting differs); keep the two in sync. The TUI copy carries the vitest
|
||||
// suite (this `web` package has no test runner), so behavioural changes should
|
||||
// be validated there.
|
||||
|
||||
export interface FuzzyMatch {
|
||||
/** Total score; higher is better. */
|
||||
score: number;
|
||||
/** Indices into the original (non-lowercased) target that were matched. */
|
||||
positions: number[];
|
||||
}
|
||||
|
||||
const WORD_BOUNDARY = /[-_/.\s]/;
|
||||
|
||||
function isBoundary(target: string, index: number): boolean {
|
||||
if (index === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const prev = target[index - 1];
|
||||
|
||||
if (WORD_BOUNDARY.test(prev)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// camelCase / lower→upper transition (e.g. the `O` in `gptO`).
|
||||
const cur = target[index];
|
||||
|
||||
return (
|
||||
prev === prev.toLowerCase() &&
|
||||
cur !== cur.toLowerCase() &&
|
||||
cur === cur.toUpperCase()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a single query token against a target. Returns null when the token is
|
||||
* not a subsequence of the target. An empty query scores 0 with no positions.
|
||||
*/
|
||||
export function fuzzyScore(target: string, query: string): FuzzyMatch | null {
|
||||
if (!query) {
|
||||
return { score: 0, positions: [] };
|
||||
}
|
||||
|
||||
const lowerTarget = target.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
const positions: number[] = [];
|
||||
let score = 0;
|
||||
let prevIndex = -1;
|
||||
let searchFrom = 0;
|
||||
|
||||
for (const ch of lowerQuery) {
|
||||
const idx = lowerTarget.indexOf(ch, searchFrom);
|
||||
|
||||
if (idx < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
positions.push(idx);
|
||||
|
||||
// Base point for the matched character.
|
||||
score += 1;
|
||||
|
||||
// Contiguous with the previous match → strong bonus.
|
||||
if (prevIndex >= 0 && idx === prevIndex + 1) {
|
||||
score += 5;
|
||||
} else if (prevIndex >= 0) {
|
||||
// Penalise the gap we had to skip (capped), so contiguous beats scattered.
|
||||
score -= Math.min(idx - prevIndex - 1, 3);
|
||||
}
|
||||
|
||||
// Word-boundary / start-of-string matches are meaningful.
|
||||
if (isBoundary(target, idx)) {
|
||||
score += 3;
|
||||
}
|
||||
|
||||
// Matching the very first character of the target is the strongest signal.
|
||||
if (idx === 0) {
|
||||
score += 5;
|
||||
}
|
||||
|
||||
prevIndex = idx;
|
||||
searchFrom = idx + 1;
|
||||
}
|
||||
|
||||
// Prefix bonus: the query matched a contiguous prefix of the target.
|
||||
if (
|
||||
positions.length &&
|
||||
positions[0] === 0 &&
|
||||
positions[positions.length - 1] === positions.length - 1
|
||||
) {
|
||||
score += 8;
|
||||
}
|
||||
|
||||
// Exact full match dominates everything else.
|
||||
if (lowerTarget === lowerQuery) {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
// Slightly prefer shorter targets when scores are otherwise close, so a
|
||||
// query that fully prefixes a short id beats the same prefix on a long one.
|
||||
score -= lowerTarget.length * 0.01;
|
||||
|
||||
return { score, positions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Score a target against a whitespace-separated, multi-token query. Every token
|
||||
* must match (AND semantics); the result aggregates per-token scores and the
|
||||
* union of matched positions. Returns null if any token fails to match.
|
||||
*/
|
||||
export function fuzzyScoreMulti(
|
||||
target: string,
|
||||
query: string,
|
||||
): FuzzyMatch | null {
|
||||
const tokens = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
if (!tokens.length) {
|
||||
return { score: 0, positions: [] };
|
||||
}
|
||||
|
||||
let score = 0;
|
||||
const positionSet = new Set<number>();
|
||||
|
||||
for (const token of tokens) {
|
||||
const match = fuzzyScore(target, token);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
score += match.score;
|
||||
|
||||
for (const pos of match.positions) {
|
||||
positionSet.add(pos);
|
||||
}
|
||||
}
|
||||
|
||||
return { score, positions: [...positionSet].sort((a, b) => a - b) };
|
||||
}
|
||||
|
||||
export interface RankedItem<T> {
|
||||
item: T;
|
||||
score: number;
|
||||
positions: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter + rank a list by a fuzzy query against a derived text key. Non-matching
|
||||
* items are dropped; matches are sorted by score (descending), ties broken by
|
||||
* the original index so ordering is stable for equal scores. An empty query
|
||||
* returns every item in original order with no positions.
|
||||
*/
|
||||
export function fuzzyRank<T>(
|
||||
items: readonly T[],
|
||||
query: string,
|
||||
toText: (item: T) => string,
|
||||
): RankedItem<T>[] {
|
||||
const trimmed = query.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
return items.map((item) => ({ item, score: 0, positions: [] }));
|
||||
}
|
||||
|
||||
const ranked: Array<RankedItem<T> & { index: number }> = [];
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const match = fuzzyScoreMulti(toText(item), trimmed);
|
||||
|
||||
if (match) {
|
||||
ranked.push({ item, score: match.score, positions: match.positions, index });
|
||||
}
|
||||
});
|
||||
|
||||
ranked.sort((a, b) => b.score - a.score || a.index - b.index);
|
||||
|
||||
return ranked.map(({ item, score, positions }) => ({ item, score, positions }));
|
||||
}
|
||||
Reference in New Issue
Block a user