Implement fuzzy search for model/session selector and improve Input multi-key sequence handling (#122)

* implement fuzzy search and filtering for tui selectors

* update changelog and readme

* add correct pr to changelog
This commit is contained in:
Markus Ylisiurunen 2025-12-05 21:33:04 +02:00 committed by GitHub
parent ca39e899f4
commit ff047e5ee1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 251 additions and 27 deletions

View file

@ -0,0 +1,92 @@
import { describe, expect, test } from "vitest";
import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.js";
describe("fuzzyMatch", () => {
test("empty query matches everything with score 0", () => {
const result = fuzzyMatch("", "anything");
expect(result.matches).toBe(true);
expect(result.score).toBe(0);
});
test("query longer than text does not match", () => {
const result = fuzzyMatch("longquery", "short");
expect(result.matches).toBe(false);
});
test("exact match has good score", () => {
const result = fuzzyMatch("test", "test");
expect(result.matches).toBe(true);
expect(result.score).toBeLessThan(0); // Should be negative due to consecutive bonuses
});
test("characters must appear in order", () => {
const matchInOrder = fuzzyMatch("abc", "aXbXc");
expect(matchInOrder.matches).toBe(true);
const matchOutOfOrder = fuzzyMatch("abc", "cba");
expect(matchOutOfOrder.matches).toBe(false);
});
test("case insensitive matching", () => {
const result = fuzzyMatch("ABC", "abc");
expect(result.matches).toBe(true);
const result2 = fuzzyMatch("abc", "ABC");
expect(result2.matches).toBe(true);
});
test("consecutive matches score better than scattered matches", () => {
const consecutive = fuzzyMatch("foo", "foobar");
const scattered = fuzzyMatch("foo", "f_o_o_bar");
expect(consecutive.matches).toBe(true);
expect(scattered.matches).toBe(true);
expect(consecutive.score).toBeLessThan(scattered.score);
});
test("word boundary matches score better", () => {
const atBoundary = fuzzyMatch("fb", "foo-bar");
const notAtBoundary = fuzzyMatch("fb", "afbx");
expect(atBoundary.matches).toBe(true);
expect(notAtBoundary.matches).toBe(true);
expect(atBoundary.score).toBeLessThan(notAtBoundary.score);
});
});
describe("fuzzyFilter", () => {
test("empty query returns all items unchanged", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "", (x) => x);
expect(result).toEqual(items);
});
test("filters out non-matching items", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "an", (x) => x);
expect(result).toContain("banana");
expect(result).not.toContain("apple");
expect(result).not.toContain("cherry");
});
test("sorts results by match quality", () => {
const items = ["a_p_p", "app", "application"];
const result = fuzzyFilter(items, "app", (x) => x);
// "app" should be first (exact consecutive match at start)
expect(result[0]).toBe("app");
});
test("works with custom getText function", () => {
const items = [
{ name: "foo", id: 1 },
{ name: "bar", id: 2 },
{ name: "foobar", id: 3 },
];
const result = fuzzyFilter(items, "foo", (item) => item.name);
expect(result.length).toBe(2);
expect(result.map((r) => r.name)).toContain("foo");
expect(result.map((r) => r.name)).toContain("foobar");
});
});

View file

@ -0,0 +1,83 @@
// Fuzzy search. Matches if all query characters appear in order (not necessarily consecutive).
// Lower score = better match.
export interface FuzzyMatch {
matches: boolean;
score: number;
}
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
if (queryLower.length === 0) {
return { matches: true, score: 0 };
}
if (queryLower.length > textLower.length) {
return { matches: false, score: 0 };
}
let queryIndex = 0;
let score = 0;
let lastMatchIndex = -1;
let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && queryIndex < queryLower.length; i++) {
if (textLower[i] === queryLower[queryIndex]) {
const isWordBoundary = i === 0 || /[\s\-_./]/.test(textLower[i - 1]!);
// Reward consecutive character matches (e.g., typing "foo" matches "foobar" better than "f_o_o")
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5;
} else {
consecutiveMatches = 0;
// Penalize gaps between matched characters
if (lastMatchIndex >= 0) {
score += (i - lastMatchIndex - 1) * 2;
}
}
// Reward matches at word boundaries (start of words are more likely intentional targets)
if (isWordBoundary) {
score -= 10;
}
// Slight penalty for matches later in the string (prefer earlier matches)
score += i * 0.1;
lastMatchIndex = i;
queryIndex++;
}
}
// Not all query characters were found in order
if (queryIndex < queryLower.length) {
return { matches: false, score: 0 };
}
return { matches: true, score };
}
// Filter and sort items by fuzzy match quality (best matches first)
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
if (!query.trim()) {
return items;
}
const results: { item: T; score: number }[] = [];
for (const item of items) {
const text = getText(item);
const match = fuzzyMatch(query, text);
if (match.matches) {
results.push({ item, score: match.score });
}
}
// Sort ascending by score (lower = better match)
results.sort((a, b) => a.score - b.score);
return results.map((r) => r.item);
}

View file

@ -1,5 +1,6 @@
import type { Model } from "@mariozechner/pi-ai";
import { Container, Input, Spacer, Text, type TUI } from "@mariozechner/pi-tui";
import { fuzzyFilter } from "../fuzzy.js";
import { getAvailableModels } from "../model-config.js";
import type { SettingsManager } from "../settings-manager.js";
import { theme } from "../theme/theme.js";
@ -114,19 +115,7 @@ export class ModelSelectorComponent extends Container {
}
private filterModels(query: string): void {
if (!query.trim()) {
this.filteredModels = this.allModels;
} else {
const searchTokens = query
.toLowerCase()
.split(/\s+/)
.filter((t) => t);
this.filteredModels = this.allModels.filter(({ provider, id, model }) => {
const searchText = `${provider} ${id} ${model.name}`.toLowerCase();
return searchTokens.every((token) => searchText.includes(token));
});
}
this.filteredModels = fuzzyFilter(this.allModels, query, ({ provider, id }) => `${provider} ${id}`);
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredModels.length - 1));
this.updateList();
}

View file

@ -1,4 +1,5 @@
import { type Component, Container, Input, Spacer, Text, truncateToWidth } from "@mariozechner/pi-tui";
import { fuzzyFilter } from "../fuzzy.js";
import type { SessionManager } from "../session-manager.js";
import { theme } from "../theme/theme.js";
import { DynamicBorder } from "./dynamic-border.js";
@ -42,20 +43,7 @@ class SessionList implements Component {
}
private filterSessions(query: string): void {
if (!query.trim()) {
this.filteredSessions = this.allSessions;
} else {
const searchTokens = query
.toLowerCase()
.split(/\s+/)
.filter((t) => t);
this.filteredSessions = this.allSessions.filter((session) => {
// Search through all messages in the session
const searchText = session.allMessagesText.toLowerCase();
return searchTokens.every((token) => searchText.includes(token));
});
}
this.filteredSessions = fuzzyFilter(this.allSessions, query, (session) => session.allMessagesText);
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredSessions.length - 1));
}