From de44e0ad336201ce75a9bd1828f0533203ab339c Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 18 Jan 2026 13:54:08 -0800 Subject: [PATCH] feat(tui): add fuzzy search to model picker - Add SearchableSelectList component with fuzzy filtering - Integrate with /models command for quick model search - Support up/down navigation while typing - Uses pi-tui's fuzzyFilter for intelligent matching --- .../components/searchable-select-list.test.ts | 111 ++++++++++ src/tui/components/searchable-select-list.ts | 199 ++++++++++++++++++ src/tui/components/selectors.ts | 7 +- src/tui/theme/theme.ts | 12 ++ src/tui/tui-command-handlers.ts | 4 +- 5 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 src/tui/components/searchable-select-list.test.ts create mode 100644 src/tui/components/searchable-select-list.ts diff --git a/src/tui/components/searchable-select-list.test.ts b/src/tui/components/searchable-select-list.test.ts new file mode 100644 index 000000000..35b58de17 --- /dev/null +++ b/src/tui/components/searchable-select-list.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { SearchableSelectList, type SearchableSelectListTheme } from "./searchable-select-list.js"; + +const mockTheme: SearchableSelectListTheme = { + selectedPrefix: (t) => `[${t}]`, + selectedText: (t) => `**${t}**`, + description: (t) => `(${t})`, + scrollInfo: (t) => `~${t}~`, + noMatch: (t) => `!${t}!`, + searchPrompt: (t) => `>${t}<`, + searchInput: (t) => `|${t}|`, + matchHighlight: (t) => `*${t}*`, +}; + +const testItems = [ + { value: "anthropic/claude-3-opus", label: "anthropic/claude-3-opus", description: "Claude 3 Opus" }, + { value: "anthropic/claude-3-sonnet", label: "anthropic/claude-3-sonnet", description: "Claude 3 Sonnet" }, + { value: "openai/gpt-4", label: "openai/gpt-4", description: "GPT-4" }, + { value: "openai/gpt-4-turbo", label: "openai/gpt-4-turbo", description: "GPT-4 Turbo" }, + { value: "google/gemini-pro", label: "google/gemini-pro", description: "Gemini Pro" }, +]; + +describe("SearchableSelectList", () => { + it("renders all items when no filter is applied", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + const output = list.render(80); + + // Should have search prompt line, spacer, and items + expect(output.length).toBeGreaterThanOrEqual(3); + expect(output[0]).toContain("search"); + }); + + it("filters items when typing", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Simulate typing "gemini" - unique enough to narrow down + list.handleInput("g"); + list.handleInput("e"); + list.handleInput("m"); + list.handleInput("i"); + list.handleInput("n"); + list.handleInput("i"); + + const selected = list.getSelectedItem(); + expect(selected?.value).toBe("google/gemini-pro"); + }); + + it("filters items with fuzzy matching", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Simulate typing "gpt" which should match openai/gpt-4 models + list.handleInput("g"); + list.handleInput("p"); + list.handleInput("t"); + + const selected = list.getSelectedItem(); + expect(selected?.value).toContain("gpt"); + }); + + it("shows no match message when filter yields no results", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Type something that won't match + list.handleInput("x"); + list.handleInput("y"); + list.handleInput("z"); + + const output = list.render(80); + expect(output.some((line) => line.includes("No matching"))).toBe(true); + }); + + it("navigates with arrow keys", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + + // Initially first item is selected + expect(list.getSelectedItem()?.value).toBe("anthropic/claude-3-opus"); + + // Press down arrow (escape sequence for down arrow) + list.handleInput("\x1b[B"); + + expect(list.getSelectedItem()?.value).toBe("anthropic/claude-3-sonnet"); + }); + + it("calls onSelect when enter is pressed", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + let selectedValue: string | undefined; + + list.onSelect = (item) => { + selectedValue = item.value; + }; + + // Press enter + list.handleInput("\r"); + + expect(selectedValue).toBe("anthropic/claude-3-opus"); + }); + + it("calls onCancel when escape is pressed", () => { + const list = new SearchableSelectList(testItems, 5, mockTheme); + let cancelled = false; + + list.onCancel = () => { + cancelled = true; + }; + + // Press escape + list.handleInput("\x1b"); + + expect(cancelled).toBe(true); + }); +}); diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts new file mode 100644 index 000000000..ea0b5b936 --- /dev/null +++ b/src/tui/components/searchable-select-list.ts @@ -0,0 +1,199 @@ +import { + type Component, + fuzzyFilter, + Input, + isKeyRelease, + matchesKey, + type SelectItem, + type SelectListTheme, + truncateToWidth, +} from "@mariozechner/pi-tui"; + +export interface SearchableSelectListTheme extends SelectListTheme { + searchPrompt: (text: string) => string; + searchInput: (text: string) => string; + matchHighlight: (text: string) => string; +} + +/** + * A select list with a search input at the top for fuzzy filtering. + */ +export class SearchableSelectList implements Component { + private items: SelectItem[]; + private filteredItems: SelectItem[]; + private selectedIndex = 0; + private maxVisible: number; + private theme: SearchableSelectListTheme; + private searchInput: Input; + private searchQuery = ""; + + onSelect?: (item: SelectItem) => void; + onCancel?: () => void; + onSelectionChange?: (item: SelectItem) => void; + + constructor(items: SelectItem[], maxVisible: number, theme: SearchableSelectListTheme) { + this.items = items; + this.filteredItems = items; + this.maxVisible = maxVisible; + this.theme = theme; + this.searchInput = new Input(); + } + + private updateFilter() { + const query = this.searchInput.getValue().trim(); + this.searchQuery = query; + + if (!query) { + this.filteredItems = this.items; + } else { + this.filteredItems = fuzzyFilter(this.items, query, (item) => `${item.label} ${item.description ?? ""}`); + } + + // Reset selection when filter changes + this.selectedIndex = 0; + this.notifySelectionChange(); + } + + setSelectedIndex(index: number) { + this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1)); + } + + invalidate() { + this.searchInput.invalidate(); + } + + render(width: number): string[] { + const lines: string[] = []; + + // Search input line + const prompt = this.theme.searchPrompt("search: "); + const inputLines = this.searchInput.render(width - 8); + const inputText = inputLines[0] ?? ""; + lines.push(`${prompt}${this.theme.searchInput(inputText)}`); + lines.push(""); // Spacer + + // If no items match filter, show message + if (this.filteredItems.length === 0) { + lines.push(this.theme.noMatch(" No matching models")); + return lines; + } + + // Calculate visible range with scrolling + const startIndex = Math.max( + 0, + Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible), + ); + const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length); + + // Render visible items + for (let i = startIndex; i < endIndex; i++) { + const item = this.filteredItems[i]; + if (!item) continue; + const isSelected = i === this.selectedIndex; + let line = ""; + + if (isSelected) { + const prefixWidth = 2; + const displayValue = item.label || item.value; + if (item.description && width > 40) { + const maxValueWidth = Math.min(30, width - prefixWidth - 4); + const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); + const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); + const descriptionStart = prefixWidth + truncatedValue.length + spacing.length; + const remainingWidth = width - descriptionStart - 2; + if (remainingWidth > 10) { + const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); + line = this.theme.selectedText(`→ ${truncatedValue}${spacing}${truncatedDesc}`); + } else { + const maxWidth = width - prefixWidth - 2; + line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`); + } + } else { + const maxWidth = width - prefixWidth - 2; + line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`); + } + } else { + const displayValue = item.label || item.value; + const prefix = " "; + if (item.description && width > 40) { + const maxValueWidth = Math.min(30, width - prefix.length - 4); + const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); + const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length)); + const descriptionStart = prefix.length + truncatedValue.length + spacing.length; + const remainingWidth = width - descriptionStart - 2; + if (remainingWidth > 10) { + const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); + line = `${prefix}${truncatedValue}${spacing}${this.theme.description(truncatedDesc)}`; + } else { + const maxWidth = width - prefix.length - 2; + line = `${prefix}${truncateToWidth(displayValue, maxWidth, "")}`; + } + } else { + const maxWidth = width - prefix.length - 2; + line = `${prefix}${truncateToWidth(displayValue, maxWidth, "")}`; + } + } + lines.push(line); + } + + // Show scroll indicator if needed + if (this.filteredItems.length > this.maxVisible) { + const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`; + lines.push(this.theme.scrollInfo(` ${scrollInfo}`)); + } + + return lines; + } + + handleInput(keyData: string): void { + if (isKeyRelease(keyData)) return; + + // Navigation keys + if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p")) { + this.selectedIndex = Math.max(0, this.selectedIndex - 1); + this.notifySelectionChange(); + return; + } + + if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n")) { + this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1); + this.notifySelectionChange(); + return; + } + + if (matchesKey(keyData, "enter")) { + const item = this.filteredItems[this.selectedIndex]; + if (item && this.onSelect) { + this.onSelect(item); + } + return; + } + + if (matchesKey(keyData, "escape")) { + if (this.onCancel) { + this.onCancel(); + } + return; + } + + // Pass other keys to search input + const prevValue = this.searchInput.getValue(); + this.searchInput.handleInput(keyData); + const newValue = this.searchInput.getValue(); + + if (prevValue !== newValue) { + this.updateFilter(); + } + } + + private notifySelectionChange() { + const item = this.filteredItems[this.selectedIndex]; + if (item && this.onSelectionChange) { + this.onSelectionChange(item); + } + } + + getSelectedItem(): SelectItem | null { + return this.filteredItems[this.selectedIndex] ?? null; + } +} diff --git a/src/tui/components/selectors.ts b/src/tui/components/selectors.ts index f7b01a4ec..f56d24e3b 100644 --- a/src/tui/components/selectors.ts +++ b/src/tui/components/selectors.ts @@ -1,10 +1,15 @@ import { type SelectItem, SelectList, type SettingItem, SettingsList } from "@mariozechner/pi-tui"; -import { selectListTheme, settingsListTheme } from "../theme/theme.js"; +import { searchableSelectListTheme, selectListTheme, settingsListTheme } from "../theme/theme.js"; +import { SearchableSelectList } from "./searchable-select-list.js"; export function createSelectList(items: SelectItem[], maxVisible = 7) { return new SelectList(items, maxVisible, selectListTheme); } +export function createSearchableSelectList(items: SelectItem[], maxVisible = 7) { + return new SearchableSelectList(items, maxVisible, searchableSelectListTheme); +} + export function createSettingsList( items: SettingItem[], onChange: (id: string, value: string) => void, diff --git a/src/tui/theme/theme.ts b/src/tui/theme/theme.ts index 22293f184..b8072fda3 100644 --- a/src/tui/theme/theme.ts +++ b/src/tui/theme/theme.ts @@ -5,6 +5,7 @@ import type { SettingsListTheme, } from "@mariozechner/pi-tui"; import chalk from "chalk"; +import type { SearchableSelectListTheme } from "../components/searchable-select-list.js"; const palette = { text: "#E8E3D5", @@ -92,3 +93,14 @@ export const editorTheme: EditorTheme = { borderColor: (text) => fg(palette.border)(text), selectList: selectListTheme, }; + +export const searchableSelectListTheme: SearchableSelectListTheme = { + selectedPrefix: (text) => fg(palette.accent)(text), + selectedText: (text) => chalk.bold(fg(palette.accent)(text)), + description: (text) => fg(palette.dim)(text), + scrollInfo: (text) => fg(palette.dim)(text), + noMatch: (text) => fg(palette.dim)(text), + searchPrompt: (text) => fg(palette.accentSoft)(text), + searchInput: (text) => fg(palette.text)(text), + matchHighlight: (text) => chalk.bold(fg(palette.accent)(text)), +}; diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 1d5e34eaf..d48094ae7 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -7,7 +7,7 @@ import { import { normalizeAgentId } from "../routing/session-key.js"; import { helpText, parseCommand } from "./commands.js"; import type { ChatLog } from "./components/chat-log.js"; -import { createSelectList, createSettingsList } from "./components/selectors.js"; +import { createSearchableSelectList, createSelectList, createSettingsList } from "./components/selectors.js"; import type { GatewayChatClient } from "./gateway-chat.js"; import { formatStatusSummary } from "./tui-status-summary.js"; import type { @@ -72,7 +72,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { label: `${model.provider}/${model.id}`, description: model.name && model.name !== model.id ? model.name : "", })); - const selector = createSelectList(items, 9); + const selector = createSearchableSelectList(items, 9); selector.onSelect = (item) => { void (async () => { try {