claude-code-best/src/components/CustomSelect/select.tsx
2026-05-01 21:39:30 +08:00

808 lines
28 KiB
TypeScript

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<T> = {
description?: string;
dimDescription?: boolean;
label: ReactNode;
value: T;
disabled?: boolean;
};
export type OptionWithDescription<T = string> =
| (BaseOption<T> & {
type?: 'text';
})
| (BaseOption<T> & {
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<T> = {
/**
* 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<T>[];
/**
* 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<number, PastedContent>;
/**
* Callback to remove a pasted image by its ID.
*/
readonly onRemoveImage?: (id: number) => void;
};
export function Select<T>({
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<T>): 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<Map<T, string>>(() => {
const initialMap = new Map<T, string>();
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<Map<T, string>>(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 (
<Box {...styles.container()}>
{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 (
<SelectInputOption
key={String(option.value)}
option={option}
isFocused={isFocused}
isSelected={isSelected}
shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption}
shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption}
maxIndexWidth={maxIndexWidth}
index={i}
inputValue={inputValue}
onInputChange={value => {
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)}
<Text {...styles.highlightedText()}>{highlightText}</Text>
{labelText.slice(index + highlightText.length)}
</>
);
}
const isOptionDisabled = option.disabled === true;
const optionColor = isOptionDisabled
? undefined
: isSelected
? 'success'
: isFocused
? 'suggestion'
: undefined;
return (
<Box key={String(option.value)} flexDirection="column" flexShrink={0}>
<SelectOption
isFocused={isFocused}
isSelected={isSelected}
shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption}
shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption}
>
<Text dimColor={isOptionDisabled} color={optionColor}>
{label}
</Text>
</SelectOption>
{option.description && (
<Box paddingLeft={2}>
<Text dimColor={isOptionDisabled || option.dimDescription !== false} color={optionColor}>
<Ansi>{option.description}</Ansi>
</Text>
</Box>
)}
<Text> </Text>
</Box>
);
})}
</Box>
);
}
if (layout === 'compact-vertical') {
const maxIndexWidth = hideIndexes ? 0 : state.options.length.toString().length;
return (
<Box {...styles.container()}>
{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 (
<SelectInputOption
key={String(option.value)}
option={option}
isFocused={isFocused}
isSelected={isSelected}
shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption}
shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption}
maxIndexWidth={maxIndexWidth}
index={i}
inputValue={inputValue}
onInputChange={value => {
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)}
<Text {...styles.highlightedText()}>{highlightText}</Text>
{labelText.slice(index + highlightText.length)}
</>
);
}
const isOptionDisabled = option.disabled === true;
return (
<Box key={String(option.value)} flexDirection="column" flexShrink={0}>
<SelectOption
isFocused={isFocused}
isSelected={isSelected}
shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption}
shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption}
>
<>
{!hideIndexes && <Text dimColor>{`${i}.`.padEnd(maxIndexWidth + 1)}</Text>}
<Text
dimColor={isOptionDisabled}
color={isOptionDisabled ? undefined : isSelected ? 'success' : isFocused ? 'suggestion' : undefined}
>
{label}
</Text>
</>
</SelectOption>
{option.description && (
<Box paddingLeft={hideIndexes ? 4 : maxIndexWidth + 4}>
<Text
dimColor={isOptionDisabled || option.dimDescription !== false}
color={isOptionDisabled ? undefined : isSelected ? 'success' : isFocused ? 'suggestion' : undefined}
>
<Ansi>{option.description}</Ansi>
</Text>
</Box>
)}
</Box>
);
})}
</Box>
);
}
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)}
<Text {...styles.highlightedText()}>{highlightText}</Text>
{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 (
<Box {...styles.container()}>
{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 (
<TwoColumnRow key={String(data.option.value)} isFocused={data.isFocused}>
{/* Label part - no gap, handle spacing explicitly */}
<Box flexDirection="row" flexShrink={0}>
{data.isFocused ? (
<Text color="suggestion">{figures.pointer}</Text>
) : data.shouldShowDownArrow ? (
<Text dimColor>{figures.arrowDown}</Text>
) : data.shouldShowUpArrow ? (
<Text dimColor>{figures.arrowUp}</Text>
) : (
<Text> </Text>
)}
<Text> </Text>
<Text
dimColor={data.isOptionDisabled}
color={
data.isOptionDisabled
? undefined
: data.isSelected
? 'success'
: data.isFocused
? 'suggestion'
: undefined
}
>
{!hideIndexes && <Text dimColor>{`${data.index}.`.padEnd(maxIndexWidth + 2)}</Text>}
{data.label}
</Text>
{data.isSelected && <Text color="success"> {figures.tick}</Text>}
{/* Padding to align descriptions */}
{padding > 0 && <Text>{' '.repeat(padding)}</Text>}
</Box>
{/* Description part */}
<Box flexGrow={1} marginLeft={2}>
<Text
wrap="wrap"
dimColor={data.isOptionDisabled || data.option.dimDescription !== false}
color={
data.isOptionDisabled
? undefined
: data.isSelected
? 'success'
: data.isFocused
? 'suggestion'
: undefined
}
>
<Ansi>{data.option.description || ' '}</Ansi>
</Text>
</Box>
</TwoColumnRow>
);
})}
</Box>
);
}
return (
<Box {...styles.container()}>
{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 (
<SelectInputOption
key={String(option.value)}
option={option}
isFocused={isFocused}
isSelected={isSelected}
shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption}
shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption}
maxIndexWidth={maxIndexWidth}
index={i}
inputValue={inputValue}
onInputChange={value => {
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)}
<Text {...styles.highlightedText()}>{highlightText}</Text>
{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 (
<SelectOption
key={String(option.value)}
isFocused={isFocused}
isSelected={isSelected}
shouldShowDownArrow={areMoreOptionsBelow && isLastVisibleOption}
shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption}
>
<Box flexDirection="row" flexShrink={0}>
{!hideIndexes && <Text dimColor>{`${i}.`.padEnd(maxIndexWidth + 2)}</Text>}
<Text
dimColor={isOptionDisabled}
color={isOptionDisabled ? undefined : isSelected ? 'success' : isFocused ? 'suggestion' : undefined}
>
{label}
{inlineDescriptions && option.description && (
<Text dimColor={isOptionDisabled || option.dimDescription !== false}> {option.description}</Text>
)}
</Text>
</Box>
{!inlineDescriptions && option.description && (
<Box flexShrink={99} marginLeft={2}>
<Text
wrap="wrap-trim"
dimColor={isOptionDisabled || option.dimDescription !== false}
color={isOptionDisabled ? undefined : isSelected ? 'success' : isFocused ? 'suggestion' : undefined}
>
<Ansi>{option.description}</Ansi>
</Text>
</Box>
)}
</SelectOption>
);
})}
</Box>
);
}
// 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 (
<Box ref={cursorRef} flexDirection="row">
{children}
</Box>
);
}