mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-18 18:03:44 +00:00
184 lines
6.2 KiB
TypeScript
184 lines
6.2 KiB
TypeScript
import { isArrowDown, isArrowUp, isCtrlC, isEnter, isEscape } from "../keys.js";
|
|
import type { Component } from "../tui.js";
|
|
import { truncateToWidth } from "../utils.js";
|
|
|
|
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 class SelectList implements Component {
|
|
private items: SelectItem[] = [];
|
|
private filteredItems: SelectItem[] = [];
|
|
private selectedIndex: number = 0;
|
|
private maxVisible: number = 5;
|
|
private theme: SelectListTheme;
|
|
|
|
public onSelect?: (item: SelectItem) => void;
|
|
public onCancel?: () => void;
|
|
public onSelectionChange?: (item: SelectItem) => void;
|
|
|
|
constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme) {
|
|
this.items = items;
|
|
this.filteredItems = items;
|
|
this.maxVisible = maxVisible;
|
|
this.theme = theme;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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;
|
|
|
|
let line = "";
|
|
if (isSelected) {
|
|
// Use arrow indicator for selection - entire line uses selectedText color
|
|
const prefixWidth = 2; // "→ " is 2 characters visually
|
|
const displayValue = item.label || item.value;
|
|
|
|
if (item.description && width > 40) {
|
|
// Calculate how much space we have for value + description
|
|
const maxValueWidth = Math.min(30, width - prefixWidth - 4);
|
|
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
|
|
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
|
|
|
|
// Calculate remaining space for description using visible widths
|
|
const descriptionStart = prefixWidth + truncatedValue.length + spacing.length;
|
|
const remainingWidth = width - descriptionStart - 2; // -2 for safety
|
|
|
|
if (remainingWidth > 10) {
|
|
const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
|
|
// Apply selectedText to entire line content
|
|
line = this.theme.selectedText(`→ ${truncatedValue}${spacing}${truncatedDesc}`);
|
|
} else {
|
|
// Not enough space for description
|
|
const maxWidth = width - prefixWidth - 2;
|
|
line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`);
|
|
}
|
|
} else {
|
|
// No description or not enough width
|
|
const maxWidth = width - prefixWidth - 2;
|
|
line = this.theme.selectedText(`→ ${truncateToWidth(displayValue, maxWidth, "")}`);
|
|
}
|
|
} else {
|
|
const displayValue = item.label || item.value;
|
|
const prefix = " ";
|
|
|
|
if (item.description && width > 40) {
|
|
// Calculate how much space we have for value + description
|
|
const maxValueWidth = Math.min(30, width - prefix.length - 4);
|
|
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
|
|
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
|
|
|
|
// Calculate remaining space for description
|
|
const descriptionStart = prefix.length + truncatedValue.length + spacing.length;
|
|
const remainingWidth = width - descriptionStart - 2; // -2 for safety
|
|
|
|
if (remainingWidth > 10) {
|
|
const truncatedDesc = truncateToWidth(item.description, remainingWidth, "");
|
|
const descText = this.theme.description(spacing + truncatedDesc);
|
|
line = prefix + truncatedValue + descText;
|
|
} else {
|
|
// Not enough space for description
|
|
const maxWidth = width - prefix.length - 2;
|
|
line = prefix + truncateToWidth(displayValue, maxWidth, "");
|
|
}
|
|
} else {
|
|
// No description or not enough width
|
|
const maxWidth = width - prefix.length - 2;
|
|
line = prefix + truncateToWidth(displayValue, maxWidth, "");
|
|
}
|
|
}
|
|
|
|
lines.push(line);
|
|
}
|
|
|
|
// 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 {
|
|
// Up arrow - wrap to bottom when at top
|
|
if (isArrowUp(keyData)) {
|
|
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
|
|
this.notifySelectionChange();
|
|
}
|
|
// Down arrow - wrap to top when at bottom
|
|
else if (isArrowDown(keyData)) {
|
|
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
|
|
this.notifySelectionChange();
|
|
}
|
|
// Enter
|
|
else if (isEnter(keyData)) {
|
|
const selectedItem = this.filteredItems[this.selectedIndex];
|
|
if (selectedItem && this.onSelect) {
|
|
this.onSelect(selectedItem);
|
|
}
|
|
}
|
|
// Escape or Ctrl+C
|
|
else if (isEscape(keyData) || isCtrlC(keyData)) {
|
|
if (this.onCancel) {
|
|
this.onCancel();
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|