import { getKeybindings } from "../keybindings.js"; import type { Component } from "../tui.js"; import { truncateToWidth, visibleWidth } from "../utils.js"; const DEFAULT_PRIMARY_COLUMN_WIDTH = 32; const PRIMARY_COLUMN_GAP = 2; const MIN_DESCRIPTION_WIDTH = 10; const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/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; } export interface SelectListTheme { selectedPrefix: (text: string) => string; selectedText: (text: string) => string; description: (text: string) => string; scrollInfo: (text: string) => string; noMatch: (text: string) => string; } export interface SelectListTruncatePrimaryContext { text: string; maxWidth: number; columnWidth: number; item: SelectItem; isSelected: boolean; } export interface SelectListLayoutOptions { minPrimaryColumnWidth?: number; maxPrimaryColumnWidth?: number; truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string; } export class SelectList implements Component { private items: SelectItem[] = []; private filteredItems: SelectItem[] = []; private selectedIndex: number = 0; private maxVisible: number = 5; private theme: SelectListTheme; private layout: SelectListLayoutOptions; public onSelect?: (item: SelectItem) => void; public onCancel?: () => void; public onSelectionChange?: (item: SelectItem) => void; constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) { this.items = items; this.filteredItems = items; this.maxVisible = maxVisible; this.theme = theme; this.layout = layout; } setFilter(filter: string): void { this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase())); // Reset selection when filter changes this.selectedIndex = 0; } 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[] = []; // If no items match filter, show message if (this.filteredItems.length === 0) { lines.push(this.theme.noMatch(" No matching commands")); 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 descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined; lines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth)); } // Add scroll indicators if needed if (startIndex > 0 || endIndex < this.filteredItems.length) { const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`; // Truncate if too long for terminal lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, ""))); } return lines; } handleInput(keyData: string): void { const kb = getKeybindings(); // 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(); } // Enter else if (kb.matches(keyData, "tui.select.confirm")) { const selectedItem = this.filteredItems[this.selectedIndex]; if (selectedItem && this.onSelect) { this.onSelect(selectedItem); } } // Escape or Ctrl+C else if (kb.matches(keyData, "tui.select.cancel")) { if (this.onCancel) { this.onCancel(); } } } private renderItem( item: SelectItem, isSelected: boolean, width: number, descriptionSingleLine: string | undefined, primaryColumnWidth: number, ): string { const prefix = isSelected ? "→ " : " "; 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 = " ".repeat(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, ""); 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; } private 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); } private 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)), }; } private 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, ""); return truncateToWidth(truncatedValue, maxWidth, ""); } private getDisplayValue(item: SelectItem): string { return item.label || item.value; } private 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; } }