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' && } ) }