TUI: optimize fuzzy filtering and consolidate time formatting
- Extract formatRelativeTime to shared utility for reuse across components - Optimize FilterableSelectList with pre-lowercased searchTextLower field (avoids toLowerCase on every keystroke) - Implement custom fuzzy matching with space-separated token support and word boundary scoring - Use matchesKey utility for consistent keybinding handling (arrows, vim j/k, ctrl+p/n) - Fix searchable-select-list to support vim keybindings consistently - Fix system-prompt runtimeInfo null check with nullish coalescing operator
This commit is contained in:
committed by
Peter Steinberger
parent
1d9d5b30ce
commit
a28c271488
@@ -1,21 +1,89 @@
|
||||
import {
|
||||
Input,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
type SelectListTheme,
|
||||
fuzzyFilter,
|
||||
getEditorKeybindings,
|
||||
Input,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
SelectList,
|
||||
type SelectListTheme,
|
||||
getEditorKeybindings,
|
||||
} from "@mariozechner/pi-tui";
|
||||
import type { Component } from "@mariozechner/pi-tui";
|
||||
import chalk from "chalk";
|
||||
|
||||
/**
|
||||
* Fuzzy match with pre-lowercased inputs (avoids toLowerCase on every keystroke).
|
||||
* Returns score (lower = better) or null if no match.
|
||||
*/
|
||||
function fuzzyMatchLower(queryLower: string, textLower: string): number | null {
|
||||
if (queryLower.length === 0) return 0;
|
||||
if (queryLower.length > textLower.length) return null;
|
||||
|
||||
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]);
|
||||
if (lastMatchIndex === i - 1) {
|
||||
consecutiveMatches++;
|
||||
score -= consecutiveMatches * 5;
|
||||
} else {
|
||||
consecutiveMatches = 0;
|
||||
if (lastMatchIndex >= 0) score += (i - lastMatchIndex - 1) * 2;
|
||||
}
|
||||
if (isWordBoundary) score -= 10;
|
||||
score += i * 0.1;
|
||||
lastMatchIndex = i;
|
||||
queryIndex++;
|
||||
}
|
||||
}
|
||||
return queryIndex < queryLower.length ? null : score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter items using pre-lowercased searchTextLower field.
|
||||
* Supports space-separated tokens (all must match).
|
||||
*/
|
||||
function fuzzyFilterLower<T extends { searchTextLower?: string }>(
|
||||
items: T[],
|
||||
queryLower: string,
|
||||
): T[] {
|
||||
const trimmed = queryLower.trim();
|
||||
if (!trimmed) return items;
|
||||
|
||||
const tokens = trimmed.split(/\s+/).filter((t) => t.length > 0);
|
||||
if (tokens.length === 0) return items;
|
||||
|
||||
const results: { item: T; score: number }[] = [];
|
||||
for (const item of items) {
|
||||
const text = item.searchTextLower ?? "";
|
||||
let totalScore = 0;
|
||||
let allMatch = true;
|
||||
for (const token of tokens) {
|
||||
const score = fuzzyMatchLower(token, text);
|
||||
if (score !== null) {
|
||||
totalScore += score;
|
||||
} else {
|
||||
allMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allMatch) results.push({ item, score: totalScore });
|
||||
}
|
||||
results.sort((a, b) => a.score - b.score);
|
||||
return results.map((r) => r.item);
|
||||
}
|
||||
|
||||
export interface FilterableSelectItem extends SelectItem {
|
||||
/** Additional searchable fields beyond label */
|
||||
searchText?: string;
|
||||
/** Additional searchable fields beyond label */
|
||||
searchText?: string;
|
||||
/** Pre-computed lowercase search text (label + description + searchText) for filtering */
|
||||
searchTextLower?: string;
|
||||
}
|
||||
|
||||
export interface FilterableSelectListTheme extends SelectListTheme {
|
||||
filterLabel: (text: string) => string;
|
||||
filterLabel: (text: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -23,121 +91,116 @@ export interface FilterableSelectListTheme extends SelectListTheme {
|
||||
* User types to filter, arrows/j/k to navigate, Enter to select, Escape to clear/cancel.
|
||||
*/
|
||||
export class FilterableSelectList implements Component {
|
||||
private input: Input;
|
||||
private selectList: SelectList;
|
||||
private allItems: FilterableSelectItem[];
|
||||
private maxVisible: number;
|
||||
private theme: FilterableSelectListTheme;
|
||||
private filterText = "";
|
||||
private input: Input;
|
||||
private selectList: SelectList;
|
||||
private allItems: FilterableSelectItem[];
|
||||
private maxVisible: number;
|
||||
private theme: FilterableSelectListTheme;
|
||||
private filterText = "";
|
||||
|
||||
onSelect?: (item: SelectItem) => void;
|
||||
onCancel?: () => void;
|
||||
onSelect?: (item: SelectItem) => void;
|
||||
onCancel?: () => void;
|
||||
|
||||
constructor(
|
||||
items: FilterableSelectItem[],
|
||||
maxVisible: number,
|
||||
theme: FilterableSelectListTheme,
|
||||
) {
|
||||
this.allItems = items;
|
||||
this.maxVisible = maxVisible;
|
||||
this.theme = theme;
|
||||
constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
|
||||
// Pre-compute searchTextLower for each item once
|
||||
this.allItems = items.map((item) => {
|
||||
if (item.searchTextLower) return item;
|
||||
const parts = [item.label];
|
||||
if (item.description) parts.push(item.description);
|
||||
if (item.searchText) parts.push(item.searchText);
|
||||
return { ...item, searchTextLower: parts.join(" ").toLowerCase() };
|
||||
});
|
||||
this.maxVisible = maxVisible;
|
||||
this.theme = theme;
|
||||
|
||||
this.input = new Input();
|
||||
this.selectList = new SelectList(items, maxVisible, theme);
|
||||
}
|
||||
this.input = new Input();
|
||||
this.selectList = new SelectList(this.allItems, maxVisible, theme);
|
||||
}
|
||||
|
||||
private getSearchText(item: FilterableSelectItem): string {
|
||||
const parts = [item.label];
|
||||
if (item.description) parts.push(item.description);
|
||||
if (item.searchText) parts.push(item.searchText);
|
||||
return parts.join(" ");
|
||||
}
|
||||
private applyFilter(): void {
|
||||
const queryLower = this.filterText.toLowerCase();
|
||||
if (!queryLower.trim()) {
|
||||
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
|
||||
return;
|
||||
}
|
||||
const filtered = fuzzyFilterLower(this.allItems, queryLower);
|
||||
this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
|
||||
}
|
||||
|
||||
private applyFilter(): void {
|
||||
const query = this.filterText.toLowerCase().trim();
|
||||
if (!query) {
|
||||
this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme);
|
||||
return;
|
||||
}
|
||||
invalidate(): void {
|
||||
this.input.invalidate();
|
||||
this.selectList.invalidate();
|
||||
}
|
||||
|
||||
const filtered = fuzzyFilter(this.allItems, query, (item) =>
|
||||
this.getSearchText(item),
|
||||
);
|
||||
this.selectList = new SelectList(filtered, this.maxVisible, this.theme);
|
||||
}
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
invalidate(): void {
|
||||
this.input.invalidate();
|
||||
this.selectList.invalidate();
|
||||
}
|
||||
// Filter input row
|
||||
const filterLabel = this.theme.filterLabel("Filter: ");
|
||||
const inputLines = this.input.render(width - 8);
|
||||
const inputText = inputLines[0] ?? "";
|
||||
lines.push(filterLabel + inputText);
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
// Separator
|
||||
lines.push(chalk.dim("─".repeat(width)));
|
||||
|
||||
// Filter input row
|
||||
const filterLabel = this.theme.filterLabel("Filter: ");
|
||||
const inputLines = this.input.render(width - 8);
|
||||
const inputText = inputLines[0] ?? "";
|
||||
lines.push(filterLabel + inputText);
|
||||
// Select list
|
||||
const listLines = this.selectList.render(width);
|
||||
lines.push(...listLines);
|
||||
|
||||
// Separator
|
||||
lines.push(chalk.dim("─".repeat(width)));
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Select list
|
||||
const listLines = this.selectList.render(width);
|
||||
lines.push(...listLines);
|
||||
handleInput(keyData: string): void {
|
||||
// Navigation: arrows, vim j/k, or ctrl+p/ctrl+n
|
||||
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
|
||||
this.selectList.handleInput("\x1b[A");
|
||||
return;
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") {
|
||||
this.selectList.handleInput("\x1b[B");
|
||||
return;
|
||||
}
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
// Navigation keys go to select list
|
||||
if (keyData === "\x1b[A" || keyData === "\x1b[B" || keyData === "k" || keyData === "j") {
|
||||
// Map vim keys to arrows for selectList
|
||||
if (keyData === "k") keyData = "\x1b[A";
|
||||
if (keyData === "j") keyData = "\x1b[B";
|
||||
this.selectList.handleInput(keyData);
|
||||
return;
|
||||
}
|
||||
// Enter selects
|
||||
if (matchesKey(keyData, "enter")) {
|
||||
const selected = this.selectList.getSelectedItem();
|
||||
if (selected) {
|
||||
this.onSelect?.(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter selects
|
||||
if (keyData === "\r" || keyData === "\n") {
|
||||
const selected = this.selectList.getSelectedItem();
|
||||
if (selected) {
|
||||
this.onSelect?.(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Escape: clear filter or cancel
|
||||
const kb = getEditorKeybindings();
|
||||
if (kb.matches(keyData, "selectCancel")) {
|
||||
if (this.filterText) {
|
||||
this.filterText = "";
|
||||
this.input.setValue("");
|
||||
this.applyFilter();
|
||||
} else {
|
||||
this.onCancel?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: clear filter or cancel
|
||||
const kb = getEditorKeybindings();
|
||||
if (kb.matches(keyData, "selectCancel")) {
|
||||
if (this.filterText) {
|
||||
this.filterText = "";
|
||||
this.input.setValue("");
|
||||
this.applyFilter();
|
||||
} else {
|
||||
this.onCancel?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// All other input goes to filter
|
||||
const prevValue = this.input.getValue();
|
||||
this.input.handleInput(keyData);
|
||||
const newValue = this.input.getValue();
|
||||
|
||||
// All other input goes to filter
|
||||
const prevValue = this.input.getValue();
|
||||
this.input.handleInput(keyData);
|
||||
const newValue = this.input.getValue();
|
||||
if (newValue !== prevValue) {
|
||||
this.filterText = newValue;
|
||||
this.applyFilter();
|
||||
}
|
||||
}
|
||||
|
||||
if (newValue !== prevValue) {
|
||||
this.filterText = newValue;
|
||||
this.applyFilter();
|
||||
}
|
||||
}
|
||||
getSelectedItem(): SelectItem | null {
|
||||
return this.selectList.getSelectedItem();
|
||||
}
|
||||
|
||||
getSelectedItem(): SelectItem | null {
|
||||
return this.selectList.getSelectedItem();
|
||||
}
|
||||
|
||||
getFilterText(): string {
|
||||
return this.filterText;
|
||||
}
|
||||
getFilterText(): string {
|
||||
return this.filterText;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,309 +1,303 @@
|
||||
import {
|
||||
type Component,
|
||||
fuzzyFilter,
|
||||
Input,
|
||||
isKeyRelease,
|
||||
matchesKey,
|
||||
type SelectItem,
|
||||
type SelectListTheme,
|
||||
truncateToWidth,
|
||||
type Component,
|
||||
fuzzyFilter,
|
||||
getEditorKeybindings,
|
||||
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;
|
||||
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 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;
|
||||
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();
|
||||
}
|
||||
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();
|
||||
private updateFilter() {
|
||||
const query = this.searchInput.getValue().trim();
|
||||
|
||||
if (!query) {
|
||||
this.filteredItems = this.items;
|
||||
} else {
|
||||
this.filteredItems = this.smartFilter(query);
|
||||
}
|
||||
if (!query) {
|
||||
this.filteredItems = this.items;
|
||||
} else {
|
||||
this.filteredItems = this.smartFilter(query);
|
||||
}
|
||||
|
||||
// Reset selection when filter changes
|
||||
this.selectedIndex = 0;
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
// 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[] = [];
|
||||
/**
|
||||
* 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();
|
||||
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);
|
||||
}
|
||||
// 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,
|
||||
];
|
||||
}
|
||||
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 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 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 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 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
setSelectedIndex(index: number) {
|
||||
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
|
||||
}
|
||||
invalidate() {
|
||||
this.searchInput.invalidate();
|
||||
}
|
||||
|
||||
invalidate() {
|
||||
this.searchInput.invalidate();
|
||||
}
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
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
|
||||
|
||||
// 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();
|
||||
|
||||
const query = this.searchInput.getValue().trim();
|
||||
// If no items match filter, show message
|
||||
if (this.filteredItems.length === 0) {
|
||||
lines.push(this.theme.noMatch(" No matches"));
|
||||
return lines;
|
||||
}
|
||||
|
||||
// If no items match filter, show message
|
||||
if (this.filteredItems.length === 0) {
|
||||
lines.push(this.theme.noMatch(" No matches"));
|
||||
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);
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 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}`));
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
handleInput(keyData: string): void {
|
||||
if (isKeyRelease(keyData)) return;
|
||||
// Navigation keys
|
||||
if (matchesKey(keyData, "up") || matchesKey(keyData, "ctrl+p") || keyData === "k") {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
this.notifySelectionChange();
|
||||
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") || keyData === "j") {
|
||||
this.selectedIndex = Math.min(this.filteredItems.length - 1, 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, "enter")) {
|
||||
const item = this.filteredItems[this.selectedIndex];
|
||||
if (item && this.onSelect) {
|
||||
this.onSelect(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const kb = getEditorKeybindings();
|
||||
if (kb.matches(keyData, "selectCancel")) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
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();
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
if (prevValue !== newValue) {
|
||||
this.updateFilter();
|
||||
}
|
||||
}
|
||||
private notifySelectionChange() {
|
||||
const item = this.filteredItems[this.selectedIndex];
|
||||
if (item && this.onSelectionChange) {
|
||||
this.onSelectionChange(item);
|
||||
}
|
||||
}
|
||||
|
||||
private notifySelectionChange() {
|
||||
const item = this.filteredItems[this.selectedIndex];
|
||||
if (item && this.onSelectionChange) {
|
||||
this.onSelectionChange(item);
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedItem(): SelectItem | null {
|
||||
return this.filteredItems[this.selectedIndex] ?? null;
|
||||
}
|
||||
getSelectedItem(): SelectItem | null {
|
||||
return this.filteredItems[this.selectedIndex] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user