source dump of claude code
at main 414 lines 11 kB view raw
1import { useCallback, useState } from 'react' 2import { isDeepStrictEqual } from 'util' 3import { useRegisterOverlay } from '../../context/overlayContext.js' 4import type { InputEvent } from '../../ink/events/input-event.js' 5// eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw space/arrow multiselect input 6import { useInput } from '../../ink.js' 7import { 8 normalizeFullWidthDigits, 9 normalizeFullWidthSpace, 10} from '../../utils/stringUtils.js' 11import type { OptionWithDescription } from './select.js' 12import { useSelectNavigation } from './use-select-navigation.js' 13 14export type UseMultiSelectStateProps<T> = { 15 /** 16 * When disabled, user input is ignored. 17 * 18 * @default false 19 */ 20 isDisabled?: boolean 21 22 /** 23 * Number of items to display. 24 * 25 * @default 5 26 */ 27 visibleOptionCount?: number 28 29 /** 30 * Options. 31 */ 32 options: OptionWithDescription<T>[] 33 34 /** 35 * Initially selected values. 36 */ 37 defaultValue?: T[] 38 39 /** 40 * Callback when selection changes. 41 */ 42 onChange?: (values: T[]) => void 43 44 /** 45 * Callback for canceling the select. 46 */ 47 onCancel: () => void 48 49 /** 50 * Callback for focusing an option. 51 */ 52 onFocus?: (value: T) => void 53 54 /** 55 * Value to focus 56 */ 57 focusValue?: T 58 59 /** 60 * Text for the submit button. When provided, a submit button is shown and 61 * Enter toggles selection (submit only fires when the button is focused). 62 * When omitted, Enter submits directly and Space toggles selection. 63 */ 64 submitButtonText?: string 65 66 /** 67 * Callback when user submits. Receives the currently selected values. 68 */ 69 onSubmit?: (values: T[]) => void 70 71 /** 72 * Callback when user presses down from the last item (submit button). 73 * If provided, navigation will not wrap to the first item. 74 */ 75 onDownFromLastItem?: () => void 76 77 /** 78 * Callback when user presses up from the first item. 79 * If provided, navigation will not wrap to the last item. 80 */ 81 onUpFromFirstItem?: () => void 82 83 /** 84 * Focus the last option initially instead of the first. 85 */ 86 initialFocusLast?: boolean 87 88 /** 89 * When true, numeric keys (1-9) do not toggle options by index. 90 * Mirrors the rendering layer's hideIndexes: if index labels aren't shown, 91 * pressing a number shouldn't silently toggle an invisible mapping. 92 */ 93 hideIndexes?: boolean 94} 95 96export type MultiSelectState<T> = { 97 /** 98 * Value of the currently focused option. 99 */ 100 focusedValue: T | undefined 101 102 /** 103 * Index of the first visible option. 104 */ 105 visibleFromIndex: number 106 107 /** 108 * Index of the last visible option. 109 */ 110 visibleToIndex: number 111 112 /** 113 * All options. 114 */ 115 options: OptionWithDescription<T>[] 116 117 /** 118 * Visible options. 119 */ 120 visibleOptions: Array<OptionWithDescription<T> & { index: number }> 121 122 /** 123 * Whether the focused option is an input type. 124 */ 125 isInInput: boolean 126 127 /** 128 * Currently selected values. 129 */ 130 selectedValues: T[] 131 132 /** 133 * Current input field values. 134 */ 135 inputValues: Map<T, string> 136 137 /** 138 * Whether the submit button is focused. 139 */ 140 isSubmitFocused: boolean 141 142 /** 143 * Update an input field value. 144 */ 145 updateInputValue: (value: T, inputValue: string) => void 146 147 /** 148 * Callback for canceling the select. 149 */ 150 onCancel: () => void 151} 152 153export function useMultiSelectState<T>({ 154 isDisabled = false, 155 visibleOptionCount = 5, 156 options, 157 defaultValue = [], 158 onChange, 159 onCancel, 160 onFocus, 161 focusValue, 162 submitButtonText, 163 onSubmit, 164 onDownFromLastItem, 165 onUpFromFirstItem, 166 initialFocusLast, 167 hideIndexes = false, 168}: UseMultiSelectStateProps<T>): MultiSelectState<T> { 169 const [selectedValues, setSelectedValues] = useState<T[]>(defaultValue) 170 const [isSubmitFocused, setIsSubmitFocused] = useState(false) 171 172 // Reset selectedValues when options change (e.g. async-loaded data changes 173 // defaultValue after mount). Mirrors the reset pattern in use-select-navigation.ts 174 // and the deleted ui/useMultiSelectState.ts — without this, MCPServerDesktopImportDialog 175 // keeps colliding servers checked after getAllMcpConfigs() resolves. 176 const [lastOptions, setLastOptions] = useState(options) 177 if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) { 178 setSelectedValues(defaultValue) 179 setLastOptions(options) 180 } 181 182 // State for input type options 183 const [inputValues, setInputValues] = useState<Map<T, string>>(() => { 184 const initialMap = new Map<T, string>() 185 options.forEach(option => { 186 if (option.type === 'input' && option.initialValue) { 187 initialMap.set(option.value, option.initialValue) 188 } 189 }) 190 return initialMap 191 }) 192 193 const updateSelectedValues = useCallback( 194 (values: T[] | ((prev: T[]) => T[])) => { 195 const newValues = 196 typeof values === 'function' ? values(selectedValues) : values 197 setSelectedValues(newValues) 198 onChange?.(newValues) 199 }, 200 [selectedValues, onChange], 201 ) 202 203 const navigation = useSelectNavigation<T>({ 204 visibleOptionCount, 205 options, 206 initialFocusValue: initialFocusLast 207 ? options[options.length - 1]?.value 208 : undefined, 209 onFocus, 210 focusValue, 211 }) 212 213 // Automatically register as an overlay. 214 // This ensures CancelRequestHandler won't intercept Escape when the multi-select is active. 215 useRegisterOverlay('multi-select') 216 217 const updateInputValue = useCallback( 218 (value: T, inputValue: string) => { 219 setInputValues(prev => { 220 const next = new Map(prev) 221 next.set(value, inputValue) 222 return next 223 }) 224 225 // Find the option and call its onChange 226 const option = options.find(opt => opt.value === value) 227 if (option && option.type === 'input') { 228 option.onChange(inputValue) 229 } 230 231 // Update selected values to include/exclude based on input 232 updateSelectedValues(prev => { 233 if (inputValue) { 234 if (!prev.includes(value)) { 235 return [...prev, value] 236 } 237 return prev 238 } else { 239 return prev.filter(v => v !== value) 240 } 241 }) 242 }, 243 [options, updateSelectedValues], 244 ) 245 246 // Handle all keyboard input 247 useInput( 248 (input, key, event: InputEvent) => { 249 const normalizedInput = normalizeFullWidthDigits(input) 250 const focusedOption = options.find( 251 opt => opt.value === navigation.focusedValue, 252 ) 253 const isInInput = focusedOption?.type === 'input' 254 255 // When in input field, only allow navigation keys 256 if (isInInput) { 257 const isAllowedKey = 258 key.upArrow || 259 key.downArrow || 260 key.escape || 261 key.tab || 262 key.return || 263 (key.ctrl && (input === 'n' || input === 'p' || key.return)) 264 if (!isAllowedKey) return 265 } 266 267 const lastOptionValue = options[options.length - 1]?.value 268 269 // Handle Tab to move forward 270 if (key.tab && !key.shift) { 271 if ( 272 submitButtonText && 273 onSubmit && 274 navigation.focusedValue === lastOptionValue && 275 !isSubmitFocused 276 ) { 277 setIsSubmitFocused(true) 278 } else if (!isSubmitFocused) { 279 navigation.focusNextOption() 280 } 281 return 282 } 283 284 // Handle Shift+Tab to move backward 285 if (key.tab && key.shift) { 286 if (submitButtonText && onSubmit && isSubmitFocused) { 287 setIsSubmitFocused(false) 288 navigation.focusOption(lastOptionValue) 289 } else { 290 navigation.focusPreviousOption() 291 } 292 return 293 } 294 295 // Handle arrow down / Ctrl+N / j 296 if ( 297 key.downArrow || 298 (key.ctrl && input === 'n') || 299 (!key.ctrl && !key.shift && input === 'j') 300 ) { 301 if (isSubmitFocused && onDownFromLastItem) { 302 onDownFromLastItem() 303 } else if ( 304 submitButtonText && 305 onSubmit && 306 navigation.focusedValue === lastOptionValue && 307 !isSubmitFocused 308 ) { 309 setIsSubmitFocused(true) 310 } else if ( 311 !submitButtonText && 312 onDownFromLastItem && 313 navigation.focusedValue === lastOptionValue 314 ) { 315 // No submit button — exit from the last option 316 onDownFromLastItem() 317 } else if (!isSubmitFocused) { 318 navigation.focusNextOption() 319 } 320 return 321 } 322 323 // Handle arrow up / Ctrl+P / k 324 if ( 325 key.upArrow || 326 (key.ctrl && input === 'p') || 327 (!key.ctrl && !key.shift && input === 'k') 328 ) { 329 if (submitButtonText && onSubmit && isSubmitFocused) { 330 setIsSubmitFocused(false) 331 navigation.focusOption(lastOptionValue) 332 } else if ( 333 onUpFromFirstItem && 334 navigation.focusedValue === options[0]?.value 335 ) { 336 onUpFromFirstItem() 337 } else { 338 navigation.focusPreviousOption() 339 } 340 return 341 } 342 343 // Handle page navigation 344 if (key.pageDown) { 345 navigation.focusNextPage() 346 return 347 } 348 349 if (key.pageUp) { 350 navigation.focusPreviousPage() 351 return 352 } 353 354 // Handle Enter or Space for selection/submit 355 if (key.return || normalizeFullWidthSpace(input) === ' ') { 356 // Ctrl+Enter from input field submits 357 if (key.ctrl && key.return && isInInput && onSubmit) { 358 onSubmit(selectedValues) 359 return 360 } 361 362 // Enter on submit button submits 363 if (isSubmitFocused && onSubmit) { 364 onSubmit(selectedValues) 365 return 366 } 367 368 // No submit button: Enter submits directly, Space still toggles 369 if (key.return && !submitButtonText && onSubmit) { 370 onSubmit(selectedValues) 371 return 372 } 373 374 // Enter or Space toggles selection (including for input fields) 375 if (navigation.focusedValue !== undefined) { 376 const newValues = selectedValues.includes(navigation.focusedValue) 377 ? selectedValues.filter(v => v !== navigation.focusedValue) 378 : [...selectedValues, navigation.focusedValue] 379 updateSelectedValues(newValues) 380 } 381 return 382 } 383 384 // Handle numeric keys (1-9) for direct selection 385 if (!hideIndexes && /^[0-9]+$/.test(normalizedInput)) { 386 const index = parseInt(normalizedInput) - 1 387 if (index >= 0 && index < options.length) { 388 const value = options[index]!.value 389 const newValues = selectedValues.includes(value) 390 ? selectedValues.filter(v => v !== value) 391 : [...selectedValues, value] 392 updateSelectedValues(newValues) 393 } 394 return 395 } 396 397 // Handle Escape 398 if (key.escape) { 399 onCancel() 400 event.stopImmediatePropagation() 401 } 402 }, 403 { isActive: !isDisabled }, 404 ) 405 406 return { 407 ...navigation, 408 selectedValues, 409 inputValues, 410 isSubmitFocused, 411 updateInputValue, 412 onCancel, 413 } 414}