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

@ -6,6 +6,14 @@
- **Footer display**: Token counts now use M suffix for millions (e.g., `10.2M` instead of `10184k`). Context display shortened from `61.3% of 200k` to `61.3%/200k`.
### Fixed
- **Multi-key sequences in inputs**: Inputs like model search now handle multi-key sequences identically to the main prompt editor. ([#122](https://github.com/badlogic/pi-mono/pull/122) by [@markusylisiurunen](https://github.com/markusylisiurunen))
### Added
- **Fuzzy search models and sessions**: Implemented a simple fuzzy search for models and sessions (e.g., `codexmax` now finds `gpt-5.1-codex-max`). ([#122](https://github.com/badlogic/pi-mono/pull/122) by [@markusylisiurunen](https://github.com/markusylisiurunen))
## [0.12.11] - 2025-12-05
### Changed

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

View file

@ -93,6 +93,14 @@ input.onSubmit = (value) => console.log(value);
input.setValue("initial");
```
**Key Bindings:**
- `Enter` - Submit
- `Ctrl+A` / `Ctrl+E` - Line start/end
- `Ctrl+W` or `Option+Backspace` - Delete word backwards
- `Ctrl+U` - Delete to start of line
- `Ctrl+K` - Delete to end of line
- Arrow keys, Backspace, Delete work as expected
### Editor
Multi-line text editor with autocomplete, file completion, and paste handling.

View file

@ -113,6 +113,31 @@ export class Input implements Component {
return;
}
if (data.charCodeAt(0) === 23) {
// Ctrl+W - delete word backwards
this.deleteWordBackwards();
return;
}
if (data === "\x1b\x7f") {
// Option/Alt+Backspace - delete word backwards
this.deleteWordBackwards();
return;
}
if (data.charCodeAt(0) === 21) {
// Ctrl+U - delete from cursor to start of line
this.value = this.value.slice(this.cursor);
this.cursor = 0;
return;
}
if (data.charCodeAt(0) === 11) {
// Ctrl+K - delete from cursor to end of line
this.value = this.value.slice(0, this.cursor);
return;
}
// Regular character input
if (data.length === 1 && data >= " " && data <= "~") {
this.value = this.value.slice(0, this.cursor) + data + this.value.slice(this.cursor);
@ -120,6 +145,37 @@ export class Input implements Component {
}
}
private deleteWordBackwards(): void {
if (this.cursor === 0) {
return;
}
const text = this.value.slice(0, this.cursor);
let deleteFrom = this.cursor;
const isWhitespace = (char: string): boolean => /\s/.test(char);
const isPunctuation = (char: string): boolean => /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/.test(char);
const charBeforeCursor = text[deleteFrom - 1] ?? "";
// If immediately on whitespace or punctuation, delete that single boundary char
if (isWhitespace(charBeforeCursor) || isPunctuation(charBeforeCursor)) {
deleteFrom -= 1;
} else {
// Otherwise, delete a run of non-boundary characters (the "word")
while (deleteFrom > 0) {
const ch = text[deleteFrom - 1] ?? "";
if (isWhitespace(ch) || isPunctuation(ch)) {
break;
}
deleteFrom -= 1;
}
}
this.value = text.slice(0, deleteFrom) + this.value.slice(this.cursor);
this.cursor = deleteFrom;
}
private handlePaste(pastedText: string): void {
// Clean the pasted text - remove newlines and carriage returns
const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "");