mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 13:55:50 +00:00
1545 lines
49 KiB
TypeScript
1545 lines
49 KiB
TypeScript
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 '@anthropic/ink'
|
|
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, Dialog, KeyboardShortcutHint } from '@anthropic/ink'
|
|
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<typeof setTimeout> | 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 <Spinner /> from ../Spinner.js: that one renders in a
|
|
* <Box width={2}> 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 <Text color="warning">{RESOLVING_SPINNER_CHARS[frame]}</Text>
|
|
}
|
|
|
|
/** 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 (
|
|
<ElicitationURLDialog
|
|
event={event}
|
|
onResponse={onResponse}
|
|
onWaitingDismiss={onWaitingDismiss}
|
|
/>
|
|
)
|
|
}
|
|
|
|
return <ElicitationFormDialog event={event} onResponse={onResponse} />
|
|
}
|
|
|
|
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<string, string | number | boolean | string[]>
|
|
>(() => {
|
|
const initialValues: Record<string, string | number | boolean | string[]> =
|
|
{}
|
|
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<string, string>
|
|
>(() => {
|
|
const initialErrors: Record<string, string> = {}
|
|
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<Set<string>>(
|
|
() => 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<ReturnType<typeof setTimeout> | undefined>(
|
|
undefined,
|
|
)
|
|
const resolveAbortRef = useRef<Map<string, AbortController>>(new Map())
|
|
const enumTypeaheadRef = useRef({
|
|
buffer: '',
|
|
timer: undefined as ReturnType<typeof setTimeout> | 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 (
|
|
<Box flexDirection="column">
|
|
{hasFieldsAbove && (
|
|
<Box marginLeft={2}>
|
|
<Text dimColor>
|
|
{figures.arrowUp} {scrollWindow.start} more above
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
{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 ? (
|
|
<ResolvingSpinner />
|
|
) : error ? (
|
|
<Text color="error">{figures.warning}</Text>
|
|
) : hasValue ? (
|
|
<Text color="success" dimColor={!isActive}>
|
|
{figures.tick}
|
|
</Text>
|
|
) : isRequired ? (
|
|
<Text color="error">*</Text>
|
|
) : (
|
|
<Text> </Text>
|
|
)
|
|
|
|
// Selection color matches field status
|
|
const selectionColor = error
|
|
? 'error'
|
|
: hasValue
|
|
? 'success'
|
|
: isRequired
|
|
? 'error'
|
|
: 'suggestion'
|
|
|
|
const activeColor = isActive ? selectionColor : undefined
|
|
|
|
const label = (
|
|
<Text color={activeColor} bold={isActive}>
|
|
{schema.title || name}
|
|
</Text>
|
|
)
|
|
|
|
// 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 = <Text dimColor>{figures.triangleDownSmall}</Text>
|
|
accordionContent = (
|
|
<Box flexDirection="column" marginLeft={6}>
|
|
{msValues.map((optVal, optIdx) => {
|
|
const optLabel = getMultiSelectLabel(schema, optVal)
|
|
const isChecked = selected.includes(optVal)
|
|
const isFocused = optIdx === accordionOptionIndex
|
|
return (
|
|
<Box key={optVal} gap={1}>
|
|
<Text color="suggestion">
|
|
{isFocused ? figures.pointer : ' '}
|
|
</Text>
|
|
<Text color={isChecked ? 'success' : undefined}>
|
|
{isChecked
|
|
? figures.checkboxOn
|
|
: figures.checkboxOff}
|
|
</Text>
|
|
<Text
|
|
color={isFocused ? 'suggestion' : undefined}
|
|
bold={isFocused}
|
|
>
|
|
{optLabel}
|
|
</Text>
|
|
</Box>
|
|
)
|
|
})}
|
|
</Box>
|
|
)
|
|
} else {
|
|
// Collapsed: ▸ arrow then comma-joined selected items
|
|
const arrow = isActive ? (
|
|
<Text dimColor>{figures.triangleRightSmall} </Text>
|
|
) : null
|
|
if (selected.length > 0) {
|
|
const displayLabels = selected.map(v =>
|
|
getMultiSelectLabel(schema, v),
|
|
)
|
|
valueContent = (
|
|
<Text>
|
|
{arrow}
|
|
<Text color={activeColor} bold={isActive}>
|
|
{displayLabels.join(', ')}
|
|
</Text>
|
|
</Text>
|
|
)
|
|
} else {
|
|
valueContent = (
|
|
<Text>
|
|
{arrow}
|
|
<Text dimColor italic>
|
|
not set
|
|
</Text>
|
|
</Text>
|
|
)
|
|
}
|
|
}
|
|
} else if (isEnumSchema(schema)) {
|
|
const enumValues = getEnumValues(schema)
|
|
const isExpanded = expandedAccordion === name && isActive
|
|
|
|
if (isExpanded) {
|
|
valueContent = <Text dimColor>{figures.triangleDownSmall}</Text>
|
|
accordionContent = (
|
|
<Box flexDirection="column" marginLeft={6}>
|
|
{enumValues.map((optVal, optIdx) => {
|
|
const optLabel = getEnumLabel(schema, optVal)
|
|
const isSelected = value === optVal
|
|
const isFocused = optIdx === accordionOptionIndex
|
|
return (
|
|
<Box key={optVal} gap={1}>
|
|
<Text color="suggestion">
|
|
{isFocused ? figures.pointer : ' '}
|
|
</Text>
|
|
<Text color={isSelected ? 'success' : undefined}>
|
|
{isSelected ? figures.radioOn : figures.radioOff}
|
|
</Text>
|
|
<Text
|
|
color={isFocused ? 'suggestion' : undefined}
|
|
bold={isFocused}
|
|
>
|
|
{optLabel}
|
|
</Text>
|
|
</Box>
|
|
)
|
|
})}
|
|
</Box>
|
|
)
|
|
} else {
|
|
// Collapsed: ▸ arrow then current value
|
|
const arrow = isActive ? (
|
|
<Text dimColor>{figures.triangleRightSmall} </Text>
|
|
) : null
|
|
if (hasValue) {
|
|
valueContent = (
|
|
<Text>
|
|
{arrow}
|
|
<Text color={activeColor} bold={isActive}>
|
|
{getEnumLabel(schema, value as string)}
|
|
</Text>
|
|
</Text>
|
|
)
|
|
} else {
|
|
valueContent = (
|
|
<Text>
|
|
{arrow}
|
|
<Text dimColor italic>
|
|
not set
|
|
</Text>
|
|
</Text>
|
|
)
|
|
}
|
|
}
|
|
} else if (schema.type === 'boolean') {
|
|
if (isActive) {
|
|
valueContent = hasValue ? (
|
|
<Text color={activeColor} bold>
|
|
{value ? figures.checkboxOn : figures.checkboxOff}
|
|
</Text>
|
|
) : (
|
|
<Text dimColor>{figures.checkboxOff}</Text>
|
|
)
|
|
} else {
|
|
valueContent = hasValue ? (
|
|
<Text>
|
|
{value ? figures.checkboxOn : figures.checkboxOff}
|
|
</Text>
|
|
) : (
|
|
<Text dimColor italic>
|
|
not set
|
|
</Text>
|
|
)
|
|
}
|
|
} else if (isTextField(schema)) {
|
|
if (isActive) {
|
|
valueContent = (
|
|
<TextInput
|
|
value={textInputValue}
|
|
onChange={handleTextInputChange}
|
|
onSubmit={handleTextInputSubmit}
|
|
placeholder={`Type something\u{2026}`}
|
|
columns={Math.min(columns - 20, 60)}
|
|
cursorOffset={textInputCursorOffset}
|
|
onChangeCursorOffset={setTextInputCursorOffset}
|
|
focus
|
|
showCursor
|
|
/>
|
|
)
|
|
} else {
|
|
const displayValue =
|
|
hasValue && isDateTimeSchema(schema)
|
|
? formatDateDisplay(String(value), schema)
|
|
: String(value)
|
|
valueContent = hasValue ? (
|
|
<Text>{displayValue}</Text>
|
|
) : (
|
|
<Text dimColor italic>
|
|
not set
|
|
</Text>
|
|
)
|
|
}
|
|
} else {
|
|
valueContent = hasValue ? (
|
|
<Text>{String(value)}</Text>
|
|
) : (
|
|
<Text dimColor italic>
|
|
not set
|
|
</Text>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Box key={name} flexDirection="column">
|
|
<Box gap={1}>
|
|
<Text color={selectionColor}>
|
|
{isActive ? figures.pointer : ' '}
|
|
</Text>
|
|
{checkbox}
|
|
<Box>
|
|
{label}
|
|
<Text color={activeColor}>: </Text>
|
|
{valueContent}
|
|
</Box>
|
|
</Box>
|
|
{accordionContent}
|
|
{schema.description && (
|
|
<Box marginLeft={6}>
|
|
<Text dimColor>{schema.description}</Text>
|
|
</Box>
|
|
)}
|
|
<Box marginLeft={6} height={1}>
|
|
{error ? (
|
|
<Text color="error" italic>
|
|
{error}
|
|
</Text>
|
|
) : (
|
|
<Text> </Text>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)
|
|
})}
|
|
{hasFieldsBelow && (
|
|
<Box marginLeft={2}>
|
|
<Text dimColor>
|
|
{figures.arrowDown} {schemaFields.length - scrollWindow.end} more
|
|
below
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
title={`MCP server \u201c${serverName}\u201d requests your input`}
|
|
subtitle={`\n${message}`}
|
|
color="permission"
|
|
onCancel={() => onResponse('cancel')}
|
|
isCancelActive={(!currentField || !!focusedButton) && !expandedAccordion}
|
|
inputGuide={exitState =>
|
|
exitState.pending ? (
|
|
<Text>Press {exitState.keyName} again to exit</Text>
|
|
) : (
|
|
<Byline>
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Confirmation"
|
|
fallback="Esc"
|
|
description="cancel"
|
|
/>
|
|
<KeyboardShortcutHint shortcut="↑↓" action="navigate" />
|
|
{currentField && (
|
|
<KeyboardShortcutHint shortcut="Backspace" action="unset" />
|
|
)}
|
|
{currentField && currentField.schema.type === 'boolean' && (
|
|
<KeyboardShortcutHint shortcut="Space" action="toggle" />
|
|
)}
|
|
{currentField &&
|
|
isEnumSchema(currentField.schema) &&
|
|
(expandedAccordion ? (
|
|
<KeyboardShortcutHint shortcut="Space" action="select" />
|
|
) : (
|
|
<KeyboardShortcutHint shortcut="→" action="expand" />
|
|
))}
|
|
{currentField &&
|
|
isMultiSelectEnumSchema(currentField.schema) &&
|
|
(expandedAccordion ? (
|
|
<KeyboardShortcutHint shortcut="Space" action="toggle" />
|
|
) : (
|
|
<KeyboardShortcutHint shortcut="→" action="expand" />
|
|
))}
|
|
</Byline>
|
|
)
|
|
}
|
|
>
|
|
<Box flexDirection="column">
|
|
{renderFormFields()}
|
|
<Box>
|
|
<Text color="success">
|
|
{focusedButton === 'accept' ? figures.pointer : ' '}
|
|
</Text>
|
|
<Text
|
|
bold={focusedButton === 'accept'}
|
|
color={focusedButton === 'accept' ? 'success' : undefined}
|
|
dimColor={focusedButton !== 'accept'}
|
|
>
|
|
{' Accept '}
|
|
</Text>
|
|
<Text color="error">
|
|
{focusedButton === 'decline' ? figures.pointer : ' '}
|
|
</Text>
|
|
<Text
|
|
bold={focusedButton === 'decline'}
|
|
color={focusedButton === 'decline' ? 'error' : undefined}
|
|
dimColor={focusedButton !== 'decline'}
|
|
>
|
|
{' Decline'}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<Dialog
|
|
title={`MCP server \u201c${serverName}\u201d \u2014 waiting for completion`}
|
|
subtitle={`\n${message}`}
|
|
color="permission"
|
|
onCancel={() => onWaitingDismiss?.('cancel')}
|
|
isCancelActive
|
|
inputGuide={exitState =>
|
|
exitState.pending ? (
|
|
<Text>Press {exitState.keyName} again to exit</Text>
|
|
) : (
|
|
<Byline>
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Confirmation"
|
|
fallback="Esc"
|
|
description="cancel"
|
|
/>
|
|
<KeyboardShortcutHint shortcut="\u2190\u2192" action="switch" />
|
|
</Byline>
|
|
)
|
|
}
|
|
>
|
|
<Box flexDirection="column">
|
|
<Box marginBottom={1} flexDirection="column">
|
|
<Text>
|
|
{urlBeforeDomain}
|
|
<Text bold>{domain}</Text>
|
|
{urlAfterDomain}
|
|
</Text>
|
|
</Box>
|
|
<Box marginBottom={1}>
|
|
<Text dimColor italic>
|
|
Waiting for the server to confirm completion…
|
|
</Text>
|
|
</Box>
|
|
<Box>
|
|
<Text color="success">
|
|
{focusedButton === 'open' ? figures.pointer : ' '}
|
|
</Text>
|
|
<Text
|
|
bold={focusedButton === 'open'}
|
|
color={focusedButton === 'open' ? 'success' : undefined}
|
|
dimColor={focusedButton !== 'open'}
|
|
>
|
|
{' Reopen URL '}
|
|
</Text>
|
|
<Text color="success">
|
|
{focusedButton === 'action' ? figures.pointer : ' '}
|
|
</Text>
|
|
<Text
|
|
bold={focusedButton === 'action'}
|
|
color={focusedButton === 'action' ? 'success' : undefined}
|
|
dimColor={focusedButton !== 'action'}
|
|
>
|
|
{` ${actionLabel}`}
|
|
</Text>
|
|
{showCancel && (
|
|
<>
|
|
<Text> </Text>
|
|
<Text color="error">
|
|
{focusedButton === 'cancel' ? figures.pointer : ' '}
|
|
</Text>
|
|
<Text
|
|
bold={focusedButton === 'cancel'}
|
|
color={focusedButton === 'cancel' ? 'error' : undefined}
|
|
dimColor={focusedButton !== 'cancel'}
|
|
>
|
|
{' Cancel'}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Dialog
|
|
title={`MCP server \u201c${serverName}\u201d wants to open a URL`}
|
|
subtitle={`\n${message}`}
|
|
color="permission"
|
|
onCancel={() => onResponse('cancel')}
|
|
isCancelActive
|
|
inputGuide={exitState =>
|
|
exitState.pending ? (
|
|
<Text>Press {exitState.keyName} again to exit</Text>
|
|
) : (
|
|
<Byline>
|
|
<ConfigurableShortcutHint
|
|
action="confirm:no"
|
|
context="Confirmation"
|
|
fallback="Esc"
|
|
description="cancel"
|
|
/>
|
|
<KeyboardShortcutHint shortcut="\u2190\u2192" action="switch" />
|
|
</Byline>
|
|
)
|
|
}
|
|
>
|
|
<Box flexDirection="column">
|
|
<Box marginBottom={1} flexDirection="column">
|
|
<Text>
|
|
{urlBeforeDomain}
|
|
<Text bold>{domain}</Text>
|
|
{urlAfterDomain}
|
|
</Text>
|
|
</Box>
|
|
<Box>
|
|
<Text color="success">
|
|
{focusedButton === 'accept' ? figures.pointer : ' '}
|
|
</Text>
|
|
<Text
|
|
bold={focusedButton === 'accept'}
|
|
color={focusedButton === 'accept' ? 'success' : undefined}
|
|
dimColor={focusedButton !== 'accept'}
|
|
>
|
|
{' Accept '}
|
|
</Text>
|
|
<Text color="error">
|
|
{focusedButton === 'decline' ? figures.pointer : ' '}
|
|
</Text>
|
|
<Text
|
|
bold={focusedButton === 'decline'}
|
|
color={focusedButton === 'decline' ? 'error' : undefined}
|
|
dimColor={focusedButton !== 'decline'}
|
|
>
|
|
{' Decline'}
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
</Dialog>
|
|
)
|
|
}
|