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

View File

@@ -0,0 +1,75 @@
/**
* Button component with focus styling.
*/
import React from 'react';
import { Box, Text, useFocus } from 'ink';
import { colors } from '../theme.js';
/**
* Props for the Button component.
*/
interface ButtonProps {
/** Button label */
label: string;
/** Whether button is focused */
focused?: boolean;
/** Whether button is disabled */
disabled?: boolean;
/** Optional keyboard shortcut hint */
shortcut?: string;
}
/**
* Button component with focus highlighting.
*/
export function Button({
label,
focused = false,
disabled = false,
shortcut,
}: ButtonProps): React.ReactElement {
const bgColor = disabled
? colors.textMuted
: focused
? colors.focus
: colors.secondary;
const textColor = disabled
? colors.bg
: focused
? colors.bg
: colors.text;
return (
<Box>
<Box paddingX={1} marginRight={1}>
<Text
backgroundColor={bgColor}
color={textColor}
bold={focused}
>
{` ${label} `}
</Text>
</Box>
{shortcut && (
<Text color={colors.textMuted} dimColor>({shortcut})</Text>
)}
</Box>
);
}
/**
* Button row component for multiple buttons.
*/
interface ButtonRowProps {
children: React.ReactNode;
}
export function ButtonRow({ children }: ButtonRowProps): React.ReactElement {
return (
<Box marginTop={1} gap={2}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,243 @@
/**
* Dialog components for modals, confirmations, and input dialogs.
*/
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import TextInput from 'ink-text-input';
import { colors } from '../theme.js';
/**
* Base dialog wrapper props.
*/
interface DialogWrapperProps {
/** Dialog title */
title: string;
/** Border color */
borderColor?: string;
/** Dialog content */
children: React.ReactNode;
/** Dialog width */
width?: number;
}
/**
* Base dialog wrapper component.
*/
function DialogWrapper({
title,
borderColor = colors.primary,
children,
width = 60,
}: DialogWrapperProps): React.ReactElement {
return (
<Box
flexDirection="column"
borderStyle="double"
borderColor={borderColor}
paddingX={2}
paddingY={1}
width={width}
>
<Text color={borderColor} bold>{title}</Text>
<Box marginY={1} flexDirection="column">
{children}
</Box>
</Box>
);
}
/**
* Props for InputDialog component.
*/
interface InputDialogProps {
/** Dialog title */
title: string;
/** Input prompt/label */
prompt: string;
/** Initial value */
initialValue?: string;
/** Placeholder text */
placeholder?: string;
/** Submit handler */
onSubmit: (value: string) => void;
/** Cancel handler */
onCancel: () => void;
/** Whether dialog is visible/active */
isActive?: boolean;
}
/**
* Input dialog for getting text input from user.
*/
export function InputDialog({
title,
prompt,
initialValue = '',
placeholder,
onSubmit,
onCancel,
isActive = true,
}: InputDialogProps): React.ReactElement {
const [value, setValue] = useState(initialValue);
useInput((input, key) => {
if (!isActive) return;
if (key.escape) {
onCancel();
}
}, { isActive });
const handleSubmit = (val: string) => {
onSubmit(val);
};
return (
<DialogWrapper title={title} borderColor={colors.primary}>
<Text>{prompt}</Text>
<Box marginTop={1} borderStyle="single" borderColor={colors.focus} paddingX={1}>
<TextInput
value={value}
onChange={setValue}
onSubmit={handleSubmit}
placeholder={placeholder}
focus={isActive}
/>
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>Enter to submit Esc to cancel</Text>
</Box>
</DialogWrapper>
);
}
/**
* Props for ConfirmDialog component.
*/
interface ConfirmDialogProps {
/** Dialog title */
title: string;
/** Confirmation message */
message: string;
/** Confirm handler */
onConfirm: () => void;
/** Cancel handler */
onCancel: () => void;
/** Whether dialog is visible/active */
isActive?: boolean;
/** Confirm button label */
confirmLabel?: string;
/** Cancel button label */
cancelLabel?: string;
}
/**
* Confirmation dialog with Yes/No options.
*/
export function ConfirmDialog({
title,
message,
onConfirm,
onCancel,
isActive = true,
confirmLabel = 'Yes',
cancelLabel = 'No',
}: ConfirmDialogProps): React.ReactElement {
const [selected, setSelected] = useState<'confirm' | 'cancel'>('confirm');
useInput((input, key) => {
if (!isActive) return;
if (key.leftArrow || key.rightArrow || key.tab) {
setSelected(prev => prev === 'confirm' ? 'cancel' : 'confirm');
} else if (key.return) {
if (selected === 'confirm') {
onConfirm();
} else {
onCancel();
}
} else if (key.escape || input === 'n' || input === 'N') {
onCancel();
} else if (input === 'y' || input === 'Y') {
onConfirm();
}
}, { isActive });
return (
<DialogWrapper title={title} borderColor={colors.warning}>
<Text wrap="wrap">{message}</Text>
<Box marginTop={1} gap={2}>
<Text
backgroundColor={selected === 'confirm' ? colors.focus : colors.secondary}
color={selected === 'confirm' ? colors.bg : colors.text}
bold={selected === 'confirm'}
>
{` ${confirmLabel} `}
</Text>
<Text
backgroundColor={selected === 'cancel' ? colors.focus : colors.secondary}
color={selected === 'cancel' ? colors.bg : colors.text}
bold={selected === 'cancel'}
>
{` ${cancelLabel} `}
</Text>
</Box>
<Box marginTop={1}>
<Text color={colors.textMuted}>Y/N or Tab to switch Enter to select</Text>
</Box>
</DialogWrapper>
);
}
/**
* Props for MessageDialog component.
*/
interface MessageDialogProps {
/** Dialog title */
title: string;
/** Message content */
message: string;
/** Close handler */
onClose: () => void;
/** Dialog type for styling */
type?: 'info' | 'error' | 'success';
/** Whether dialog is visible/active */
isActive?: boolean;
}
/**
* Simple message dialog (info, error, success).
*/
export function MessageDialog({
title,
message,
onClose,
type = 'info',
isActive = true,
}: MessageDialogProps): React.ReactElement {
useInput((input, key) => {
if (!isActive) return;
if (key.return || key.escape) {
onClose();
}
}, { isActive });
const borderColor = type === 'error' ? colors.error :
type === 'success' ? colors.success :
colors.info;
const icon = type === 'error' ? '✗' :
type === 'success' ? '✓' :
'';
return (
<DialogWrapper title={`${icon} ${title}`} borderColor={borderColor}>
<Text wrap="wrap">{message}</Text>
<Box marginTop={1}>
<Text color={colors.textMuted}>Press Enter or Esc to close</Text>
</Box>
</DialogWrapper>
);
}

View File

@@ -0,0 +1,109 @@
/**
* Text input component with focus styling.
*/
import React from 'react';
import { Box, Text } from 'ink';
import TextInput from 'ink-text-input';
import { colors } from '../theme.js';
/**
* Props for the Input component.
*/
interface InputProps {
/** Current value */
value: string;
/** Change handler */
onChange: (value: string) => void;
/** Submit handler (Enter key) */
onSubmit?: (value: string) => void;
/** Placeholder text */
placeholder?: string;
/** Label shown above input */
label?: string;
/** Whether input is focused */
focus?: boolean;
/** Whether to mask input (for passwords) */
mask?: string;
/** Whether input is disabled */
disabled?: boolean;
}
/**
* Text input component with label and focus styling.
*/
export function Input({
value,
onChange,
onSubmit,
placeholder,
label,
focus = true,
mask,
disabled = false,
}: InputProps): React.ReactElement {
const borderColor = focus ? colors.focus : colors.border;
return (
<Box flexDirection="column">
{label && (
<Text color={colors.text} bold>{label}</Text>
)}
<Box
borderStyle="single"
borderColor={borderColor}
paddingX={1}
>
{disabled ? (
<Text color={colors.textMuted}>{value || placeholder || ''}</Text>
) : (
<TextInput
value={value}
onChange={onChange}
onSubmit={onSubmit}
placeholder={placeholder}
focus={focus}
mask={mask}
/>
)}
</Box>
</Box>
);
}
/**
* Multi-line text display (read-only, styled like input).
*/
interface TextDisplayProps {
/** Text content */
content: string;
/** Label shown above */
label?: string;
/** Whether to show border */
border?: boolean;
}
export function TextDisplay({
content,
label,
border = true
}: TextDisplayProps): React.ReactElement {
return (
<Box flexDirection="column">
{label && (
<Text color={colors.text} bold>{label}</Text>
)}
{border ? (
<Box
borderStyle="single"
borderColor={colors.border}
paddingX={1}
>
<Text>{content}</Text>
</Box>
) : (
<Text>{content}</Text>
)}
</Box>
);
}

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>
);
}

