Initial Commit

This commit is contained in:
2026-01-29 07:13:33 +00:00
commit 399e93f714
34 changed files with 7663 additions and 0 deletions

159
src/tui/components/List.tsx Normal file
View File

@@ -0,0 +1,159 @@
/**
* Selectable list component with keyboard navigation.
*/
import React from 'react';
import { Box, Text, useInput } from 'ink';
import { colors } from '../theme.js';
/**
* List item type.
*/
export interface ListItem<T = unknown> {
/** Unique key for the item */
key: string;
/** Display label */
label: string;
/** Optional secondary text */
description?: string;
/** Optional value associated with item */
value?: T;
/** Whether item is disabled */
disabled?: boolean;
}
/**
* Props for the List component.
*/
interface ListProps<T> {
/** List items */
items: ListItem<T>[];
/** Currently selected index */
selectedIndex: number;
/** Selection change handler */
onSelect: (index: number) => void;
/** Item activation handler (Enter key) */
onActivate?: (item: ListItem<T>, index: number) => void;
/** Whether list is focused */
focus?: boolean;
/** Maximum visible items (for scrolling) */
maxVisible?: number;
/** Optional label */
label?: string;
/** Show border */
border?: boolean;
}
/**
* Selectable list with keyboard navigation.
*/
export function List<T>({
items,
selectedIndex,
onSelect,
onActivate,
focus = true,
maxVisible = 10,
label,
border = true,
}: ListProps<T>): React.ReactElement {
// Handle keyboard input
useInput((input, key) => {
if (!focus) return;
if (key.upArrow || input === 'k') {
const newIndex = selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
onSelect(newIndex);
} else if (key.downArrow || input === 'j') {
const newIndex = selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
onSelect(newIndex);
} else if (key.return && onActivate && items[selectedIndex]) {
const item = items[selectedIndex];
if (item && !item.disabled) {
onActivate(item, selectedIndex);
}
}
}, { isActive: focus });
// Calculate visible range for scrolling
const startIndex = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), items.length - maxVisible));
const visibleItems = items.slice(startIndex, startIndex + maxVisible);
const borderColor = focus ? colors.focus : colors.border;
const content = (
<Box flexDirection="column">
{visibleItems.map((item, visibleIndex) => {
const actualIndex = startIndex + visibleIndex;
const isSelected = actualIndex === selectedIndex;
return (
<Box key={item.key}>
<Text
color={item.disabled ? colors.textMuted : isSelected ? colors.bg : colors.text}
backgroundColor={isSelected ? colors.focus : undefined}
bold={isSelected}
dimColor={item.disabled}
>
{isSelected ? '▸ ' : ' '}
{item.label}
</Text>
{item.description && (
<Text color={colors.textMuted} dimColor> - {item.description}</Text>
)}
</Box>
);
})}
{items.length === 0 && (
<Text color={colors.textMuted} dimColor>No items</Text>
)}
</Box>
);
return (
<Box flexDirection="column">
{label && <Text color={colors.text} bold>{label}</Text>}
{border ? (
<Box
borderStyle="single"
borderColor={borderColor}
paddingX={1}
flexDirection="column"
>
{content}
</Box>
) : content}
{items.length > maxVisible && (
<Text color={colors.textMuted} dimColor>
{startIndex + 1}-{Math.min(startIndex + maxVisible, items.length)} of {items.length}
</Text>
)}
</Box>
);
}
/**
* Simple inline list for displaying items without selection.
*/
interface SimpleListProps {
items: string[];
label?: string;
bullet?: string;
}
export function SimpleList({
items,
label,
bullet = '•'
}: SimpleListProps): React.ReactElement {
return (
<Box flexDirection="column">
{label && <Text color={colors.text} bold>{label}</Text>}
{items.map((item, index) => (
<Text key={index} color={colors.text}>
{bullet} {item}
</Text>
))}
</Box>
);
}