Download packages/pi-tui/src/components/select-list.ts from SaylorTwift/kimi-code: direct link, hf CLI and curl.
- Browser
- Download file 9.51 kB
-
https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/pi-tui/src/components/select-list.ts
- Command line
-
hf download hf://SaylorTwift/kimi-code/packages/pi-tui/src/components/select-list.ts
-
curl -L -o select-list.ts https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/pi-tui/src/components/select-list.ts
9.51 kB
| import { getKeybindings } from "../keybindings.ts"; | |
| import type { Component, TuiMouseEvent, TuiMouseEventResult } from "../tui.ts"; | |
| import { truncateToWidth, visibleWidth } from "../utils.ts"; | |
| 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; | |
| /** Provider-specific metadata (e.g. autocomplete item markers), passed through unchanged. */ | |
| data?: Record<string, unknown>; | |
| } | |
| 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 mousePressedIndex: number | undefined; | |
| 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, endIndex } = this.getVisibleRange(); | |
| // 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; | |
| } | |
| handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined { | |
| if (this.filteredItems.length === 0) return undefined; | |
| if (event.type === "wheel" && event.wheelDelta) { | |
| const delta = event.wheelDelta < 0 ? -1 : 1; | |
| const previousIndex = this.selectedIndex; | |
| this.selectedIndex = Math.max(0, Math.min(this.filteredItems.length - 1, this.selectedIndex + delta)); | |
| if (this.selectedIndex !== previousIndex) this.notifySelectionChange(); | |
| return { handled: true, render: this.selectedIndex !== previousIndex }; | |
| } | |
| // Hover must not change selection: the visible range is centered on it. | |
| if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined; | |
| const { startIndex, endIndex } = this.getVisibleRange(); | |
| const itemIndex = startIndex + event.y; | |
| if (itemIndex < startIndex || itemIndex >= endIndex) return undefined; | |
| if (event.type === "press") { | |
| this.mousePressedIndex = itemIndex; | |
| if (this.selectedIndex !== itemIndex) { | |
| this.selectedIndex = itemIndex; | |
| this.notifySelectionChange(); | |
| } | |
| return { handled: true, focus: true }; | |
| } | |
| if (event.type === "click") { | |
| const clickedIndex = this.mousePressedIndex ?? itemIndex; | |
| this.mousePressedIndex = undefined; | |
| const changed = this.selectedIndex !== clickedIndex; | |
| this.selectedIndex = clickedIndex; | |
| if (changed) this.notifySelectionChange(); | |
| const selectedItem = this.filteredItems[this.selectedIndex]; | |
| if (selectedItem) this.onSelect?.(selectedItem); | |
| return { handled: true }; | |
| } | |
| return undefined; | |
| } | |
| 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 getVisibleRange(): { startIndex: number; endIndex: number } { | |
| const startIndex = Math.max( | |
| 0, | |
| Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible), | |
| ); | |
| return { | |
| startIndex, | |
| endIndex: Math.min(startIndex + this.maxVisible, this.filteredItems.length), | |
| }; | |
| } | |
| 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; | |
| } | |
| } | |