import { type Component, fuzzyFilter, Input, isKeyRelease, matchesKey, type SelectItem, type SelectListTheme, truncateToWidth, } from "@mariozechner/pi-tui"; import { visibleWidth } from "../../terminal/ansi.js"; 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; 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(); if (!query) { this.filteredItems = this.items; } else { this.filteredItems = this.smartFilter(query); } // Reset selection when filter changes this.selectedIndex = 0; this.notifySelectionChange(); } /** * Smart filtering that prioritizes: * 1. Exact substring match in label (highest priority) * 2. Word-boundary prefix match in label * 3. Exact substring match in description * 4. Fuzzy match (lowest priority) */ private smartFilter(query: string): SelectItem[] { const q = query.toLowerCase(); type ScoredItem = { item: SelectItem; score: number }; const exactLabel: ScoredItem[] = []; const wordBoundary: ScoredItem[] = []; const descriptionMatches: ScoredItem[] = []; const fuzzyCandidates: SelectItem[] = []; for (const item of this.items) { const label = item.label.toLowerCase(); const desc = (item.description ?? "").toLowerCase(); // Tier 1: Exact substring in label (score 0-99) const labelIndex = label.indexOf(q); if (labelIndex !== -1) { // Earlier match = better score exactLabel.push({ item, score: labelIndex }); continue; } // Tier 2: Word-boundary prefix in label (score 100-199) const wordBoundaryIndex = this.findWordBoundaryIndex(label, q); if (wordBoundaryIndex !== null) { wordBoundary.push({ item, score: wordBoundaryIndex }); continue; } // Tier 3: Exact substring in description (score 200-299) const descIndex = desc.indexOf(q); if (descIndex !== -1) { descriptionMatches.push({ item, score: descIndex }); continue; } // Tier 4: Fuzzy match (score 300+) fuzzyCandidates.push(item); } exactLabel.sort(this.compareByScore); wordBoundary.sort(this.compareByScore); descriptionMatches.sort(this.compareByScore); const fuzzyMatches = fuzzyFilter( fuzzyCandidates, query, (i) => `${i.label} ${i.description ?? ""}`, ); return [ ...exactLabel.map((s) => s.item), ...wordBoundary.map((s) => s.item), ...descriptionMatches.map((s) => s.item), ...fuzzyMatches, ]; } /** * Check if query matches at a word boundary in text. * E.g., "gpt" matches "openai/gpt-4" at the "gpt" word boundary. */ private matchesWordBoundary(text: string, query: string): boolean { return this.findWordBoundaryIndex(text, query) !== null; } private findWordBoundaryIndex(text: string, query: string): number | null { if (!query) return null; const maxIndex = text.length - query.length; if (maxIndex < 0) return null; for (let i = 0; i <= maxIndex; i++) { if (text.startsWith(query, i)) { if (i === 0 || /[\s\-_./:]/.test(text[i - 1] ?? "")) { return i; } } } return null; } private escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } private compareByScore = ( a: { item: SelectItem; score: number }, b: { item: SelectItem; score: number }, ) => { if (a.score !== b.score) return a.score - b.score; return this.getItemLabel(a.item).localeCompare(this.getItemLabel(b.item)); }; private getItemLabel(item: SelectItem): string { return item.label || item.value; } private highlightMatch(text: string, query: string): string { const tokens = query .trim() .split(/\s+/) .map((token) => token.toLowerCase()) .filter((token) => token.length > 0); if (tokens.length === 0) return text; const uniqueTokens = Array.from(new Set(tokens)).sort((a, b) => b.length - a.length); let result = text; for (const token of uniqueTokens) { const regex = new RegExp(this.escapeRegex(token), "gi"); result = result.replace(regex, (match) => this.theme.matchHighlight(match)); } return result; } 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 promptText = "search: "; const prompt = this.theme.searchPrompt(promptText); const inputWidth = Math.max(1, width - visibleWidth(prompt)); const inputLines = this.searchInput.render(inputWidth); const inputText = inputLines[0] ?? ""; lines.push(`${prompt}${this.theme.searchInput(inputText)}`); lines.push(""); // Spacer const query = this.searchInput.getValue().trim(); // 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; lines.push(this.renderItemLine(item, isSelected, width, query)); } // 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; } private renderItemLine( item: SelectItem, isSelected: boolean, width: number, query: string, ): string { const prefix = isSelected ? "→ " : " "; const prefixWidth = prefix.length; const displayValue = this.getItemLabel(item); if (item.description && width > 40) { const maxValueWidth = Math.min(30, width - prefixWidth - 4); const truncatedValue = truncateToWidth(displayValue, maxValueWidth, ""); const valueText = this.highlightMatch(truncatedValue, query); const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText))); const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length; const remainingWidth = width - descriptionStart - 2; if (remainingWidth > 10) { const truncatedDesc = truncateToWidth(item.description, remainingWidth, ""); const descText = isSelected ? this.highlightMatch(truncatedDesc, query) : this.highlightMatch(this.theme.description(truncatedDesc), query); const line = `${prefix}${valueText}${spacing}${descText}`; return isSelected ? this.theme.selectedText(line) : line; } } const maxWidth = width - prefixWidth - 2; const truncatedValue = truncateToWidth(displayValue, maxWidth, ""); const valueText = this.highlightMatch(truncatedValue, query); const line = `${prefix}${valueText}`; return isSelected ? this.theme.selectedText(line) : line; } 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; } }