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

@@ -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);

View File

@@ -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