Files
oh-my-pi/packages/tui/src/components/select-list.ts
T
can1357 42b79bc696 feat(coding-agent): added fuzzy token matching in coding-agent components
- Added fuzzy token matching in agent-dashboard, state-manager, and tree-selector, replacing lowercased checks.
- Added search-query state and fuzzy-filter helpers to hook, oauth, and user-message selectors for query filtering.
- Updated filtered selectors to render match results, status lines, no-match text, and move selection within results.
- Added `overflowSearch` and filter state to `SelectList`, switching overflowing list matching to fuzzy checks.
- Configured `SelectList` input flow and fixed cancel so Escape/Ctrl+C closes lists when no matches exist.
- Updated changelogs and added tests for fuzzy-filter behavior in hook, oauth, user-message, and list selectors.
2026-05-31 07:45:22 +02:00

326 lines
10 KiB
TypeScript

import { fuzzyFilter } from "../fuzzy";
import { getKeybindings } from "../keybindings";
import { extractPrintableText } from "../keys";
import type { SymbolTheme } from "../symbols";
import type { Component } from "../tui";
import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
const PRIMARY_COLUMN_GAP = 2;
const MIN_DESCRIPTION_WIDTH = 10;
function sanitizeSingleLine(text: string): string {
return replaceTabs(text)
.replace(/[\r\n]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));
export interface SelectItem {
value: string;
label: string;
description?: string;
/** Dim hint text shown inline after cursor when this item is selected */
hint?: string;
}
export interface SelectListTheme {
selectedPrefix: (text: string) => string;
selectedText: (text: string) => string;
description: (text: string) => string;
scrollInfo: (text: string) => string;
noMatch: (text: string) => string;
symbols: SymbolTheme;
}
export interface SelectListTruncatePrimaryContext {
text: string;
maxWidth: number;
columnWidth: number;
item: SelectItem;
isSelected: boolean;
}
export interface SelectListLayoutOptions {
minPrimaryColumnWidth?: number;
maxPrimaryColumnWidth?: number;
truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
/** Enable type-to-filter search when the item count exceeds maxVisible. Defaults to true. */
overflowSearch?: boolean;
}
export class SelectList implements Component {
#filteredItems: ReadonlyArray<SelectItem>;
#filterQuery = "";
#selectedIndex: number = 0;
onSelect?: (item: SelectItem) => void;
onCancel?: () => void;
onSelectionChange?: (item: SelectItem) => void;
constructor(
private readonly items: ReadonlyArray<SelectItem>,
private readonly maxVisible: number,
private readonly theme: SelectListTheme,
private readonly layout: SelectListLayoutOptions = {},
) {
this.#filteredItems = items;
}
setFilter(filter: string): void {
this.#setFilter(filter, true);
}
setSelectedIndex(index: number): void {
this.#selectedIndex = Math.max(0, Math.min(index, this.#filteredItems.length - 1));
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
const showSearchStatus = this.#shouldRenderSearchStatus();
// If no items match filter, show message
if (this.#filteredItems.length === 0) {
if (showSearchStatus) {
lines.push(this.#renderStatusLine(width));
}
lines.push(this.theme.noMatch(" No matching items"));
return lines;
}
const primaryColumnWidth = this.#getPrimaryColumnWidth();
// 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;
const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth));
}
// Add scroll/search status when needed
if (startIndex > 0 || endIndex < this.#filteredItems.length || showSearchStatus) {
lines.push(this.#renderStatusLine(width));
}
return lines;
}
handleInput(keyData: string): void {
const kb = getKeybindings();
// Escape or Ctrl+C
if (kb.matches(keyData, "tui.select.cancel")) {
if (this.onCancel) {
this.onCancel();
}
return;
}
if (this.#handleSearchInput(keyData)) {
return;
}
if (this.#filteredItems.length === 0) return;
// Up arrow - wrap to bottom when at top
if (kb.matches(keyData, "tui.select.up")) {
this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
this.#notifySelectionChange();
}
// Down arrow - wrap to top when at bottom
else if (kb.matches(keyData, "tui.select.down")) {
this.#selectedIndex = this.#selectedIndex === this.#filteredItems.length - 1 ? 0 : this.#selectedIndex + 1;
this.#notifySelectionChange();
}
// PageUp - jump up by one visible page
else if (kb.matches(keyData, "tui.select.pageUp")) {
this.#selectedIndex = Math.max(0, this.#selectedIndex - this.maxVisible);
this.#notifySelectionChange();
}
// PageDown - jump down by one visible page
else if (kb.matches(keyData, "tui.select.pageDown")) {
this.#selectedIndex = Math.min(this.#filteredItems.length - 1, this.#selectedIndex + this.maxVisible);
this.#notifySelectionChange();
}
// Enter
else if (kb.matches(keyData, "tui.select.confirm") || keyData === "\n") {
const selectedItem = this.#filteredItems[this.#selectedIndex];
if (selectedItem && this.onSelect) {
this.onSelect(selectedItem);
}
}
}
#renderItem(
item: SelectItem,
isSelected: boolean,
width: number,
descriptionSingleLine: string | undefined,
primaryColumnWidth: number,
): string {
const prefix = isSelected
? `${this.theme.symbols.cursor} `
: padding(visibleWidth(this.theme.symbols.cursor) + 1);
const prefixWidth = visibleWidth(prefix);
if (descriptionSingleLine && width > 40) {
const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);
const truncatedValue = this.#truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);
const truncatedValueWidth = visibleWidth(truncatedValue);
const spacing = padding(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));
const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;
const remainingWidth = width - descriptionStart - 2; // -2 for safety
if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, Ellipsis.Omit);
if (isSelected) {
return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
}
const descText = this.theme.description(spacing + truncatedDesc);
return prefix + truncatedValue + descText;
}
}
const maxWidth = width - prefixWidth - 2;
const truncatedValue = this.#truncatePrimary(item, isSelected, maxWidth, maxWidth);
if (isSelected) {
return this.theme.selectedText(`${prefix}${truncatedValue}`);
}
return prefix + truncatedValue;
}
#getPrimaryColumnWidth(): number {
const { min, max } = this.#getPrimaryColumnBounds();
const widestPrimary = this.#filteredItems.reduce((widest, item) => {
return Math.max(widest, visibleWidth(this.#getDisplayValue(item)) + PRIMARY_COLUMN_GAP);
}, 0);
return clamp(widestPrimary, min, max);
}
#getPrimaryColumnBounds(): { min: number; max: number } {
const rawMin =
this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
const rawMax =
this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
return {
min: Math.max(1, Math.min(rawMin, rawMax)),
max: Math.max(1, Math.max(rawMin, rawMax)),
};
}
#truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {
const displayValue = this.#getDisplayValue(item);
const truncatedValue = this.layout.truncatePrimary
? this.layout.truncatePrimary({
text: displayValue,
maxWidth,
columnWidth,
item,
isSelected,
})
: truncateToWidth(displayValue, maxWidth, Ellipsis.Omit);
return truncateToWidth(truncatedValue, maxWidth, Ellipsis.Omit);
}
#getDisplayValue(item: SelectItem): string {
return sanitizeSingleLine(item.label || item.value);
}
#renderStatusLine(width: number): string {
const selectedCount = this.#filteredItems.length === 0 ? 0 : this.#selectedIndex + 1;
const filteredCount = this.#filteredItems.length;
const count =
this.#filterQuery.trim() && filteredCount !== this.items.length
? `${selectedCount}/${filteredCount} of ${this.items.length}`
: `${selectedCount}/${filteredCount}`;
const query = sanitizeSingleLine(this.#filterQuery);
const searchSuffix = this.#shouldRenderSearchStatus() ? (query ? ` Search: ${query}` : " Type to search") : "";
const statusText = ` (${count})${searchSuffix}`;
return this.theme.scrollInfo(truncateToWidth(statusText, Math.max(1, width - 2), Ellipsis.Omit));
}
#shouldRenderSearchStatus(): boolean {
return (
this.layout.overflowSearch !== false && (this.items.length > this.maxVisible || this.#filterQuery.length > 0)
);
}
#canEditSearch(): boolean {
return this.layout.overflowSearch !== false && this.items.length > this.maxVisible;
}
#handleSearchInput(keyData: string): boolean {
if (!this.#canEditSearch()) return false;
const kb = getKeybindings();
if (kb.matches(keyData, "tui.editor.deleteCharBackward")) {
if (this.#filterQuery.length === 0) return false;
const chars = [...this.#filterQuery];
chars.pop();
this.#setFilter(chars.join(""), true);
return true;
}
const printableText = extractPrintableText(keyData);
if (printableText === undefined) return false;
if (this.#filterQuery.length === 0 && printableText.trim().length === 0) return false;
this.#setFilter(this.#filterQuery + printableText, true);
return true;
}
#setFilter(filter: string, notify: boolean): void {
this.#filterQuery = filter;
this.#filteredItems = filter.trim()
? fuzzyFilter([...this.items], filter, item => this.#getFilterText(item))
: this.items;
this.#selectedIndex = 0;
if (notify) {
this.#notifySelectionChange();
}
}
#getFilterText(item: SelectItem): string {
let text = `${item.label} ${item.value}`;
if (item.description) {
text += ` ${item.description}`;
}
if (item.hint) {
text += ` ${item.hint}`;
}
return sanitizeSingleLine(text);
}
#notifySelectionChange(): void {
const selectedItem = this.#filteredItems[this.#selectedIndex];
if (selectedItem && this.onSelectionChange) {
this.onSelectionChange(selectedItem);
}
}
getSelectedItem(): SelectItem | null {
const item = this.#filteredItems[this.#selectedIndex];
return item || null;
}
}