fix: polish session picker filtering (#1271) (thanks @Whoaa512)

This commit is contained in:
Peter Steinberger
2026-01-20 16:46:15 +00:00
parent 36719690a2
commit faa5838147
8 changed files with 1623 additions and 1563 deletions

View File

@@ -1,24 +1,24 @@
import {
Input,
matchesKey,
type SelectItem,
SelectList,
type SelectListTheme,
getEditorKeybindings,
Input,
matchesKey,
type SelectItem,
SelectList,
type SelectListTheme,
getEditorKeybindings,
} from "@mariozechner/pi-tui";
import type { Component } from "@mariozechner/pi-tui";
import chalk from "chalk";
import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
export interface FilterableSelectItem extends SelectItem {
/** Additional searchable fields beyond label */
searchText?: string;
/** Pre-computed lowercase search text (label + description + searchText) for filtering */
searchTextLower?: 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;
}
/**
@@ -26,108 +26,118 @@ 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 = prepareSearchItems(items);
this.maxVisible = maxVisible;
this.theme = theme;
this.input = new Input();
this.selectList = new SelectList(this.allItems, maxVisible, theme);
}
constructor(items: FilterableSelectItem[], maxVisible: number, theme: FilterableSelectListTheme) {
this.allItems = prepareSearchItems(items);
this.maxVisible = maxVisible;
this.theme = theme;
this.input = new Input();
this.selectList = new SelectList(this.allItems, maxVisible, theme);
}
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 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);
}
invalidate(): void {
this.input.invalidate();
this.selectList.invalidate();
}
invalidate(): void {
this.input.invalidate();
this.selectList.invalidate();
}
render(width: number): string[] {
const lines: string[] = [];
render(width: number): string[] {
const lines: string[] = [];
// Filter input row
const filterLabel = this.theme.filterLabel("Filter: ");
const inputLines = this.input.render(width - 8);
const inputText = inputLines[0] ?? "";
lines.push(filterLabel + inputText);
// Filter input row
const filterLabel = this.theme.filterLabel("Filter: ");
const inputLines = this.input.render(width - 8);
const inputText = inputLines[0] ?? "";
lines.push(filterLabel + inputText);
// Separator
lines.push(chalk.dim("─".repeat(width)));
// Separator
lines.push(chalk.dim("─".repeat(width)));
// Select list
const listLines = this.selectList.render(width);
lines.push(...listLines);
// Select list
const listLines = this.selectList.render(width);
lines.push(...listLines);
return lines;
}
return lines;
}
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;
}
handleInput(keyData: string): void {
const allowVimNav = !this.filterText.trim();
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") {
this.selectList.handleInput("\x1b[B");
return;
}
// Navigation: arrows, vim j/k, or ctrl+p/ctrl+n
if (
matchesKey(keyData, "up") ||
matchesKey(keyData, "ctrl+p") ||
(allowVimNav && keyData === "k")
) {
this.selectList.handleInput("\x1b[A");
return;
}
// Enter selects
if (matchesKey(keyData, "enter")) {
const selected = this.selectList.getSelectedItem();
if (selected) {
this.onSelect?.(selected);
}
return;
}
if (
matchesKey(keyData, "down") ||
matchesKey(keyData, "ctrl+n") ||
(allowVimNav && keyData === "j")
) {
this.selectList.handleInput("\x1b[B");
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;
}
// Enter selects
if (matchesKey(keyData, "enter")) {
const selected = this.selectList.getSelectedItem();
if (selected) {
this.onSelect?.(selected);
}
return;
}
// All other input goes to filter
const prevValue = this.input.getValue();
this.input.handleInput(keyData);
const newValue = this.input.getValue();
// 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;
}
if (newValue !== prevValue) {
this.filterText = newValue;
this.applyFilter();
}
}
// All other input goes to filter
const prevValue = this.input.getValue();
this.input.handleInput(keyData);
const newValue = this.input.getValue();
getSelectedItem(): SelectItem | null {
return this.selectList.getSelectedItem();
}
if (newValue !== prevValue) {
this.filterText = newValue;
this.applyFilter();
}
}
getFilterText(): string {
return this.filterText;
}
getSelectedItem(): SelectItem | null {
return this.selectList.getSelectedItem();
}
getFilterText(): string {
return this.filterText;
}
}

View File

@@ -1,290 +1,300 @@
import {
type Component,
fuzzyFilter,
getEditorKeybindings,
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";
import { findWordBoundaryIndex } from "./fuzzy-filter.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 = 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 = 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,
];
}
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;
}
const allowVimNav = !this.searchInput.getValue().trim();
if (matchesKey(keyData, "down") || matchesKey(keyData, "ctrl+n") || keyData === "j") {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
this.notifySelectionChange();
return;
}
// Navigation keys
if (
matchesKey(keyData, "up") ||
matchesKey(keyData, "ctrl+p") ||
(allowVimNav && keyData === "k")
) {
this.selectedIndex = Math.max(0, 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, "down") ||
matchesKey(keyData, "ctrl+n") ||
(allowVimNav && keyData === "j")
) {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
this.notifySelectionChange();
return;
}
const kb = getEditorKeybindings();
if (kb.matches(keyData, "selectCancel")) {
if (this.onCancel) {
this.onCancel();
}
return;
}
if (matchesKey(keyData, "enter")) {
const item = this.filteredItems[this.selectedIndex];
if (item && this.onSelect) {
this.onSelect(item);
}
return;
}
// Pass other keys to search input
const prevValue = this.searchInput.getValue();
this.searchInput.handleInput(keyData);
const newValue = this.searchInput.getValue();
const kb = getEditorKeybindings();
if (kb.matches(keyData, "selectCancel")) {
if (this.onCancel) {
this.onCancel();
}
return;
}
if (prevValue !== newValue) {
this.updateFilter();
}
}
// Pass other keys to search input
const prevValue = this.searchInput.getValue();
this.searchInput.handleInput(keyData);
const newValue = this.searchInput.getValue();
private notifySelectionChange() {
const item = this.filteredItems[this.selectedIndex];
if (item && this.onSelectionChange) {
this.onSelectionChange(item);
}
}
if (prevValue !== newValue) {
this.updateFilter();
}
}
getSelectedItem(): SelectItem | null {
return this.filteredItems[this.selectedIndex] ?? null;
}
private notifySelectionChange() {
const item = this.filteredItems[this.selectedIndex];
if (item && this.onSelectionChange) {
this.onSelectionChange(item);
}
}
getSelectedItem(): SelectItem | null {
return this.filteredItems[this.selectedIndex] ?? null;
}
}