View File

@@ -0,0 +1,130 @@
/**
* Progress bar and step indicator components.
*/
import React from 'react';
import { Box, Text } from 'ink';
import { colors } from '../theme.js';
/**
* Props for ProgressBar component.
*/
interface ProgressBarProps {
/** Current progress (0-100) */
percent: number;
/** Bar width in characters */
width?: number;
/** Show percentage text */
showPercent?: boolean;
/** Bar character */
character?: string;
}
/**
* Simple progress bar component.
*/
export function ProgressBar({
percent,
width = 40,
showPercent = true,
character = '█',
}: ProgressBarProps): React.ReactElement {
const clampedPercent = Math.max(0, Math.min(100, percent));
const filled = Math.round((clampedPercent / 100) * width);
const empty = width - filled;
return (
<Box>
<Text color={colors.success}>{character.repeat(filled)}</Text>
<Text color={colors.textMuted} dimColor>{'░'.repeat(empty)}</Text>
{showPercent && (
<Text color={colors.text}> {Math.round(clampedPercent)}%</Text>
)}
</Box>
);
}
/**
* Step definition for StepIndicator.
*/
export interface Step {
/** Step label */
label: string;
/** Whether step is completed */
completed?: boolean;
/** Whether step is current */
current?: boolean;
}
/**
* Props for StepIndicator component.
*/
interface StepIndicatorProps {
/** Steps to display */
steps: Step[];
/** Current step index (0-based) */
currentStep: number;
}
/**
* Step indicator showing progress through a multi-step wizard.
*/
export function StepIndicator({
steps,
currentStep,
}: StepIndicatorProps): React.ReactElement {
return (
<Box flexDirection="column" marginBottom={1}>
<Box gap={1}>
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const color = isCompleted ? colors.success :
isCurrent ? colors.focus :
colors.textMuted;
const icon = isCompleted ? '✓' :
isCurrent ? '▸' :
'○';
return (
<Box key={index}>
<Text color={color} bold={isCurrent}>
{icon} {step.label}
</Text>
{index < steps.length - 1 && (
<Text color={colors.textMuted}> </Text>
)}
</Box>
);
})}
</Box>
<Text color={colors.textMuted}>
Step {currentStep + 1} of {steps.length}
</Text>
</Box>
);
}
/**
* Loading spinner with optional message.
*/
interface LoadingProps {
/** Loading message */
message?: string;
}
export function Loading({ message = 'Loading...' }: LoadingProps): React.ReactElement {
// Simple spinner using Ink's spinner component
const Spinner = require('ink-spinner').default;
return (
<Box>
<Text color={colors.primary}>
<Spinner type="dots" />
</Text>
<Text color={colors.text}> {message}</Text>
</Box>
);
}

