From ff047e5ee11db1a2303559256d007b7210dc58a1 Mon Sep 17 00:00:00 2001 From: Markus Ylisiurunen <8409947+markusylisiurunen@users.noreply.github.com> Date: Fri, 5 Dec 2025 21:33:04 +0200 Subject: [PATCH] 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 --- packages/coding-agent/CHANGELOG.md | 8 ++ packages/coding-agent/src/fuzzy.test.ts | 92 +++++++++++++++++++ packages/coding-agent/src/fuzzy.ts | 83 +++++++++++++++++ .../coding-agent/src/tui/model-selector.ts | 15 +-- .../coding-agent/src/tui/session-selector.ts | 16 +--- packages/tui/README.md | 8 ++ packages/tui/src/components/input.ts | 56 +++++++++++ 7 files changed, 251 insertions(+), 27 deletions(-) create mode 100644 packages/coding-agent/src/fuzzy.test.ts create mode 100644 packages/coding-agent/src/fuzzy.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ee6be520..c7af84bd 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/fuzzy.test.ts b/packages/coding-agent/src/fuzzy.test.ts new file mode 100644 index 00000000..7975bf1c --- /dev/null +++ b/packages/coding-agent/src/fuzzy.test.ts @@ -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"); + }); +}); diff --git a/packages/coding-agent/src/fuzzy.ts b/packages/coding-agent/src/fuzzy.ts new file mode 100644 index 00000000..837c8bb2 --- /dev/null +++ b/packages/coding-agent/src/fuzzy.ts @@ -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(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); +} diff --git a/packages/coding-agent/src/tui/model-selector.ts b/packages/coding-agent/src/tui/model-selector.ts index d84aeb79..c343dfbb 100644 --- a/packages/coding-agent/src/tui/model-selector.ts +++ b/packages/coding-agent/src/tui/model-selector.ts @@ -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(); } diff --git a/packages/coding-agent/src/tui/session-selector.ts b/packages/coding-agent/src/tui/session-selector.ts index b96c114d..fbb47568 100644 --- a/packages/coding-agent/src/tui/session-selector.ts +++ b/packages/coding-agent/src/tui/session-selector.ts @@ -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)); } diff --git a/packages/tui/README.md b/packages/tui/README.md index ef02d244..0ed8f6d3 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -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. diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index f4f11f6a..dbf8c801 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -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, "");