import figures from 'figures'; import React, { type ReactNode, useEffect, useRef, useState } from 'react'; import { Ansi, Box, Text, stringWidth, useDeclaredCursor } from '@anthropic/ink'; import { count } from '../../utils/array.js'; import type { PastedContent } from '../../utils/config.js'; import type { ImageDimensions } from '../../utils/imageResizer.js'; import { SelectInputOption } from './select-input-option.js'; import { SelectOption } from './select-option.js'; import { useSelectInput } from './use-select-input.js'; import { useSelectState } from './use-select-state.js'; // Extract text content from ReactNode for width calculation function getTextContent(node: ReactNode): string { if (typeof node === 'string') return node; if (typeof node === 'number') return String(node); if (!node) return ''; if (Array.isArray(node)) return node.map(getTextContent).join(''); if (React.isValidElement<{ children?: ReactNode }>(node)) { return getTextContent(node.props.children); } return ''; } type BaseOption = { description?: string; dimDescription?: boolean; label: ReactNode; value: T; disabled?: boolean; }; export type OptionWithDescription = | (BaseOption & { type?: 'text'; }) | (BaseOption & { type: 'input'; onChange: (value: string) => void; placeholder?: string; initialValue?: string; /** * Controls behavior when submitting with empty input: * - true: calls onChange (treats empty as valid submission) * - false (default): calls onCancel (treats empty as cancellation) * * Also affects initial Enter press: when true, submits immediately; * when false, enters input mode first so user can type. */ allowEmptySubmitToCancel?: boolean; /** * When true, always shows the label alongside the input value, regardless of * the global inlineDescriptions/showLabel setting. Use this when the label * provides important context that should always be visible (e.g., "Yes, and allow..."). */ showLabelWithValue?: boolean; /** * Custom separator between label and value when showLabel is true. * Defaults to ", ". Use ": " for labels that read better with a colon. */ labelValueSeparator?: string; /** * When true, automatically reset cursor to end of line when: * - Option becomes focused * - Input value changes * This prevents cursor position bugs when the input value updates asynchronously. */ resetCursorOnUpdate?: boolean; }); export type SelectProps = { /** * When disabled, user input is ignored. * * @default false */ readonly isDisabled?: boolean; /** * When true, prevents selection on Enter but allows scrolling. * * @default false */ readonly disableSelection?: boolean; /** * When true, hides the numeric indexes next to each option. * * @default false */ readonly hideIndexes?: boolean; /** * Number of visible options. * * @default 5 */ readonly visibleOptionCount?: number; /** * Highlight text in option labels. */ readonly highlightText?: string; /** * Options. */ readonly options: OptionWithDescription[]; /** * Default value. */ readonly defaultValue?: T; /** * Callback when cancel is pressed. */ readonly onCancel?: () => void; /** * Callback when selected option changes. */ readonly onChange?: (value: T) => void; /** * Callback when focused option changes. * Note: This is for one-way notification only. Avoid combining with focusValue * for bidirectional sync, as this can cause feedback loops. */ readonly onFocus?: (value: T) => void; /** * Initial value to focus. This is used to set focus when the component mounts. */ readonly defaultFocusValue?: T; /** * Layout of the options. * - `compact` (default) tries to use one line per option * - `expanded` uses multiple lines and an empty line between options * - `compact-vertical` uses compact index formatting with descriptions below labels */ readonly layout?: 'compact' | 'expanded' | 'compact-vertical'; /** * When true, descriptions are rendered inline after the label instead of * in a separate column. Use this for short descriptions like hints. * * @default false */ readonly inlineDescriptions?: boolean; /** * Callback when user presses up from the first item. * If provided, navigation will not wrap to the last item. */ readonly onUpFromFirstItem?: () => void; /** * Callback when user presses down from the last item. * If provided, navigation will not wrap to the first item. */ readonly onDownFromLastItem?: () => void; /** * Callback when input mode should be toggled for an option. * Called when Tab is pressed (to enter or exit input mode). */ readonly onInputModeToggle?: (value: T) => void; /** * Callback to open external editor for editing input option values. * When provided, ctrl+g will trigger this callback in input options * with the current value and a setter function to update the internal state. */ readonly onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void; /** * Optional callback when an image is pasted into an input option. */ readonly onImagePaste?: ( base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string, ) => void; /** * Pasted content to display inline in input options. */ readonly pastedContents?: Record; /** * Callback to remove a pasted image by its ID. */ readonly onRemoveImage?: (id: number) => void; }; export function Select({ isDisabled = false, hideIndexes = false, visibleOptionCount = 5, highlightText, options, defaultValue, onCancel, onChange, onFocus, defaultFocusValue, layout = 'compact', disableSelection = false, inlineDescriptions = false, onUpFromFirstItem, onDownFromLastItem, onInputModeToggle, onOpenEditor, onImagePaste, pastedContents, onRemoveImage, }: SelectProps): React.ReactNode { // Image selection mode state const [imagesSelected, setImagesSelected] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); // State for input type options const [inputValues, setInputValues] = useState>(() => { const initialMap = new Map(); options.forEach(option => { if (option.type === 'input' && option.initialValue) { initialMap.set(option.value, option.initialValue); } }); return initialMap; }); // Track the last initialValue we synced, so we can detect user edits const lastInitialValues = useRef>(new Map()); // Sync initialValue changes to inputValues state, but only if user hasn't edited useEffect(() => { for (const option of options) { if (option.type === 'input' && option.initialValue !== undefined) { const lastInitial = lastInitialValues.current.get(option.value) ?? ''; const currentValue = inputValues.get(option.value) ?? ''; const newInitial = option.initialValue; // Only update if: // 1. The initialValue has changed // 2. The user hasn't edited (current value still matches the last initialValue we set) if (newInitial !== lastInitial && currentValue === lastInitial) { setInputValues(prev => { const next = new Map(prev); next.set(option.value, newInitial); return next; }); } // Always track the latest initialValue lastInitialValues.current.set(option.value, newInitial); } } }, [options, inputValues]); const state = useSelectState({ visibleOptionCount, options, defaultValue, onChange, onCancel, onFocus, focusValue: defaultFocusValue, }); useSelectInput({ isDisabled, disableSelection: disableSelection || (hideIndexes ? 'numeric' : false), state, options, isMultiSelect: false, // Select is always single-choice onUpFromFirstItem, onDownFromLastItem, onInputModeToggle, inputValues, imagesSelected, onEnterImageSelection: () => { if (pastedContents && Object.values(pastedContents).some(c => c.type === 'image')) { const imageCount = count(Object.values(pastedContents), c => c.type === 'image'); setImagesSelected(true); setSelectedImageIndex(imageCount - 1); return true; } return false; }, }); const styles = { container: () => ({ flexDirection: 'column' as const }), highlightedText: () => ({ bold: true }), }; if (layout === 'expanded') { const maxIndexWidth = state.options.length.toString().length; return ( {state.visibleOptions.map((option, index) => { const isFirstVisibleOption = option.index === state.visibleFromIndex; const isLastVisibleOption = option.index === state.visibleToIndex - 1; const areMoreOptionsBelow = state.visibleToIndex < options.length; const areMoreOptionsAbove = state.visibleFromIndex > 0; const i = state.visibleFromIndex + index + 1; const isFocused = !isDisabled && state.focusedValue === option.value; const isSelected = state.value === option.value; // Handle input type options if (option.type === 'input') { const inputValue = inputValues.has(option.value) ? inputValues.get(option.value)! : option.initialValue || ''; return ( { setInputValues(prev => { const next = new Map(prev); next.set(option.value, value); return next; }); }} onSubmit={(value: string) => { const hasImageAttachments = pastedContents && Object.values(pastedContents).some(c => c.type === 'image'); if (value.trim() || hasImageAttachments || option.allowEmptySubmitToCancel) { onChange?.(option.value); } else { onCancel?.(); } }} onExit={onCancel} layout="expanded" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} /> ); } // Handle text type options let label: ReactNode = option.label; // Only apply highlight when label is a string if (typeof option.label === 'string' && highlightText && option.label.includes(highlightText)) { const labelText = option.label; const index = labelText.indexOf(highlightText); label = ( <> {labelText.slice(0, index)} {highlightText} {labelText.slice(index + highlightText.length)} ); } const isOptionDisabled = option.disabled === true; const optionColor = isOptionDisabled ? undefined : isSelected ? 'success' : isFocused ? 'suggestion' : undefined; return ( {label} {option.description && ( {option.description} )} ); })} ); } if (layout === 'compact-vertical') { const maxIndexWidth = hideIndexes ? 0 : state.options.length.toString().length; return ( {state.visibleOptions.map((option, index) => { const isFirstVisibleOption = option.index === state.visibleFromIndex; const isLastVisibleOption = option.index === state.visibleToIndex - 1; const areMoreOptionsBelow = state.visibleToIndex < options.length; const areMoreOptionsAbove = state.visibleFromIndex > 0; const i = state.visibleFromIndex + index + 1; const isFocused = !isDisabled && state.focusedValue === option.value; const isSelected = state.value === option.value; // Handle input type options if (option.type === 'input') { const inputValue = inputValues.has(option.value) ? inputValues.get(option.value)! : option.initialValue || ''; return ( { setInputValues(prev => { const next = new Map(prev); next.set(option.value, value); return next; }); }} onSubmit={(value: string) => { const hasImageAttachments = pastedContents && Object.values(pastedContents).some(c => c.type === 'image'); if (value.trim() || hasImageAttachments || option.allowEmptySubmitToCancel) { onChange?.(option.value); } else { onCancel?.(); } }} onExit={onCancel} layout="compact" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} /> ); } // Handle text type options let label: ReactNode = option.label; // Only apply highlight when label is a string if (typeof option.label === 'string' && highlightText && option.label.includes(highlightText)) { const labelText = option.label; const index = labelText.indexOf(highlightText); label = ( <> {labelText.slice(0, index)} {highlightText} {labelText.slice(index + highlightText.length)} ); } const isOptionDisabled = option.disabled === true; return ( <> {!hideIndexes && {`${i}.`.padEnd(maxIndexWidth + 1)}} {label} {option.description && ( {option.description} )} ); })} ); } const maxIndexWidth = hideIndexes ? 0 : state.options.length.toString().length; // Check if any visible options have descriptions (for two-column layout) // Also check that there are NO input options, since they're not supported in two-column layout // Skip two-column layout when inlineDescriptions is enabled const hasInputOptions = state.visibleOptions.some(opt => opt.type === 'input'); const hasDescriptions = !inlineDescriptions && !hasInputOptions && state.visibleOptions.some(opt => opt.description); // Pre-compute option data for two-column layout const optionData = state.visibleOptions.map((option, index) => { const isFirstVisibleOption = option.index === state.visibleFromIndex; const isLastVisibleOption = option.index === state.visibleToIndex - 1; const areMoreOptionsBelow = state.visibleToIndex < options.length; const areMoreOptionsAbove = state.visibleFromIndex > 0; const i = state.visibleFromIndex + index + 1; const isFocused = !isDisabled && state.focusedValue === option.value; const isSelected = state.value === option.value; const isOptionDisabled = option.disabled === true; let label: ReactNode = option.label; if (typeof option.label === 'string' && highlightText && option.label.includes(highlightText)) { const labelText = option.label; const idx = labelText.indexOf(highlightText); label = ( <> {labelText.slice(0, idx)} {highlightText} {labelText.slice(idx + highlightText.length)} ); } return { option, index: i, label, isFocused, isSelected, isOptionDisabled, shouldShowDownArrow: areMoreOptionsBelow && isLastVisibleOption, shouldShowUpArrow: areMoreOptionsAbove && isFirstVisibleOption, }; }); // Calculate max label width for alignment when descriptions exist if (hasDescriptions) { const maxLabelWidth = Math.max( ...optionData.map(data => { if (data.option.type === 'input') return 0; const labelText = getTextContent(data.option.label); // Width: indicator (1) + space (1) + index + label + space + checkmark (1) const indexWidth = hideIndexes ? 0 : maxIndexWidth + 2; const checkmarkWidth = data.isSelected ? 2 : 0; return 2 + indexWidth + stringWidth(labelText) + checkmarkWidth; }), ); return ( {optionData.map(data => { if (data.option.type === 'input') { // Input options not supported in two-column layout return null; } const labelText = getTextContent(data.option.label); const indexWidth = hideIndexes ? 0 : maxIndexWidth + 2; const checkmarkWidth = data.isSelected ? 2 : 0; const currentLabelWidth = 2 + indexWidth + stringWidth(labelText) + checkmarkWidth; const padding = maxLabelWidth - currentLabelWidth; return ( {/* Label part - no gap, handle spacing explicitly */} {data.isFocused ? ( {figures.pointer} ) : data.shouldShowDownArrow ? ( {figures.arrowDown} ) : data.shouldShowUpArrow ? ( {figures.arrowUp} ) : ( )} {!hideIndexes && {`${data.index}.`.padEnd(maxIndexWidth + 2)}} {data.label} {data.isSelected && {figures.tick}} {/* Padding to align descriptions */} {padding > 0 && {' '.repeat(padding)}} {/* Description part */} {data.option.description || ' '} ); })} ); } return ( {state.visibleOptions.map((option, index) => { // Handle input type options if (option.type === 'input') { const inputValue = inputValues.has(option.value) ? inputValues.get(option.value)! : option.initialValue || ''; const isFirstVisibleOption = option.index === state.visibleFromIndex; const isLastVisibleOption = option.index === state.visibleToIndex - 1; const areMoreOptionsBelow = state.visibleToIndex < options.length; const areMoreOptionsAbove = state.visibleFromIndex > 0; const i = state.visibleFromIndex + index + 1; const isFocused = !isDisabled && state.focusedValue === option.value; const isSelected = state.value === option.value; return ( { setInputValues(prev => { const next = new Map(prev); next.set(option.value, value); return next; }); }} onSubmit={(value: string) => { const hasImageAttachments = pastedContents && Object.values(pastedContents).some(c => c.type === 'image'); if (value.trim() || hasImageAttachments || option.allowEmptySubmitToCancel) { onChange?.(option.value); } else { onCancel?.(); } }} onExit={onCancel} layout="compact" showLabel={inlineDescriptions} onOpenEditor={onOpenEditor} resetCursorOnUpdate={option.resetCursorOnUpdate} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} imagesSelected={imagesSelected} selectedImageIndex={selectedImageIndex} onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} /> ); } // Handle text type options let label: ReactNode = option.label; // Only apply highlight when label is a string if (typeof option.label === 'string' && highlightText && option.label.includes(highlightText)) { const labelText = option.label; const index = labelText.indexOf(highlightText); label = ( <> {labelText.slice(0, index)} {highlightText} {labelText.slice(index + highlightText.length)} ); } const isFirstVisibleOption = option.index === state.visibleFromIndex; const isLastVisibleOption = option.index === state.visibleToIndex - 1; const areMoreOptionsBelow = state.visibleToIndex < options.length; const areMoreOptionsAbove = state.visibleFromIndex > 0; const i = state.visibleFromIndex + index + 1; const isFocused = !isDisabled && state.focusedValue === option.value; const isSelected = state.value === option.value; const isOptionDisabled = option.disabled === true; return ( {!hideIndexes && {`${i}.`.padEnd(maxIndexWidth + 2)}} {label} {inlineDescriptions && option.description && ( {option.description} )} {!inlineDescriptions && option.description && ( {option.description} )} ); })} ); } // Row container for the two-column (label + description) layout. Unlike // the other Select layouts, this one doesn't render through SelectOption → // ListItem, so it declares the native cursor directly. Parks the cursor // on the pointer indicator so screen readers / magnifiers track focus. function TwoColumnRow({ isFocused, children }: { isFocused: boolean; children: ReactNode }): React.ReactNode { const cursorRef = useDeclaredCursor({ line: 0, column: 0, active: isFocused, }); return ( {children} ); }