Files
orcs-code/src/components/mcp/ElicitationDialog.tsx
Anandan 462a985d7e Remove embedded source map directives from tracked sources (#329)
Inline base64 source maps had been checked into tracked src files. This strips those comments from the repository without changing runtime behavior or adding ongoing guardrails, per the requested one-time cleanup scope.

Constraint: Keep this change limited to tracked source cleanup only
Rejected: Add CI/source verification guard | user requested one-time cleanup only
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If these directives reappear, fix the producing transform instead of reintroducing repo-side cleanup code
Tested: rg -n "sourceMappingURL" ., bun run smoke, bun run verify:privacy, bun run test:provider, npm run test:provider-recommendation
Not-tested: bun run typecheck (repository has many pre-existing unrelated failures)

Co-authored-by: anandh8x <test@example.com>
2026-04-04 21:19:27 +08:00

1169 lines
46 KiB
TypeScript

import { c as _c } from "react-compiler-runtime";
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<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() {
const $ = _c(4);
const [frame, setFrame] = useState(0);
let t0;
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = () => {
const timer = setInterval(setFrame, 80, advanceSpinnerFrame);
return () => clearInterval(timer);
};
t1 = [];
$[0] = t0;
$[1] = t1;
} else {
t0 = $[0];
t1 = $[1];
}
useEffect(t0, t1);
const t2 = RESOLVING_SPINNER_CHARS[frame];
let t3;
if ($[2] !== t2) {
t3 = <Text color="warning">{t2}</Text>;
$[2] = t2;
$[3] = t3;
} else {
t3 = $[3];
}
return t3;
}
/** 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(t0) {
const $ = _c(7);
const {
event,
onResponse,
onWaitingDismiss
} = t0;
if (event.params.mode === "url") {
let t1;
if ($[0] !== event || $[1] !== onResponse || $[2] !== onWaitingDismiss) {
t1 = <ElicitationURLDialog event={event} onResponse={onResponse} onWaitingDismiss={onWaitingDismiss} />;
$[0] = event;
$[1] = onResponse;
$[2] = onWaitingDismiss;
$[3] = t1;
} else {
t1 = $[3];
}
return t1;
}
let t1;
if ($[4] !== event || $[5] !== onResponse) {
t1 = <ElicitationFormDialog event={event} onResponse={onResponse} />;
$[4] = event;
$[5] = onResponse;
$[6] = t1;
} else {
t1 = $[6];
}
return t1;
}
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_0, propSchema_0] of Object.entries(requestedSchema.properties)) {
if (isTextField(propSchema_0) && propSchema_0?.default !== undefined) {
const validation = validateElicitationInput(String(propSchema_0.default), propSchema_0);
if (!validation.isValid && validation.error) {
initialErrors[propName_0] = 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_0 = formValues[field.name];
const text = val_0 !== undefined ? String(val_0) : '';
setTextInputValue(text);
setTextInputCursorOffset(text.length);
}
}, [schemaFields, formValues]);
function validateMultiSelect(fieldName: string, schema_0: PrimitiveSchemaDefinition) {
if (!isMultiSelectEnumSchema(schema_0)) return;
const selected = formValues[fieldName] as string[] | undefined ?? [];
const fieldRequired = schemaFields.find(f => f.name === fieldName)?.isRequired ?? false;
const min = schema_0.minItems;
const max = schema_0.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_0: string, value: number | string | boolean | string[] | undefined) {
setFormValues(prev => {
const next = {
...prev
};
if (value === undefined) {
delete next[fieldName_0];
} else {
next[fieldName_0] = value;
}
return next;
});
// Clear "required" error when a value is provided
if (value !== undefined && validationErrors[fieldName_0] === 'This field is required') {
updateValidationError(fieldName_0);
}
}
function updateValidationError(fieldName_1: string, error?: string) {
setValidationErrors(prev_0 => {
const next_0 = {
...prev_0
};
if (error) {
next_0[fieldName_1] = error;
} else {
delete next_0[fieldName_1];
}
return next_0;
});
}
function unsetField(fieldName_2: string) {
if (!fieldName_2) return;
setField(fieldName_2, undefined);
updateValidationError(fieldName_2);
setTextInputValue('');
setTextInputCursorOffset(0);
}
function commitTextField(fieldName_3: string, schema_1: PrimitiveSchemaDefinition, value_0: string) {
const trimmedValue = value_0.trim();
// Empty input for non-plain-string types means unset
if (trimmedValue === '' && (schema_1.type !== 'string' || 'format' in schema_1 && schema_1.format !== undefined)) {
unsetField(fieldName_3);
return;
}
if (trimmedValue === '') {
// Empty plain string — keep or unset depending on whether it was set
if (formValues[fieldName_3] !== undefined) {
setField(fieldName_3, '');
}
return;
}
const validation_0 = validateElicitationInput(value_0, schema_1);
setField(fieldName_3, validation_0.isValid ? validation_0.value : value_0);
updateValidationError(fieldName_3, validation_0.isValid ? undefined : validation_0.error);
}
function resolveFieldAsync(fieldName_4: string, schema_2: PrimitiveSchemaDefinition, rawValue: string) {
if (!signal) return;
// Abort any existing resolution for this field
const existing = resolveAbortRef.current.get(fieldName_4);
if (existing) {
existing.abort();
}
const controller_0 = new AbortController();
resolveAbortRef.current.set(fieldName_4, controller_0);
setResolvingFields(prev_1 => new Set(prev_1).add(fieldName_4));
void validateElicitationInputAsync(rawValue, schema_2, controller_0.signal).then(result => {
resolveAbortRef.current.delete(fieldName_4);
setResolvingFields(prev_2 => {
const next_1 = new Set(prev_2);
next_1.delete(fieldName_4);
return next_1;
});
if (controller_0.signal.aborted) return;
if (result.isValid) {
setField(fieldName_4, result.value);
updateValidationError(fieldName_4);
// Update the text input if we're still on this field
const isoText = String(result.value);
setTextInputValue(prev_3 => {
// Only replace if the field is still showing the raw input
if (prev_3 === rawValue) {
setTextInputCursorOffset(isoText.length);
return isoText;
}
return prev_3;
});
} else {
// Keep raw text, show validation error
updateValidationError(fieldName_4, result.error);
}
}, () => {
resolveAbortRef.current.delete(fieldName_4);
setResolvingFields(prev_4 => {
const next_2 = new Set(prev_4);
next_2.delete(fieldName_4);
return next_2;
});
});
}
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_5 = currentField.name;
const schema_3 = currentField.schema;
dateDebounceRef.current = setTimeout((dateDebounceRef_0, resolveFieldAsync_0, fieldName_6, schema_4, newValue_0) => {
dateDebounceRef_0.current = undefined;
resolveFieldAsync_0(fieldName_6, schema_4, newValue_0);
}, 2000, dateDebounceRef, resolveFieldAsync, fieldName_5, schema_3, 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_0 = enumTypeaheadRef.current;
if (ta_0.timer !== undefined) clearTimeout(ta_0.timer);
ta_0.buffer += char.toLowerCase();
ta_0.timer = setTimeout(resetTypeahead, 2000, ta_0);
const match = labels.findIndex(l => l.startsWith(ta_0.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_1 = formValues[currentField.name];
setTextInputValue(val_1 !== undefined ? String(val_1) : '');
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_0 = 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_0.includes(optionValue) ? selected_0.filter(v => v !== optionValue) : [...selected_0, optionValue];
const newValue_1 = newSelected.length > 0 ? newSelected : undefined;
setField(currentField.name, newValue_1);
const min_0 = msSchema.minItems;
const max_0 = msSchema.maxItems;
if (min_0 !== undefined && newSelected.length < min_0 && (newSelected.length > 0 || currentField.isRequired)) {
updateValidationError(currentField.name, `Select at least ${min_0} ${plural(min_0, 'item')}`);
} else if (max_0 !== undefined && newSelected.length > max_0) {
updateValidationError(currentField.name, `Select at most ${max_0} ${plural(max_0, 'item')}`);
} else {
updateValidationError(currentField.name);
}
}
return;
}
if (key.return) {
// Check (not toggle) the focused item, then collapse and advance
const optionValue_0 = msValues[accordionOptionIndex];
if (optionValue_0 !== undefined && !selected_0.includes(optionValue_0)) {
setField(currentField.name, [...selected_0, optionValue_0]);
}
setExpandedAccordion(undefined);
handleNavigation('down');
return;
}
if (_input) {
const labels_0 = msValues.map(v_0 => getMultiSelectLabel(msSchema, v_0).toLowerCase());
runTypeahead(_input, labels_0, 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_1 = enumValues[accordionOptionIndex];
if (optionValue_1 !== undefined) {
setField(currentField.name, optionValue_1);
}
setExpandedAccordion(undefined);
return;
}
// Enter: select, collapse, and move to next field
if (key.return) {
const optionValue_2 = enumValues[accordionOptionIndex];
if (optionValue_2 !== undefined) {
setField(currentField.name, optionValue_2);
}
setExpandedAccordion(undefined);
handleNavigation('down');
return;
}
if (_input) {
const labels_1 = enumValues.map(v_1 => getEnumLabel(enumSchema, v_1).toLowerCase());
runTypeahead(_input, labels_1, 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_0 = requestedSchema.required || [];
for (const fieldName_7 of requiredFields_0) {
if (formValues[fieldName_7] === undefined) {
updateValidationError(fieldName_7, 'This field is required');
}
}
const firstBadIndex = schemaFields.findIndex(f_0 => requiredFields_0.includes(f_0.name) && formValues[f_0.name] === undefined || validationErrors[f_0.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_1 = enumTypeaheadRef.current;
ta_1.buffer = '';
if (ta_1.timer !== undefined) {
clearTimeout(ta_1.timer);
ta_1.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: schema_5,
name: name_0
} = currentField;
const value_1 = formValues[name_0];
// Boolean: Space to toggle, Enter to move on
if (schema_5.type === 'boolean') {
if (_input === ' ') {
setField(name_0, value_1 === undefined ? true : !value_1);
return;
}
if (key.return) {
handleNavigation('down');
return;
}
if (key.backspace && value_1 !== undefined) {
unsetField(name_0);
return;
}
// y/n typeahead
if (_input && !key.return) {
runTypeahead(_input, ['yes', 'no'], i => setField(name_0, i === 0));
return;
}
return;
}
// Enum or multi-select (collapsed) — accordion style
if (isEnumSchema(schema_5) || isMultiSelectEnumSchema(schema_5)) {
if (key.return) {
handleNavigation('down');
return;
}
if (key.backspace && value_1 !== undefined) {
unsetField(name_0);
return;
}
// Compute option labels + initial focus index for rightArrow expand.
// Single-select focuses on the current value; multi-select starts at 0.
let labels_2: string[];
let startIdx = 0;
if (isEnumSchema(schema_5)) {
const vals = getEnumValues(schema_5);
labels_2 = vals.map(v_2 => getEnumLabel(schema_5, v_2).toLowerCase());
if (value_1 !== undefined) {
startIdx = Math.max(0, vals.indexOf(value_1 as string));
}
} else {
const vals_0 = getMultiSelectValues(schema_5);
labels_2 = vals_0.map(v_3 => getMultiSelectLabel(schema_5, v_3).toLowerCase());
}
if (key.rightArrow) {
setExpandedAccordion(name_0);
setAccordionOptionIndex(startIdx);
return;
}
// Typeahead: expand and jump to matching option
if (_input && !key.leftArrow) {
runTypeahead(_input, labels_2, i_0 => {
setExpandedAccordion(name_0);
setAccordionOptionIndex(i_0);
});
return;
}
return;
}
// Backspace: text fields when empty
if (key.backspace) {
if (isEditingTextField && textInputValue === '') {
unsetField(name_0);
return;
}
}
// Text field Enter is handled by TextInput's onSubmit
}, {
isActive: true
});
function validateRequired(): boolean {
const requiredFields_1 = requestedSchema.required || [];
for (const fieldName_8 of requiredFields_1) {
const value_2 = formValues[fieldName_8];
if (value_2 === undefined || value_2 === null || value_2 === '') {
return false;
}
if (Array.isArray(value_2) && value_2.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_0, visibleIdx) => {
const index_0 = scrollWindow.start + visibleIdx;
const {
name: name_1,
schema: schema_6,
isRequired
} = field_0;
const isActive = index_0 === currentFieldIndex && !focusedButton;
const value_3 = formValues[name_1];
const hasValue = value_3 !== undefined && (!Array.isArray(value_3) || value_3.length > 0);
const error_0 = validationErrors[name_1];
// Checkbox: spinner → ⚠ error → ✔ set → * required → space
const isResolving = resolvingFields.has(name_1);
const checkbox = isResolving ? <ResolvingSpinner /> : error_0 ? <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_0 ? 'error' : hasValue ? 'success' : isRequired ? 'error' : 'suggestion';
const activeColor = isActive ? selectionColor : undefined;
const label = <Text color={activeColor} bold={isActive}>
{schema_6.title || name_1}
</Text>;
// Render the value portion based on field type
let valueContent: React.ReactNode;
let accordionContent: React.ReactNode = null;
if (isMultiSelectEnumSchema(schema_6)) {
const msValues_0 = getMultiSelectValues(schema_6);
const selected_1 = value_3 as string[] | undefined ?? [];
const isExpanded = expandedAccordion === name_1 && isActive;
if (isExpanded) {
valueContent = <Text dimColor>{figures.triangleDownSmall}</Text>;
accordionContent = <Box flexDirection="column" marginLeft={6}>
{msValues_0.map((optVal, optIdx) => {
const optLabel = getMultiSelectLabel(schema_6, optVal);
const isChecked = selected_1.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_1.length > 0) {
const displayLabels = selected_1.map(v_4 => getMultiSelectLabel(schema_6, v_4));
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_6)) {
const enumValues_0 = getEnumValues(schema_6);
const isExpanded_0 = expandedAccordion === name_1 && isActive;
if (isExpanded_0) {
valueContent = <Text dimColor>{figures.triangleDownSmall}</Text>;
accordionContent = <Box flexDirection="column" marginLeft={6}>
{enumValues_0.map((optVal_0, optIdx_0) => {
const optLabel_0 = getEnumLabel(schema_6, optVal_0);
const isSelected = value_3 === optVal_0;
const isFocused_0 = optIdx_0 === accordionOptionIndex;
return <Box key={optVal_0} gap={1}>
<Text color="suggestion">
{isFocused_0 ? figures.pointer : ' '}
</Text>
<Text color={isSelected ? 'success' : undefined}>
{isSelected ? figures.radioOn : figures.radioOff}
</Text>
<Text color={isFocused_0 ? 'suggestion' : undefined} bold={isFocused_0}>
{optLabel_0}
</Text>
</Box>;
})}
</Box>;
} else {
// Collapsed: ▸ arrow then current value
const arrow_0 = isActive ? <Text dimColor>{figures.triangleRightSmall} </Text> : null;
if (hasValue) {
valueContent = <Text>
{arrow_0}
<Text color={activeColor} bold={isActive}>
{getEnumLabel(schema_6, value_3 as string)}
</Text>
</Text>;
} else {
valueContent = <Text>
{arrow_0}
<Text dimColor italic>
not set
</Text>
</Text>;
}
}
} else if (schema_6.type === 'boolean') {
if (isActive) {
valueContent = hasValue ? <Text color={activeColor} bold>
{value_3 ? figures.checkboxOn : figures.checkboxOff}
</Text> : <Text dimColor>{figures.checkboxOff}</Text>;
} else {
valueContent = hasValue ? <Text>
{value_3 ? figures.checkboxOn : figures.checkboxOff}
</Text> : <Text dimColor italic>
not set
</Text>;
}
} else if (isTextField(schema_6)) {
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_6) ? formatDateDisplay(String(value_3), schema_6) : String(value_3);
valueContent = hasValue ? <Text>{displayValue}</Text> : <Text dimColor italic>
not set
</Text>;
}
} else {
valueContent = hasValue ? <Text>{String(value_3)}</Text> : <Text dimColor italic>
not set
</Text>;
}
return <Box key={name_1} flexDirection="column">
<Box gap={1}>
<Text color={selectionColor}>
{isActive ? figures.pointer : ' '}
</Text>
{checkbox}
<Box>
{label}
<Text color={activeColor}>: </Text>
{valueContent}
</Box>
</Box>
{accordionContent}
{schema_6.description && <Box marginLeft={6}>
<Text dimColor>{schema_6.description}</Text>
</Box>}
<Box marginLeft={6} height={1}>
{error_0 ? <Text color="error" italic>
{error_0}
</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_0 => {
const idx = waitingButtons.indexOf(prev_0);
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_0 => exitState_0.pending ? <Text>Press {exitState_0.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>;
}