import React, { type ReactNode, useEffect, useRef, useState } from 'react'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- UP arrow exit not in Attachments bindings import { Box, Text, useInput } from '@anthropic/ink'; import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; import type { PastedContent } from '../../utils/config.js'; import { getImageFromClipboard } from '../../utils/imagePaste.js'; import type { ImageDimensions } from '../../utils/imageResizer.js'; import { ClickableImageRef } from '../ClickableImageRef.js'; import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; import { Byline } from '@anthropic/ink'; import TextInput from '../TextInput.js'; import type { OptionWithDescription } from './select.js'; import { SelectOption } from './select-option.js'; type Props = { option: Extract, { type: 'input' }>; isFocused: boolean; isSelected: boolean; shouldShowDownArrow: boolean; shouldShowUpArrow: boolean; maxIndexWidth: number; index: number; inputValue: string; onInputChange: (value: string) => void; onSubmit: (value: string) => void; onExit?: () => void; layout: 'compact' | 'expanded'; children?: ReactNode; /** * When true, shows the label before the input field. * When false (default), uses the label as the placeholder. */ showLabel?: boolean; /** * Callback to open external editor for editing the input value. * When provided, ctrl+g will trigger this callback with the current value * and a setter function to update the internal state. */ onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void; /** * 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; /** * Optional callback when an image is pasted into the input. */ onImagePaste?: ( base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string, ) => void; /** * Pasted content to display inline above the input when focused. */ pastedContents?: Record; /** * Callback to remove a pasted image by its ID. */ onRemoveImage?: (id: number) => void; /** * Whether image selection mode is active. */ imagesSelected?: boolean; /** * Currently selected image index within the image attachments array. */ selectedImageIndex?: number; /** * Callback to set image selection mode on/off. */ onImagesSelectedChange?: (selected: boolean) => void; /** * Callback to change the selected image index. */ onSelectedImageIndexChange?: (index: number) => void; }; export function SelectInputOption({ option, isFocused, isSelected, shouldShowDownArrow, shouldShowUpArrow, maxIndexWidth, index, inputValue, onInputChange, onSubmit, onExit, layout, children, showLabel: showLabelProp = false, onOpenEditor, resetCursorOnUpdate = false, onImagePaste, pastedContents, onRemoveImage, imagesSelected, selectedImageIndex = 0, onImagesSelectedChange, onSelectedImageIndexChange, }: Props): React.ReactNode { const imageAttachments = pastedContents ? Object.values(pastedContents).filter(c => c.type === 'image') : []; // Allow individual options to force showing the label via showLabelWithValue const showLabel = showLabelProp || option.showLabelWithValue === true; const [cursorOffset, setCursorOffset] = useState(inputValue.length); // Track whether the latest inputValue change was from user typing/pasting, // so we can skip resetting cursor to end on user-initiated changes. const isUserEditing = useRef(false); // Reset cursor to end of line when: // 1. Option becomes focused (user navigates to it) // 2. Input value changes externally (e.g., async classifier description updates) // Skip reset when the change was from user typing (which sets isUserEditing ref) // Only enabled when resetCursorOnUpdate prop is true useEffect(() => { if (resetCursorOnUpdate && isFocused) { if (isUserEditing.current) { isUserEditing.current = false; } else { setCursorOffset(inputValue.length); } } }, [resetCursorOnUpdate, isFocused, inputValue]); // ctrl+g to open external editor (reuses chat:externalEditor keybinding) useKeybinding( 'chat:externalEditor', () => { onOpenEditor?.(inputValue, onInputChange); }, { context: 'Chat', isActive: isFocused && !!onOpenEditor }, ); // ctrl+v to paste image from clipboard (same as PromptInput) useKeybinding( 'chat:imagePaste', () => { if (!onImagePaste) return; void getImageFromClipboard().then(imageData => { if (imageData) { onImagePaste(imageData.base64, imageData.mediaType, undefined, imageData.dimensions); } }); }, { context: 'Chat', isActive: isFocused && !!onImagePaste }, ); // Backspace with empty input removes the last pasted image (non-image-selection mode) useKeybinding( 'attachments:remove', () => { if (imageAttachments.length > 0 && onRemoveImage) { onRemoveImage(imageAttachments.at(-1)!.id); } }, { context: 'Attachments', isActive: isFocused && !imagesSelected && inputValue === '' && imageAttachments.length > 0 && !!onRemoveImage, }, ); // Image selection mode keybindings — reuses existing Attachments actions useKeybindings( { 'attachments:next': () => { if (imageAttachments.length > 1) { onSelectedImageIndexChange?.((selectedImageIndex + 1) % imageAttachments.length); } }, 'attachments:previous': () => { if (imageAttachments.length > 1) { onSelectedImageIndexChange?.((selectedImageIndex - 1 + imageAttachments.length) % imageAttachments.length); } }, 'attachments:remove': () => { const img = imageAttachments[selectedImageIndex]; if (img && onRemoveImage) { onRemoveImage(img.id); // If no images left after removal, exit image selection if (imageAttachments.length <= 1) { onImagesSelectedChange?.(false); } else { // Adjust index if we deleted the last image onSelectedImageIndexChange?.(Math.min(selectedImageIndex, imageAttachments.length - 2)); } } }, 'attachments:exit': () => { onImagesSelectedChange?.(false); }, }, { context: 'Attachments', isActive: isFocused && !!imagesSelected }, ); // UP arrow exits image selection mode (UP isn't bound to attachments:exit) useInput( (_input, key) => { if (key.upArrow) { onImagesSelectedChange?.(false); } }, { isActive: isFocused && !!imagesSelected }, ); // Exit image mode when option loses focus useEffect(() => { if (!isFocused && imagesSelected) { onImagesSelectedChange?.(false); } }, [isFocused, imagesSelected, onImagesSelectedChange]); const descriptionPaddingLeft = layout === 'expanded' ? maxIndexWidth + 3 : maxIndexWidth + 4; return ( {`${index}.`.padEnd(maxIndexWidth + 2)} {children} {showLabel ? ( <> {option.label} {isFocused ? ( <> {option.labelValueSeparator ?? ', '} { isUserEditing.current = true; onInputChange(value); option.onChange(value); }} onSubmit={onSubmit} onExit={onExit} placeholder={option.placeholder} focus={!imagesSelected} showCursor={true} multiline={true} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={80} onImagePaste={onImagePaste} onPaste={(pastedText: string) => { isUserEditing.current = true; const before = inputValue.slice(0, cursorOffset); const after = inputValue.slice(cursorOffset); const newValue = before + pastedText + after; onInputChange(newValue); option.onChange(newValue); setCursorOffset(before.length + pastedText.length); }} /> ) : ( inputValue && ( {option.labelValueSeparator ?? ', '} {inputValue} ) )} ) : isFocused ? ( { isUserEditing.current = true; onInputChange(value); option.onChange(value); }} onSubmit={onSubmit} onExit={onExit} placeholder={option.placeholder || (typeof option.label === 'string' ? option.label : undefined)} focus={!imagesSelected} showCursor={true} multiline={true} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={80} onImagePaste={onImagePaste} onPaste={(pastedText: string) => { isUserEditing.current = true; const before = inputValue.slice(0, cursorOffset); const after = inputValue.slice(cursorOffset); const newValue = before + pastedText + after; onInputChange(newValue); option.onChange(newValue); setCursorOffset(before.length + pastedText.length); }} /> ) : ( {inputValue || option.placeholder || option.label} )} {option.description && ( {option.description} )} {imageAttachments.length > 0 && ( {imageAttachments.map((img, idx) => ( ))} {imagesSelected ? ( {imageAttachments.length > 1 && ( <> )} ) : isFocused ? ( '(↓ to select)' ) : null} )} {layout === 'expanded' && } ); }