mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 06:04:40 +00:00
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:
parent
ca39e899f4
commit
ff047e5ee1
7 changed files with 251 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
92
packages/coding-agent/src/fuzzy.test.ts
Normal file
92
packages/coding-agent/src/fuzzy.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
83
packages/coding-agent/src/fuzzy.ts
Normal file
83
packages/coding-agent/src/fuzzy.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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, "");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue