From 46dcda1d0c56f160575ae00e97fd93a8456094ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 18 Jan 2026 23:26:42 +0000 Subject: [PATCH] fix: preserve fuzzy ranking in model picker (#1198) (thanks @vignesh07) --- CHANGELOG.md | 1 + .../components/searchable-select-list.test.ts | 15 ++++++ src/tui/components/searchable-select-list.ts | 47 +++++++++---------- 3 files changed, 39 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f09fb1dd9..00bbb461d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.clawd.bot ### Changes - Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). - Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. +- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07. - Docs: clarify allowlist input types and onboarding behavior for messaging channels. ### Fixes diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts index 596c07f42..80f5e758b 100644 --- a/src/tui/components/searchable-select-list.test.ts +++ b/src/tui/components/searchable-select-list.test.ts @@ -92,6 +92,21 @@ describe("SearchableSelectList", () => { expect(selected?.value).toContain("gpt"); }); + it("preserves fuzzy ranking when only fuzzy matches exist", () => { + const items = [ + { value: "xg---4", label: "xg---4", description: "Worse fuzzy match" }, + { value: "gpt-4", label: "gpt-4", description: "Better fuzzy match" }, + ]; + const list = new SearchableSelectList(items, 5, mockTheme); + + for (const ch of "g4") { + list.handleInput(ch); + } + + const selected = list.getSelectedItem(); + expect(selected?.value).toBe("gpt-4"); + }); + it("shows no match message when filter yields no results", () => { 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 f84b81cb5..53841acf9 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -25,7 +25,6 @@ export class SearchableSelectList implements Component { private maxVisible: number; private theme: SearchableSelectListTheme; private searchInput: Input; - private searchQuery = ""; onSelect?: (item: SelectItem) => void; onCancel?: () => void; @@ -41,7 +40,6 @@ export class SearchableSelectList implements Component { private updateFilter() { const query = this.searchInput.getValue().trim(); - this.searchQuery = query; if (!query) { this.filteredItems = this.items; @@ -63,45 +61,45 @@ export class SearchableSelectList implements Component { */ private smartFilter(query: string): SelectItem[] { const q = query.toLowerCase(); - type ScoredItem = { item: SelectItem; score: number }; - const scored: ScoredItem[] = []; + const exactLabel: ScoredItem[] = []; + const wordBoundary: SelectItem[] = []; + const descriptionMatches: SelectItem[] = []; + const fuzzyCandidates: SelectItem[] = []; 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; + exactLabel.push({ item, score: labelIndex }); + continue; } // Tier 2: Word-boundary prefix in label (score 100-199) - else if (this.matchesWordBoundary(label, q)) { - score = 100; + if (this.matchesWordBoundary(label, q)) { + wordBoundary.push(item); + continue; } // Tier 3: Exact substring in description (score 200-299) - else if (desc.indexOf(q) !== -1) { - score = 200; + if (desc.indexOf(q) !== -1) { + descriptionMatches.push(item); + continue; } // 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 }); - } + fuzzyCandidates.push(item); } - // Sort by score (lower = better) - scored.sort((a, b) => a.score - b.score); - return scored.map((s) => s.item); + exactLabel.sort((a, b) => a.score - b.score); + const fuzzyMatches = fuzzyFilter(fuzzyCandidates, query, (i) => `${i.label} ${i.description ?? ""}`); + return [ + ...exactLabel.map((s) => s.item), + ...wordBoundary, + ...descriptionMatches, + ...fuzzyMatches, + ]; } /** @@ -130,7 +128,8 @@ export class SearchableSelectList implements Component { // Search input line const prompt = this.theme.searchPrompt("search: "); - const inputLines = this.searchInput.render(width - 8); + const inputWidth = Math.max(1, width - 8); + const inputLines = this.searchInput.render(inputWidth); const inputText = inputLines[0] ?? ""; lines.push(`${prompt}${this.theme.searchInput(inputText)}`); lines.push(""); // Spacer