View File

@@ -0,0 +1,71 @@
/**
* Screen wrapper component providing consistent layout.
*/
import React, { type ReactNode } from 'react';
import { Box, Text } from 'ink';
import { colors } from '../theme.js';
/**
* Props for the Screen component.
*/
interface ScreenProps {
/** Screen title displayed in header */
title: string;
/** Optional subtitle */
subtitle?: string;
/** Screen content */
children: ReactNode;
/** Optional footer content */
footer?: ReactNode;
/** Optional help text shown at bottom */
helpText?: string;
}
/**
* Screen wrapper component.
* Provides consistent header, content area, and footer layout.
*/
export function Screen({
title,
subtitle,
children,
footer,
helpText
}: ScreenProps): React.ReactElement {
return (
<Box flexDirection="column" flexGrow={1}>
{/* Header */}
<Box
borderStyle="single"
borderColor={colors.primary}
paddingX={1}
marginBottom={1}
>
<Box flexDirection="column">
<Text color={colors.primary} bold>{title}</Text>
{subtitle && <Text color={colors.textMuted}>{subtitle}</Text>}
</Box>
</Box>
{/* Content */}
<Box flexDirection="column" flexGrow={1} paddingX={1}>
{children}
</Box>
{/* Help text */}
{helpText && (
<Box paddingX={1} marginTop={1}>
<Text color={colors.textMuted} dimColor>{helpText}</Text>
</Box>
)}
{/* Footer */}
{footer && (
<Box paddingX={1} marginTop={1}>
{footer}
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,10 @@
/**
* Export all shared components.
*/
export { Screen } from './Screen.js';
export { Input, TextDisplay } from './Input.js';
export { Button, ButtonRow } from './Button.js';
export { List, SimpleList, type ListItem } from './List.js';
export { InputDialog, ConfirmDialog, MessageDialog } from './Dialog.js';
export { ProgressBar, StepIndicator, Loading, type Step } from './ProgressBar.js';