From 950f8a04ea550a51c98719a04fc8929217353dff Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 18 Jan 2026 14:01:27 -0800 Subject: [PATCH] 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. --- .../components/searchable-select-list.test.ts | 35 ++++++++++ src/tui/components/searchable-select-list.ts | 65 ++++++++++++++++++- 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts index 35b58de17..596c07f42 100644 --- a/src/tui/components/searchable-select-list.test.ts +++ b/src/tui/components/searchable-select-list.test.ts @@ -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); diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index ea0b5b936..f84b81cb5 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -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)); }