import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, PrimitiveSchemaDefinition, } from '@modelcontextprotocol/sdk/types.js' import figures from 'figures' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useRegisterOverlay } from '../../context/overlayContext.js' import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js' import { useTerminalSize } from '../../hooks/useTerminalSize.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw text input for elicitation form import { Box, Text, useInput } from '../../ink.js' import { useKeybinding } from '../../keybindings/useKeybinding.js' import type { ElicitationRequestEvent } from '../../services/mcp/elicitationHandler.js' import { openBrowser } from '../../utils/browser.js' import { getEnumLabel, getEnumValues, getMultiSelectLabel, getMultiSelectValues, isDateTimeSchema, isEnumSchema, isMultiSelectEnumSchema, validateElicitationInput, validateElicitationInputAsync, } from '../../utils/mcp/elicitationValidation.js' import { plural } from '../../utils/stringUtils.js' import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' import { Byline } from '../design-system/Byline.js' import { Dialog } from '../design-system/Dialog.js' import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' import TextInput from '../TextInput.js' type Props = { event: ElicitationRequestEvent onResponse: ( action: ElicitResult['action'], content?: ElicitResult['content'], ) => void /** Called when the phase 2 waiting state is dismissed (URL elicitations only). */ onWaitingDismiss?: (action: 'dismiss' | 'retry' | 'cancel') => void } const isTextField = (s: PrimitiveSchemaDefinition) => ['string', 'number', 'integer'].includes(s.type) const RESOLVING_SPINNER_CHARS = '\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F' const advanceSpinnerFrame = (f: number) => (f + 1) % RESOLVING_SPINNER_CHARS.length /** Timer callback for enumTypeaheadRef — module-scope to avoid closure capture. */ function resetTypeahead(ta: { buffer: string timer: ReturnType | undefined }): void { ta.buffer = '' ta.timer = undefined } /** * Isolated spinner glyph for a field that is being resolved asynchronously. * Owns its own 80ms animation timer so ticks only re-render this tiny leaf, * not the entire ElicitationFormDialog (~1200 lines + renderFormFields). * Mounted/unmounted by the parent via the `isResolving` condition. * * Not using the shared from ../Spinner.js: that one renders in a * with color="text", which would break the 1-col checkbox * column alignment here (other checkbox states are width-1 glyphs). */ function ResolvingSpinner(): React.ReactNode { const [frame, setFrame] = useState(0) useEffect(() => { const timer = setInterval(setFrame, 80, advanceSpinnerFrame) return () => clearInterval(timer) }, []) return {RESOLVING_SPINNER_CHARS[frame]} } /** Format an ISO date/datetime for display, keeping the ISO value for submission. */ function formatDateDisplay( isoValue: string, schema: PrimitiveSchemaDefinition, ): string { try { const date = new Date(isoValue) if (Number.isNaN(date.getTime())) return isoValue const format = 'format' in schema ? schema.format : undefined if (format === 'date-time') { return date.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short', }) } // date-only: parse as local date to avoid timezone shift const parts = isoValue.split('-') if (parts.length === 3) { const local = new Date( Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]), ) return local.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', }) } return isoValue } catch { return isoValue } } export function ElicitationDialog({ event, onResponse, onWaitingDismiss, }: Props): React.ReactNode { if (event.params.mode === 'url') { return ( ) } return } function ElicitationFormDialog({ event, onResponse, }: { event: ElicitationRequestEvent onResponse: Props['onResponse'] }): React.ReactNode { const { serverName, signal } = event const request = event.params as ElicitRequestFormParams const { message, requestedSchema } = request const hasFields = Object.keys(requestedSchema.properties).length > 0 const [focusedButton, setFocusedButton] = useState< 'accept' | 'decline' | null >(hasFields ? null : 'accept') const [formValues, setFormValues] = useState< Record >(() => { const initialValues: Record = {} if (requestedSchema.properties) { for (const [propName, propSchema] of Object.entries( requestedSchema.properties, )) { if (typeof propSchema === 'object' && propSchema !== null) { if (propSchema.default !== undefined) { initialValues[propName] = propSchema.default } } } } return initialValues }) const [validationErrors, setValidationErrors] = useState< Record >(() => { const initialErrors: Record = {} for (const [propName, propSchema] of Object.entries( requestedSchema.properties, )) { if (isTextField(propSchema) && propSchema?.default !== undefined) { const validation = validateElicitationInput( String(propSchema.default), propSchema, ) if (!validation.isValid && validation.error) { initialErrors[propName] = validation.error } } } return initialErrors }) useEffect(() => { if (!signal) return const handleAbort = () => { onResponse('cancel') } if (signal.aborted) { handleAbort() return } signal.addEventListener('abort', handleAbort) return () => { signal.removeEventListener('abort', handleAbort) } }, [signal, onResponse]) const schemaFields = useMemo(() => { const requiredFields = requestedSchema.required ?? [] return Object.entries(requestedSchema.properties).map(([name, schema]) => ({ name, schema, isRequired: requiredFields.includes(name), })) }, [requestedSchema]) const [currentFieldIndex, setCurrentFieldIndex] = useState< number | undefined >(hasFields ? 0 : undefined) const [textInputValue, setTextInputValue] = useState(() => { // Initialize from the first field's value if it's a text field const firstField = schemaFields[0] if (firstField && isTextField(firstField.schema)) { const val = formValues[firstField.name] if (val === undefined) return '' return String(val) } return '' }) const [textInputCursorOffset, setTextInputCursorOffset] = useState( textInputValue.length, ) const [resolvingFields, setResolvingFields] = useState>( () => new Set(), ) // Accordion state (shared by multi-select and single-select enum) const [expandedAccordion, setExpandedAccordion] = useState< string | undefined >() const [accordionOptionIndex, setAccordionOptionIndex] = useState(0) const dateDebounceRef = useRef | undefined>( undefined, ) const resolveAbortRef = useRef>(new Map()) const enumTypeaheadRef = useRef({ buffer: '', timer: undefined as ReturnType | undefined, }) // Clear pending debounce/typeahead timers and abort in-flight async // validations on unmount so they don't fire against an unmounted component // (e.g. dialog dismissed mid-debounce or mid-resolve). useEffect( () => () => { if (dateDebounceRef.current !== undefined) { clearTimeout(dateDebounceRef.current) } const ta = enumTypeaheadRef.current if (ta.timer !== undefined) { clearTimeout(ta.timer) } for (const controller of resolveAbortRef.current.values()) { controller.abort() } resolveAbortRef.current.clear() }, [], ) const { columns, rows } = useTerminalSize() const currentField = currentFieldIndex !== undefined ? schemaFields[currentFieldIndex] : undefined const currentFieldIsText = currentField !== undefined && isTextField(currentField.schema) && !isEnumSchema(currentField.schema) // Text fields are always in edit mode when focused — no Enter-to-edit step. const isEditingTextField = currentFieldIsText && !focusedButton useRegisterOverlay('elicitation') useNotifyAfterTimeout('Claude Code needs your input', 'elicitation_dialog') // Sync textInputValue when the focused field changes const syncTextInput = useCallback( (fieldIndex: number | undefined) => { if (fieldIndex === undefined) { setTextInputValue('') setTextInputCursorOffset(0) return } const field = schemaFields[fieldIndex] if (field && isTextField(field.schema) && !isEnumSchema(field.schema)) { const val = formValues[field.name] const text = val !== undefined ? String(val) : '' setTextInputValue(text) setTextInputCursorOffset(text.length) } }, [schemaFields, formValues], ) function validateMultiSelect( fieldName: string, schema: PrimitiveSchemaDefinition, ) { if (!isMultiSelectEnumSchema(schema)) return const selected = (formValues[fieldName] as string[] | undefined) ?? [] const fieldRequired = schemaFields.find(f => f.name === fieldName)?.isRequired ?? false const min = schema.minItems const max = schema.maxItems // Skip minItems check when field is optional and unset if ( min !== undefined && selected.length < min && (selected.length > 0 || fieldRequired) ) { updateValidationError( fieldName, `Select at least ${min} ${plural(min, 'item')}`, ) } else if (max !== undefined && selected.length > max) { updateValidationError( fieldName, `Select at most ${max} ${plural(max, 'item')}`, ) } else { updateValidationError(fieldName) } } function handleNavigation(direction: 'up' | 'down'): void { // Collapse accordion and validate on navigate away if (currentField && isMultiSelectEnumSchema(currentField.schema)) { validateMultiSelect(currentField.name, currentField.schema) setExpandedAccordion(undefined) } else if (currentField && isEnumSchema(currentField.schema)) { setExpandedAccordion(undefined) } // Commit current text field before navigating away if (isEditingTextField && currentField) { commitTextField(currentField.name, currentField.schema, textInputValue) // Cancel any pending debounce — we're resolving now on navigate-away if (dateDebounceRef.current !== undefined) { clearTimeout(dateDebounceRef.current) dateDebounceRef.current = undefined } // For date/datetime fields that failed sync validation, try async NL parsing if ( isDateTimeSchema(currentField.schema) && textInputValue.trim() !== '' && validationErrors[currentField.name] ) { resolveFieldAsync( currentField.name, currentField.schema, textInputValue, ) } } // Fields + accept + decline const itemCount = schemaFields.length + 2 const index = currentFieldIndex ?? (focusedButton === 'accept' ? schemaFields.length : focusedButton === 'decline' ? schemaFields.length + 1 : undefined) const nextIndex = index !== undefined ? (index + (direction === 'up' ? itemCount - 1 : 1)) % itemCount : 0 if (nextIndex < schemaFields.length) { setCurrentFieldIndex(nextIndex) setFocusedButton(null) syncTextInput(nextIndex) } else { setCurrentFieldIndex(undefined) setFocusedButton(nextIndex === schemaFields.length ? 'accept' : 'decline') setTextInputValue('') } } function setField( fieldName: string, value: number | string | boolean | string[] | undefined, ) { setFormValues(prev => { const next = { ...prev } if (value === undefined) { delete next[fieldName] } else { next[fieldName] = value } return next }) // Clear "required" error when a value is provided if ( value !== undefined && validationErrors[fieldName] === 'This field is required' ) { updateValidationError(fieldName) } } function updateValidationError(fieldName: string, error?: string) { setValidationErrors(prev => { const next = { ...prev } if (error) { next[fieldName] = error } else { delete next[fieldName] } return next }) } function unsetField(fieldName: string) { if (!fieldName) return setField(fieldName, undefined) updateValidationError(fieldName) setTextInputValue('') setTextInputCursorOffset(0) } function commitTextField( fieldName: string, schema: PrimitiveSchemaDefinition, value: string, ) { const trimmedValue = value.trim() // Empty input for non-plain-string types means unset if ( trimmedValue === '' && (schema.type !== 'string' || ('format' in schema && schema.format !== undefined)) ) { unsetField(fieldName) return } if (trimmedValue === '') { // Empty plain string — keep or unset depending on whether it was set if (formValues[fieldName] !== undefined) { setField(fieldName, '') } return } const validation = validateElicitationInput(value, schema) setField(fieldName, validation.isValid ? validation.value : value) updateValidationError( fieldName, validation.isValid ? undefined : validation.error, ) } function resolveFieldAsync( fieldName: string, schema: PrimitiveSchemaDefinition, rawValue: string, ) { if (!signal) return // Abort any existing resolution for this field const existing = resolveAbortRef.current.get(fieldName) if (existing) { existing.abort() } const controller = new AbortController() resolveAbortRef.current.set(fieldName, controller) setResolvingFields(prev => new Set(prev).add(fieldName)) void validateElicitationInputAsync( rawValue, schema, controller.signal, ).then( result => { resolveAbortRef.current.delete(fieldName) setResolvingFields(prev => { const next = new Set(prev) next.delete(fieldName) return next }) if (controller.signal.aborted) return if (result.isValid) { setField(fieldName, result.value) updateValidationError(fieldName) // Update the text input if we're still on this field const isoText = String(result.value) setTextInputValue(prev => { // Only replace if the field is still showing the raw input if (prev === rawValue) { setTextInputCursorOffset(isoText.length) return isoText } return prev }) } else { // Keep raw text, show validation error updateValidationError(fieldName, result.error) } }, () => { resolveAbortRef.current.delete(fieldName) setResolvingFields(prev => { const next = new Set(prev) next.delete(fieldName) return next }) }, ) } function handleTextInputChange(newValue: string) { setTextInputValue(newValue) // Commit immediately on each keystroke (sync validation) if (currentField) { commitTextField(currentField.name, currentField.schema, newValue) // For date/datetime fields, debounce async NL parsing after 2s of inactivity if (dateDebounceRef.current !== undefined) { clearTimeout(dateDebounceRef.current) dateDebounceRef.current = undefined } if ( isDateTimeSchema(currentField.schema) && newValue.trim() !== '' && validationErrors[currentField.name] ) { const fieldName = currentField.name const schema = currentField.schema dateDebounceRef.current = setTimeout( (dateDebounceRef, resolveFieldAsync, fieldName, schema, newValue) => { dateDebounceRef.current = undefined resolveFieldAsync(fieldName, schema, newValue) }, 2000, dateDebounceRef, resolveFieldAsync, fieldName, schema, newValue, ) } } } function handleTextInputSubmit() { handleNavigation('down') } /** * Append a keystroke to the typeahead buffer (reset after 2s idle) and * call `onMatch` with the index of the first label that prefix-matches. * Shared by boolean y/n, enum accordion, and multi-select accordion. */ function runTypeahead( char: string, labels: string[], onMatch: (index: number) => void, ) { const ta = enumTypeaheadRef.current if (ta.timer !== undefined) clearTimeout(ta.timer) ta.buffer += char.toLowerCase() ta.timer = setTimeout(resetTypeahead, 2000, ta) const match = labels.findIndex(l => l.startsWith(ta.buffer)) if (match !== -1) onMatch(match) } // Esc while a field is focused: cancel the dialog. // Uses Settings context (escape-only, no 'n' key) since Dialog's // Confirmation-context cancel is suppressed when a field is focused. useKeybinding( 'confirm:no', () => { // For text fields, revert uncommitted changes first if (isEditingTextField && currentField) { const val = formValues[currentField.name] setTextInputValue(val !== undefined ? String(val) : '') setTextInputCursorOffset(0) } onResponse('cancel') }, { context: 'Settings', isActive: !!currentField && !focusedButton && !expandedAccordion, }, ) useInput( (_input, key) => { // Text fields handle their own character input; we only intercept // navigation keys and backspace-on-empty here. if ( isEditingTextField && !key.upArrow && !key.downArrow && !key.return && !key.backspace ) { return } // Expanded multi-select accordion if ( expandedAccordion && currentField && isMultiSelectEnumSchema(currentField.schema) ) { const msSchema = currentField.schema const msValues = getMultiSelectValues(msSchema) const selected = (formValues[currentField.name] as string[]) ?? [] if (key.leftArrow || key.escape) { setExpandedAccordion(undefined) validateMultiSelect(currentField.name, msSchema) return } if (key.upArrow) { if (accordionOptionIndex === 0) { setExpandedAccordion(undefined) validateMultiSelect(currentField.name, msSchema) } else { setAccordionOptionIndex(accordionOptionIndex - 1) } return } if (key.downArrow) { if (accordionOptionIndex >= msValues.length - 1) { setExpandedAccordion(undefined) handleNavigation('down') } else { setAccordionOptionIndex(accordionOptionIndex + 1) } return } if (_input === ' ') { const optionValue = msValues[accordionOptionIndex] if (optionValue !== undefined) { const newSelected = selected.includes(optionValue) ? selected.filter(v => v !== optionValue) : [...selected, optionValue] const newValue = newSelected.length > 0 ? newSelected : undefined setField(currentField.name, newValue) const min = msSchema.minItems const max = msSchema.maxItems if ( min !== undefined && newSelected.length < min && (newSelected.length > 0 || currentField.isRequired) ) { updateValidationError( currentField.name, `Select at least ${min} ${plural(min, 'item')}`, ) } else if (max !== undefined && newSelected.length > max) { updateValidationError( currentField.name, `Select at most ${max} ${plural(max, 'item')}`, ) } else { updateValidationError(currentField.name) } } return } if (key.return) { // Check (not toggle) the focused item, then collapse and advance const optionValue = msValues[accordionOptionIndex] if (optionValue !== undefined && !selected.includes(optionValue)) { setField(currentField.name, [...selected, optionValue]) } setExpandedAccordion(undefined) handleNavigation('down') return } if (_input) { const labels = msValues.map(v => getMultiSelectLabel(msSchema, v).toLowerCase(), ) runTypeahead(_input, labels, setAccordionOptionIndex) return } return } // Expanded single-select enum accordion if ( expandedAccordion && currentField && isEnumSchema(currentField.schema) ) { const enumSchema = currentField.schema const enumValues = getEnumValues(enumSchema) if (key.leftArrow || key.escape) { setExpandedAccordion(undefined) return } if (key.upArrow) { if (accordionOptionIndex === 0) { setExpandedAccordion(undefined) } else { setAccordionOptionIndex(accordionOptionIndex - 1) } return } if (key.downArrow) { if (accordionOptionIndex >= enumValues.length - 1) { setExpandedAccordion(undefined) handleNavigation('down') } else { setAccordionOptionIndex(accordionOptionIndex + 1) } return } // Space: select and collapse if (_input === ' ') { const optionValue = enumValues[accordionOptionIndex] if (optionValue !== undefined) { setField(currentField.name, optionValue) } setExpandedAccordion(undefined) return } // Enter: select, collapse, and move to next field if (key.return) { const optionValue = enumValues[accordionOptionIndex] if (optionValue !== undefined) { setField(currentField.name, optionValue) } setExpandedAccordion(undefined) handleNavigation('down') return } if (_input) { const labels = enumValues.map(v => getEnumLabel(enumSchema, v).toLowerCase(), ) runTypeahead(_input, labels, setAccordionOptionIndex) return } return } // Accept / Decline buttons if (key.return && focusedButton === 'accept') { if (validateRequired() && Object.keys(validationErrors).length === 0) { onResponse('accept', formValues) } else { // Show "required" validation errors on missing fields const requiredFields = requestedSchema.required || [] for (const fieldName of requiredFields) { if (formValues[fieldName] === undefined) { updateValidationError(fieldName, 'This field is required') } } const firstBadIndex = schemaFields.findIndex( f => (requiredFields.includes(f.name) && formValues[f.name] === undefined) || validationErrors[f.name] !== undefined, ) if (firstBadIndex !== -1) { setCurrentFieldIndex(firstBadIndex) setFocusedButton(null) syncTextInput(firstBadIndex) } } return } if (key.return && focusedButton === 'decline') { onResponse('decline') return } // Up/Down navigation if (key.upArrow || key.downArrow) { // Reset enum typeahead when leaving a field const ta = enumTypeaheadRef.current ta.buffer = '' if (ta.timer !== undefined) { clearTimeout(ta.timer) ta.timer = undefined } handleNavigation(key.upArrow ? 'up' : 'down') return } // Left/Right to switch between Accept and Decline buttons if (focusedButton && (key.leftArrow || key.rightArrow)) { setFocusedButton(focusedButton === 'accept' ? 'decline' : 'accept') return } if (!currentField) return const { schema, name } = currentField const value = formValues[name] // Boolean: Space to toggle, Enter to move on if (schema.type === 'boolean') { if (_input === ' ') { setField(name, value === undefined ? true : !value) return } if (key.return) { handleNavigation('down') return } if (key.backspace && value !== undefined) { unsetField(name) return } // y/n typeahead if (_input && !key.return) { runTypeahead(_input, ['yes', 'no'], i => setField(name, i === 0)) return } return } // Enum or multi-select (collapsed) — accordion style if (isEnumSchema(schema) || isMultiSelectEnumSchema(schema)) { if (key.return) { handleNavigation('down') return } if (key.backspace && value !== undefined) { unsetField(name) return } // Compute option labels + initial focus index for rightArrow expand. // Single-select focuses on the current value; multi-select starts at 0. let labels: string[] let startIdx = 0 if (isEnumSchema(schema)) { const vals = getEnumValues(schema) labels = vals.map(v => getEnumLabel(schema, v).toLowerCase()) if (value !== undefined) { startIdx = Math.max(0, vals.indexOf(value as string)) } } else { const vals = getMultiSelectValues(schema) labels = vals.map(v => getMultiSelectLabel(schema, v).toLowerCase()) } if (key.rightArrow) { setExpandedAccordion(name) setAccordionOptionIndex(startIdx) return } // Typeahead: expand and jump to matching option if (_input && !key.leftArrow) { runTypeahead(_input, labels, i => { setExpandedAccordion(name) setAccordionOptionIndex(i) }) return } return } // Backspace: text fields when empty if (key.backspace) { if (isEditingTextField && textInputValue === '') { unsetField(name) return } } // Text field Enter is handled by TextInput's onSubmit }, { isActive: true }, ) function validateRequired(): boolean { const requiredFields = requestedSchema.required || [] for (const fieldName of requiredFields) { const value = formValues[fieldName] if (value === undefined || value === null || value === '') { return false } if (Array.isArray(value) && value.length === 0) { return false } } return true } // Scroll windowing: compute visible field range // Overhead: ~9 lines (dialog chrome, buttons, footer). // Each field: ~3 lines (label + description + validation spacer). // NOTE(v2): Multi-select accordion expands to N+3 lines when open. // For now we assume 3 lines per field; an expanded accordion may // temporarily push content off-screen (terminal scrollback handles it). // To generalize: track per-field height (3 for collapsed, N+3 for // expanded multi-select) and compute a pixel-budget window instead // of a simple item-count window. const LINES_PER_FIELD = 3 const DIALOG_OVERHEAD = 14 const maxVisibleFields = Math.max( 2, Math.floor((rows - DIALOG_OVERHEAD) / LINES_PER_FIELD), ) const scrollWindow = useMemo(() => { const total = schemaFields.length if (total <= maxVisibleFields) { return { start: 0, end: total } } // When buttons are focused (currentFieldIndex undefined), pin to end const focusIdx = currentFieldIndex ?? total - 1 let start = Math.max(0, focusIdx - Math.floor(maxVisibleFields / 2)) const end = Math.min(start + maxVisibleFields, total) // Adjust start if we hit the bottom start = Math.max(0, end - maxVisibleFields) return { start, end } }, [schemaFields.length, maxVisibleFields, currentFieldIndex]) const hasFieldsAbove = scrollWindow.start > 0 const hasFieldsBelow = scrollWindow.end < schemaFields.length function renderFormFields(): React.ReactNode { if (!schemaFields.length) return null return ( {hasFieldsAbove && ( {figures.arrowUp} {scrollWindow.start} more above )} {schemaFields .slice(scrollWindow.start, scrollWindow.end) .map((field, visibleIdx) => { const index = scrollWindow.start + visibleIdx const { name, schema, isRequired } = field const isActive = index === currentFieldIndex && !focusedButton const value = formValues[name] const hasValue = value !== undefined && (!Array.isArray(value) || value.length > 0) const error = validationErrors[name] // Checkbox: spinner → ⚠ error → ✔ set → * required → space const isResolving = resolvingFields.has(name) const checkbox = isResolving ? ( ) : error ? ( {figures.warning} ) : hasValue ? ( {figures.tick} ) : isRequired ? ( * ) : ( ) // Selection color matches field status const selectionColor = error ? 'error' : hasValue ? 'success' : isRequired ? 'error' : 'suggestion' const activeColor = isActive ? selectionColor : undefined const label = ( {schema.title || name} ) // Render the value portion based on field type let valueContent: React.ReactNode let accordionContent: React.ReactNode = null if (isMultiSelectEnumSchema(schema)) { const msValues = getMultiSelectValues(schema) const selected = (value as string[] | undefined) ?? [] const isExpanded = expandedAccordion === name && isActive if (isExpanded) { valueContent = {figures.triangleDownSmall} accordionContent = ( {msValues.map((optVal, optIdx) => { const optLabel = getMultiSelectLabel(schema, optVal) const isChecked = selected.includes(optVal) const isFocused = optIdx === accordionOptionIndex return ( {isFocused ? figures.pointer : ' '} {isChecked ? figures.checkboxOn : figures.checkboxOff} {optLabel} ) })} ) } else { // Collapsed: ▸ arrow then comma-joined selected items const arrow = isActive ? ( {figures.triangleRightSmall} ) : null if (selected.length > 0) { const displayLabels = selected.map(v => getMultiSelectLabel(schema, v), ) valueContent = ( {arrow} {displayLabels.join(', ')} ) } else { valueContent = ( {arrow} not set ) } } } else if (isEnumSchema(schema)) { const enumValues = getEnumValues(schema) const isExpanded = expandedAccordion === name && isActive if (isExpanded) { valueContent = {figures.triangleDownSmall} accordionContent = ( {enumValues.map((optVal, optIdx) => { const optLabel = getEnumLabel(schema, optVal) const isSelected = value === optVal const isFocused = optIdx === accordionOptionIndex return ( {isFocused ? figures.pointer : ' '} {isSelected ? figures.radioOn : figures.radioOff} {optLabel} ) })} ) } else { // Collapsed: ▸ arrow then current value const arrow = isActive ? ( {figures.triangleRightSmall} ) : null if (hasValue) { valueContent = ( {arrow} {getEnumLabel(schema, value as string)} ) } else { valueContent = ( {arrow} not set ) } } } else if (schema.type === 'boolean') { if (isActive) { valueContent = hasValue ? ( {value ? figures.checkboxOn : figures.checkboxOff} ) : ( {figures.checkboxOff} ) } else { valueContent = hasValue ? ( {value ? figures.checkboxOn : figures.checkboxOff} ) : ( not set ) } } else if (isTextField(schema)) { if (isActive) { valueContent = ( ) } else { const displayValue = hasValue && isDateTimeSchema(schema) ? formatDateDisplay(String(value), schema) : String(value) valueContent = hasValue ? ( {displayValue} ) : ( not set ) } } else { valueContent = hasValue ? ( {String(value)} ) : ( not set ) } return ( {isActive ? figures.pointer : ' '} {checkbox} {label} : {valueContent} {accordionContent} {schema.description && ( {schema.description} )} {error ? ( {error} ) : ( )} ) })} {hasFieldsBelow && ( {figures.arrowDown} {schemaFields.length - scrollWindow.end} more below )} ) } return ( onResponse('cancel')} isCancelActive={(!currentField || !!focusedButton) && !expandedAccordion} inputGuide={exitState => exitState.pending ? ( Press {exitState.keyName} again to exit ) : ( {currentField && ( )} {currentField && currentField.schema.type === 'boolean' && ( )} {currentField && isEnumSchema(currentField.schema) && (expandedAccordion ? ( ) : ( ))} {currentField && isMultiSelectEnumSchema(currentField.schema) && (expandedAccordion ? ( ) : ( ))} ) } > {renderFormFields()} {focusedButton === 'accept' ? figures.pointer : ' '} {' Accept '} {focusedButton === 'decline' ? figures.pointer : ' '} {' Decline'} ) } function ElicitationURLDialog({ event, onResponse, onWaitingDismiss, }: { event: ElicitationRequestEvent onResponse: Props['onResponse'] onWaitingDismiss: Props['onWaitingDismiss'] }): React.ReactNode { const { serverName, signal, waitingState } = event const urlParams = event.params as ElicitRequestURLParams const { message, url } = urlParams const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt') const phaseRef = useRef<'prompt' | 'waiting'>('prompt') const [focusedButton, setFocusedButton] = useState< 'accept' | 'decline' | 'open' | 'action' | 'cancel' >('accept') const showCancel = waitingState?.showCancel ?? false useNotifyAfterTimeout( 'Claude Code needs your input', 'elicitation_url_dialog', ) useRegisterOverlay('elicitation-url') // Keep refs in sync for use in abort handler (avoids re-registering listener) phaseRef.current = phase const onWaitingDismissRef = useRef(onWaitingDismiss) onWaitingDismissRef.current = onWaitingDismiss useEffect(() => { const handleAbort = () => { if (phaseRef.current === 'waiting') { onWaitingDismissRef.current?.('cancel') } else { onResponse('cancel') } } if (signal.aborted) { handleAbort() return } signal.addEventListener('abort', handleAbort) return () => signal.removeEventListener('abort', handleAbort) }, [signal, onResponse]) // Parse URL to highlight the domain let domain = '' let urlBeforeDomain = '' let urlAfterDomain = '' try { const parsed = new URL(url) domain = parsed.hostname const domainStart = url.indexOf(domain) urlBeforeDomain = url.slice(0, domainStart) urlAfterDomain = url.slice(domainStart + domain.length) } catch { domain = url } // Auto-dismiss when the server sends a completion notification (sets completed flag) useEffect(() => { if (phase === 'waiting' && event.completed) { onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss') } }, [phase, event.completed, onWaitingDismiss, showCancel]) const handleAccept = useCallback(() => { void openBrowser(url) onResponse('accept') setPhase('waiting') phaseRef.current = 'waiting' setFocusedButton('open') }, [onResponse, url]) // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw input for button navigation useInput((_input, key) => { if (phase === 'prompt') { if (key.leftArrow || key.rightArrow) { setFocusedButton(prev => (prev === 'accept' ? 'decline' : 'accept')) return } if (key.return) { if (focusedButton === 'accept') { handleAccept() } else { onResponse('decline') } } } else { // waiting phase — cycle through buttons type ButtonName = 'accept' | 'decline' | 'open' | 'action' | 'cancel' const waitingButtons: readonly ButtonName[] = showCancel ? ['open', 'action', 'cancel'] : ['open', 'action'] if (key.leftArrow || key.rightArrow) { setFocusedButton(prev => { const idx = waitingButtons.indexOf(prev) const delta = key.rightArrow ? 1 : -1 return waitingButtons[ (idx + delta + waitingButtons.length) % waitingButtons.length ]! }) return } if (key.return) { if (focusedButton === 'open') { void openBrowser(url) } else if (focusedButton === 'cancel') { onWaitingDismiss?.('cancel') } else { onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss') } } } }) if (phase === 'waiting') { const actionLabel = waitingState?.actionLabel ?? 'Continue without waiting' return ( onWaitingDismiss?.('cancel')} isCancelActive inputGuide={exitState => exitState.pending ? ( Press {exitState.keyName} again to exit ) : ( ) } > {urlBeforeDomain} {domain} {urlAfterDomain} Waiting for the server to confirm completion… {focusedButton === 'open' ? figures.pointer : ' '} {' Reopen URL '} {focusedButton === 'action' ? figures.pointer : ' '} {` ${actionLabel}`} {showCancel && ( <> {focusedButton === 'cancel' ? figures.pointer : ' '} {' Cancel'} )} ) } return ( onResponse('cancel')} isCancelActive inputGuide={exitState => exitState.pending ? ( Press {exitState.keyName} again to exit ) : ( ) } > {urlBeforeDomain} {domain} {urlAfterDomain} {focusedButton === 'accept' ? figures.pointer : ' '} {' Accept '} {focusedButton === 'decline' ? figures.pointer : ' '} {' Decline'} ) }