fix: preserve fuzzy ranking in model picker (#1198) (thanks @vignesh07)

This commit is contained in:
Peter Steinberger
2026-01-18 23:26:42 +00:00
parent 950f8a04ea
commit 46dcda1d0c
3 changed files with 39 additions and 24 deletions

View File

@@ -7,6 +7,7 @@ Docs: https://docs.clawd.bot
### Changes ### Changes
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.). - 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. - 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. - Docs: clarify allowlist input types and onboarding behavior for messaging channels.
### Fixes ### Fixes

View File

@@ -92,6 +92,21 @@ describe("SearchableSelectList", () => {
expect(selected?.value).toContain("gpt"); 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", () => { it("shows no match message when filter yields no results", () => {
const list = new SearchableSelectList(testItems, 5, mockTheme); const list = new SearchableSelectList(testItems, 5, mockTheme);

View File

@@ -25,7 +25,6 @@ export class SearchableSelectList implements Component {
private maxVisible: number; private maxVisible: number;
private theme: SearchableSelectListTheme; private theme: SearchableSelectListTheme;
private searchInput: Input; private searchInput: Input;
private searchQuery = "";
onSelect?: (item: SelectItem) => void; onSelect?: (item: SelectItem) => void;
onCancel?: () => void; onCancel?: () => void;
@@ -41,7 +40,6 @@ export class SearchableSelectList implements Component {
private updateFilter() { private updateFilter() {
const query = this.searchInput.getValue().trim(); const query = this.searchInput.getValue().trim();
this.searchQuery = query;
if (!query) { if (!query) {
this.filteredItems = this.items; this.filteredItems = this.items;
@@ -63,45 +61,45 @@ export class SearchableSelectList implements Component {
*/ */
private smartFilter(query: string): SelectItem[] { private smartFilter(query: string): SelectItem[] {
const q = query.toLowerCase(); const q = query.toLowerCase();
type ScoredItem = { item: SelectItem; score: number }; 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) { for (const item of this.items) {
const label = item.label.toLowerCase(); const label = item.label.toLowerCase();
const desc = (item.description ?? "").toLowerCase(); const desc = (item.description ?? "").toLowerCase();
let score = Infinity;
// Tier 1: Exact substring in label (score 0-99) // Tier 1: Exact substring in label (score 0-99)
const labelIndex = label.indexOf(q); const labelIndex = label.indexOf(q);
if (labelIndex !== -1) { if (labelIndex !== -1) {
// Earlier match = better score // Earlier match = better score
score = labelIndex; exactLabel.push({ item, score: labelIndex });
continue;
} }
// Tier 2: Word-boundary prefix in label (score 100-199) // Tier 2: Word-boundary prefix in label (score 100-199)
else if (this.matchesWordBoundary(label, q)) { if (this.matchesWordBoundary(label, q)) {
score = 100; wordBoundary.push(item);
continue;
} }
// Tier 3: Exact substring in description (score 200-299) // Tier 3: Exact substring in description (score 200-299)
else if (desc.indexOf(q) !== -1) { if (desc.indexOf(q) !== -1) {
score = 200; descriptionMatches.push(item);
continue;
} }
// Tier 4: Fuzzy match (score 300+) // Tier 4: Fuzzy match (score 300+)
else { fuzzyCandidates.push(item);
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) exactLabel.sort((a, b) => a.score - b.score);
scored.sort((a, b) => a.score - b.score); const fuzzyMatches = fuzzyFilter(fuzzyCandidates, query, (i) => `${i.label} ${i.description ?? ""}`);
return scored.map((s) => s.item); return [
...exactLabel.map((s) => s.item),
...wordBoundary,
...descriptionMatches,
...fuzzyMatches,
];
} }
/** /**
@@ -130,7 +128,8 @@ export class SearchableSelectList implements Component {
// Search input line // Search input line
const prompt = this.theme.searchPrompt("search: "); 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] ?? ""; const inputText = inputLines[0] ?? "";
lines.push(`${prompt}${this.theme.searchInput(inputText)}`); lines.push(`${prompt}${this.theme.searchInput(inputText)}`);
lines.push(""); // Spacer lines.push(""); // Spacer