fix: prioritize exact substring matches over fuzzy in model search

- Exact substring in label (earliest position wins)
- Word-boundary prefix matches
- Description substring matches
- Fuzzy matching as fallback

This ensures 'opus' shows claude-3-opus before openrouter models.
This commit is contained in:
Vignesh Natarajan
2026-01-18 14:01:27 -08:00
committed by Peter Steinberger
parent de44e0ad33
commit 950f8a04ea
2 changed files with 99 additions and 1 deletions

View File

@@ -45,6 +45,41 @@ describe("SearchableSelectList", () => {
expect(selected?.value).toBe("google/gemini-pro");
});
it("prioritizes exact substring matches over fuzzy matches", () => {
// Add items where one has early exact match, others are fuzzy or late matches
const items = [
{ value: "openrouter/auto", label: "openrouter/auto", description: "Routes to best" },
{ value: "opus-direct", label: "opus-direct", description: "Direct opus model" },
{ value: "anthropic/claude-3-opus", label: "anthropic/claude-3-opus", description: "Claude 3 Opus" },
];
const list = new SearchableSelectList(items, 5, mockTheme);
// Type "opus" - should match "opus-direct" first (earliest exact substring)
for (const ch of "opus") {
list.handleInput(ch);
}
// First result should be "opus-direct" where "opus" appears at position 0
const selected = list.getSelectedItem();
expect(selected?.value).toBe("opus-direct");
});
it("exact label match beats description match", () => {
const items = [
{ value: "provider/other", label: "provider/other", description: "This mentions opus in description" },
{ value: "provider/opus-model", label: "provider/opus-model", description: "Something else" },
];
const list = new SearchableSelectList(items, 5, mockTheme);
for (const ch of "opus") {
list.handleInput(ch);
}
// Label match should win over description match
const selected = list.getSelectedItem();
expect(selected?.value).toBe("provider/opus-model");
});
it("filters items with fuzzy matching", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme);

View File

@@ -46,7 +46,7 @@ export class SearchableSelectList implements Component {
if (!query) {
this.filteredItems = this.items;
} else {
this.filteredItems = fuzzyFilter(this.items, query, (item) => `${item.label} ${item.description ?? ""}`);
this.filteredItems = this.smartFilter(query);
}
// Reset selection when filter changes
@@ -54,6 +54,69 @@ export class SearchableSelectList implements Component {
this.notifySelectionChange();
}
/**
* Smart filtering that prioritizes:
* 1. Exact substring match in label (highest priority)
* 2. Word-boundary prefix match in label
* 3. Exact substring match in description
* 4. Fuzzy match (lowest priority)
*/
private smartFilter(query: string): SelectItem[] {
const q = query.toLowerCase();
type ScoredItem = { item: SelectItem; score: number };
const scored: ScoredItem[] = [];
for (const item of this.items) {
const label = item.label.toLowerCase();
const desc = (item.description ?? "").toLowerCase();
let score = Infinity;
// Tier 1: Exact substring in label (score 0-99)
const labelIndex = label.indexOf(q);
if (labelIndex !== -1) {
// Earlier match = better score
score = labelIndex;
}
// Tier 2: Word-boundary prefix in label (score 100-199)
else if (this.matchesWordBoundary(label, q)) {
score = 100;
}
// Tier 3: Exact substring in description (score 200-299)
else if (desc.indexOf(q) !== -1) {
score = 200;
}
// Tier 4: Fuzzy match (score 300+)
else {
const fuzzyResult = fuzzyFilter([item], query, (i) => `${i.label} ${i.description ?? ""}`);
if (fuzzyResult.length > 0) {
score = 300;
}
}
if (score !== Infinity) {
scored.push({ item, score });
}
}
// Sort by score (lower = better)
scored.sort((a, b) => a.score - b.score);
return scored.map((s) => s.item);
}
/**
* Check if query matches at a word boundary in text.
* E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary.
*/
private matchesWordBoundary(text: string, query: string): boolean {
const wordBoundaryRegex = new RegExp(`(?:^|[\\s\\-_./:])(${this.escapeRegex(query)})`, "i");
return wordBoundaryRegex.test(text);
}
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
setSelectedIndex(index: number) {
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
}