Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
8.8 kB · 287 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288import { useMemo } from 'react'import { useRegisterOverlay } from '../../context/overlayContext.js'import type { InputEvent } from '../../ink/events/input-event.js'import { useInput } from '../../ink.js'import { useKeybindings } from '../../keybindings/useKeybinding.js'import { normalizeFullWidthDigits, normalizeFullWidthSpace,} from '../../utils/stringUtils.js'import type { OptionWithDescription } from './select.js'import type { SelectState } from './use-select-state.js'
export type UseSelectProps<T> = { /** * When disabled, user input is ignored. * * @default false */ isDisabled?: boolean
/** * When true, prevents selection on Enter or number keys, but allows * scrolling. * When 'numeric', prevents selection on number keys, but allows Enter (and * scrolling). * * @default false */ readonly disableSelection?: boolean | 'numeric'
/** * Select state. */ state: SelectState<T>
/** * Options. */ options: OptionWithDescription<T>[]
/** * Whether this is a multi-select component. * * @default false */ isMultiSelect?: boolean
/** * Callback when user presses up from the first item. * If provided, navigation will not wrap to the last item. */ onUpFromFirstItem?: () => void
/** * Callback when user presses down from the last item. * If provided, navigation will not wrap to the first item. */ onDownFromLastItem?: () => void
/** * Callback when input mode should be toggled for an option. * Called when Tab is pressed (to enter or exit input mode). */ onInputModeToggle?: (value: T) => void
/** * Current input values for input-type options. * Used to determine if number key should submit an empty input option. */ inputValues?: Map<T, string>
/** * Whether image selection mode is active on the focused input option. * When true, arrow key navigation in useInput is suppressed so that * Attachments keybindings can handle image navigation instead. */ imagesSelected?: boolean
/** * Callback to attempt entering image selection mode on DOWN arrow. * Returns true if image selection was entered (images exist), false otherwise. */ onEnterImageSelection?: () => boolean}
export const useSelectInput = <T>({ isDisabled = false, disableSelection = false, state, options, isMultiSelect = false, onUpFromFirstItem, onDownFromLastItem, onInputModeToggle, inputValues, imagesSelected = false, onEnterImageSelection,}: UseSelectProps<T>) => { // Automatically register as an overlay when onCancel is provided. // This ensures CancelRequestHandler won't intercept Escape when the select is active. useRegisterOverlay('select', !!state.onCancel)
// Determine if the focused option is an input type const isInInput = useMemo(() => { const focusedOption = options.find(opt => opt.value === state.focusedValue) return focusedOption?.type === 'input' }, [options, state.focusedValue])
// Core navigation via keybindings (up/down/enter/escape) // When in input mode, exclude navigation/accept keybindings so that // j/k/enter pass through to the TextInput instead of being intercepted. const keybindingHandlers = useMemo(() => { const handlers: Record<string, () => void> = {}
if (!isInInput) { handlers['select:next'] = () => { if (onDownFromLastItem) { const lastOption = options[options.length - 1] if (lastOption && state.focusedValue === lastOption.value) { onDownFromLastItem() return } } state.focusNextOption() } handlers['select:previous'] = () => { if (onUpFromFirstItem && state.visibleFromIndex === 0) { const firstOption = options[0] if (firstOption && state.focusedValue === firstOption.value) { onUpFromFirstItem() return } } state.focusPreviousOption() } handlers['select:accept'] = () => { if (disableSelection === true) return if (state.focusedValue === undefined) return
const focusedOption = options.find( opt => opt.value === state.focusedValue, ) if (focusedOption?.disabled === true) return
state.selectFocusedOption?.() state.onChange?.(state.focusedValue) } }
if (state.onCancel) { handlers['select:cancel'] = () => { state.onCancel!() } }
return handlers }, [ options, state, onDownFromLastItem, onUpFromFirstItem, isInInput, disableSelection, ])
useKeybindings(keybindingHandlers, { context: 'Select', isActive: !isDisabled, })
// Remaining keys that stay as useInput: number keys, pageUp/pageDown, tab, space, // and arrow key navigation when in input mode useInput( (input, key, event: InputEvent) => { const normalizedInput = normalizeFullWidthDigits(input) const focusedOption = options.find( opt => opt.value === state.focusedValue, ) const currentIsInInput = focusedOption?.type === 'input'
// Handle Tab key for input mode toggling if (key.tab && onInputModeToggle && state.focusedValue !== undefined) { onInputModeToggle(state.focusedValue) return }
if (currentIsInInput) { // When in image selection mode, suppress all input handling so // Attachments keybindings can handle navigation/deletion instead if (imagesSelected) return
// DOWN arrow enters image selection mode if images exist if (key.downArrow && onEnterImageSelection?.()) { event.stopImmediatePropagation() return }
// Arrow keys still navigate the select even while in input mode if (key.downArrow || (key.ctrl && input === 'n')) { if (onDownFromLastItem) { const lastOption = options[options.length - 1] if (lastOption && state.focusedValue === lastOption.value) { onDownFromLastItem() event.stopImmediatePropagation() return } } state.focusNextOption() event.stopImmediatePropagation() return } if (key.upArrow || (key.ctrl && input === 'p')) { if (onUpFromFirstItem && state.visibleFromIndex === 0) { const firstOption = options[0] if (firstOption && state.focusedValue === firstOption.value) { onUpFromFirstItem() event.stopImmediatePropagation() return } } state.focusPreviousOption() event.stopImmediatePropagation() return }
// All other keys (including digits) pass through to TextInput. // Digits should type literally into the input rather than select // options — the user has focused a text field and expects typing // to insert characters, not jump to a different option. return }
if (key.pageDown) { state.focusNextPage() }
if (key.pageUp) { state.focusPreviousPage() }
if (disableSelection !== true) { // Space for multi-select toggle if ( isMultiSelect && normalizeFullWidthSpace(input) === ' ' && state.focusedValue !== undefined ) { const isFocusedOptionDisabled = focusedOption?.disabled === true if (!isFocusedOptionDisabled) { state.selectFocusedOption?.() state.onChange?.(state.focusedValue) } }
if ( disableSelection !== 'numeric' && /^[0-9]+$/.test(normalizedInput) ) { const index = parseInt(normalizedInput) - 1 if (index >= 0 && index < state.options.length) { const selectedOption = state.options[index]! if (selectedOption.disabled === true) { return } if (selectedOption.type === 'input') { const currentValue = inputValues?.get(selectedOption.value) ?? '' if (currentValue.trim()) { // Pre-filled input: auto-submit (user can Tab to edit instead) state.onChange?.(selectedOption.value) return } if (selectedOption.allowEmptySubmitToCancel) { state.onChange?.(selectedOption.value) return } state.focusOption(selectedOption.value) return } state.onChange?.(selectedOption.value) return } } } }, { isActive: !isDisabled }, )}