UNPKG

askeroo

Version:

A modern CLI prompt library with flow control, history navigation, and conditional prompts

386 lines 21.8 kB
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; import { useState, useEffect } from "react"; import { Text, Box, useInput, Newline } from "ink"; import { createPrompt } from "../../core/registry.js"; import { useFieldReset } from "../../hooks/use-auto-submit.js"; import { TextInput } from "../../components/TextInput.js"; // Core radio input plugin export const radio = createPrompt({ type: "radio", // autoSubmit: false (default) - Requires user interaction component: ({ node, options, events }) => { const [selectedIndex, setSelectedIndex] = useState(() => { if (options.initialValue && options.options) { const index = (options.options || []).findIndex((option) => option.value === options.initialValue); return index >= 0 ? index : 0; } return 0; }); // Track current window position for edge-scrolling const [windowStart, setWindowStart] = useState(0); const [internalSearchQuery, setInternalSearchQuery] = useState(""); const [searchCursorPosition, setSearchCursorPosition] = useState(0); const [submitted, setSubmitted] = useState(false); const [validationError, setValidationError] = useState(null); const [filterModeActive, setFilterModeActive] = useState(false); const hasHiddenSearch = options.searchable === true; const hasFilterMode = options.searchable === "filter"; const showSearchInput = hasFilterMode && filterModeActive; const isSearchActive = hasHiddenSearch || (hasFilterMode && filterModeActive); const filteredOptions = isSearchActive && internalSearchQuery.trim() && options.options ? (options.options || []).filter((opt) => opt.label .toLowerCase() .includes(internalSearchQuery.toLowerCase()) || opt.value .toLowerCase() .includes(internalSearchQuery.toLowerCase())) : options.options || []; // Track if any options match the search const hasMatchingOptions = !isSearchActive || !internalSearchQuery.trim() || filteredOptions.length > 0; useEffect(() => { if (selectedIndex >= filteredOptions.length) { setSelectedIndex(Math.max(0, filteredOptions.length - 1)); } }, [internalSearchQuery, filteredOptions.length, selectedIndex]); useEffect(() => setWindowStart(0), [filteredOptions.length, options.maxVisible]); const disabled = node.state === "disabled"; useFieldReset(disabled, submitted, setSubmitted); // Calculate visible window for scrolling const getVisibleOptions = () => { if (!options.maxVisible || filteredOptions.length <= options.maxVisible) { return { visibleOptions: filteredOptions, startIndex: 0, showStartEllipsis: false, showEndEllipsis: false, }; } // Calculate visible window - scroll only when at edges let currentWindowStart = windowStart; let currentWindowEnd = Math.min(currentWindowStart + options.maxVisible, filteredOptions.length); // If selected index is at the bottom of current window, scroll down if (selectedIndex >= currentWindowEnd) { currentWindowStart = selectedIndex - options.maxVisible + 1; currentWindowEnd = selectedIndex + 1; } // If selected index is at the top of current window, scroll up else if (selectedIndex < currentWindowStart) { currentWindowStart = selectedIndex; currentWindowEnd = selectedIndex + options.maxVisible; } // Ensure we don't exceed bounds currentWindowStart = Math.max(0, currentWindowStart); currentWindowEnd = Math.min(filteredOptions.length, currentWindowEnd); // Adjust windowStart if we hit the end if (currentWindowEnd - currentWindowStart < options.maxVisible && currentWindowStart > 0) { currentWindowStart = Math.max(0, currentWindowEnd - options.maxVisible); } // Update window position state if it changed if (currentWindowStart !== windowStart) { setWindowStart(currentWindowStart); } const visibleOptions = filteredOptions.slice(currentWindowStart, currentWindowEnd); const showStartEllipsis = currentWindowStart > 0; const showEndEllipsis = currentWindowEnd < filteredOptions.length; return { visibleOptions, startIndex: currentWindowStart, showStartEllipsis, showEndEllipsis, }; }; useEffect(() => { if (options.initialValue !== undefined) { const idx = (options.options || []).findIndex((opt) => opt.value === options.initialValue); if (idx >= 0) setSelectedIndex(idx); } }, [options.initialValue, options.options]); const runValidation = async (val) => { if (!events.onValidate || node.state !== "active") { setValidationError(null); return true; } try { const result = await events.onValidate(val); setValidationError(result); return result === null; } catch { setValidationError("Validation error occurred"); return false; } }; useEffect(() => { if (!events.onHintChange) return; // Check if there's any hint content to show const hasAnyHint = node.state === "active" && ((!node.isFirstRootPrompt && node.allowBack) || hasHiddenSearch || hasFilterMode); events.onHintChange(hasAnyHint ? (_jsxs(_Fragment, { children: [_jsx(Newline, {}), !node.isFirstRootPrompt && node.allowBack && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "escape" }), " go back"] })), hasHiddenSearch && (_jsxs(_Fragment, { children: [!node.isFirstRootPrompt && node.allowBack ? ", " : "", _jsx(Text, { color: "yellow", children: "type" }), " to search"] })), hasFilterMode && (_jsxs(_Fragment, { children: [!node.isFirstRootPrompt && node.allowBack ? ", " : "", _jsx(Text, { color: "yellow", children: "shift+f" }), " filter"] }))] })) : null); }, [ node.state, node.isFirstRootPrompt, node.allowBack, hasHiddenSearch, hasFilterMode, showSearchInput, events.onHintChange, ]); useInput(async (input, key) => { if (disabled || submitted) return; // Toggle filter mode with Shift+F if (hasFilterMode && key.shift && (input === "f" || input === "F")) { const wasActive = filterModeActive; // Clear search first, then toggle mode if (wasActive) { setInternalSearchQuery(""); setSearchCursorPosition(0); setValidationError(null); } setFilterModeActive(!filterModeActive); return; } if (key.escape) { if (hasFilterMode && filterModeActive) { // Close filter mode setFilterModeActive(false); setInternalSearchQuery(""); setSearchCursorPosition(0); setValidationError(null); return; } if (isSearchActive && internalSearchQuery.trim()) { setInternalSearchQuery(""); setSearchCursorPosition(0); return; } if (node.allowBack && events.onBack) { events.onBack(); return; } } // Handle Ctrl+A (jump to top) and Ctrl+E (jump to bottom) // Same shortcuts as cursor movement in text inputs if (key.ctrl && input === "a") { const { startIndex } = getVisibleOptions(); setSelectedIndex(startIndex); return; } if (key.ctrl && input === "e") { const { visibleOptions, startIndex } = getVisibleOptions(); const lastVisibleIndex = startIndex + visibleOptions.length - 1; setSelectedIndex(lastVisibleIndex); return; } // Handle arrow navigation for groups if (node.enableArrowNavigation && events.onNavigate) { if (key.upArrow && !node.isFirstInGroup) { events.onNavigate("up"); return; } if (key.downArrow && !node.isLastInGroup) { events.onNavigate("down"); return; } } if (key.return && filteredOptions.length > 0) { const val = filteredOptions[selectedIndex].value; if (!(await runValidation(val))) return; setSubmitted(true); // Call user's onSubmit callback if provided and use return value if any let finalValue = val; if (options.onSubmit) { const result = options.onSubmit(val); if (result !== undefined) { finalValue = result; } } events.onSubmit?.(finalValue); return; } // Visible search input - arrow navigation for options if (showSearchInput) { if (key.upArrow || key.downArrow) { const maxIndex = filteredOptions.length - 1; const allowLoop = options.allowLoop ?? false; if (key.upArrow) { setSelectedIndex(allowLoop && selectedIndex === 0 ? maxIndex : Math.max(0, selectedIndex - 1)); } else { setSelectedIndex(allowLoop && selectedIndex === maxIndex ? 0 : Math.min(maxIndex, selectedIndex + 1)); } return; } } else { // Hidden search input - only for searchable: true if (hasHiddenSearch && input && input !== " " && input.length === 1 && !key.ctrl && !key.meta && !key.return && !key.escape && !key.upArrow && !key.downArrow && !key.leftArrow && !key.rightArrow) { setInternalSearchQuery(internalSearchQuery + input); return; } if (hasHiddenSearch && (key.backspace || key.delete || input === "\b")) { setInternalSearchQuery(internalSearchQuery.slice(0, -1)); return; } // Arrow navigation if (!node.enableArrowNavigation) { const maxIndex = filteredOptions.length - 1; const allowLoop = options.allowLoop ?? false; if (key.leftArrow || key.upArrow) { setSelectedIndex(allowLoop && selectedIndex === 0 ? maxIndex : Math.max(0, selectedIndex - 1)); return; } if (key.rightArrow || key.downArrow) { setSelectedIndex(allowLoop && selectedIndex === maxIndex ? 0 : Math.min(maxIndex, selectedIndex + 1)); return; } } } // Number selection (works in both modes) if (options.showNumbers) { const num = parseInt(input); if (!isNaN(num) && num >= 1 && num <= filteredOptions.length) { const idx = num - 1; setSelectedIndex(idx); const val = filteredOptions[idx].value; if (!(await runValidation(val))) return; setSubmitted(true); // Call user's onSubmit callback if provided and use return value if any let finalValue = val; if (options.onSubmit) { const result = options.onSubmit(val); if (result !== undefined) { finalValue = result; } } events.onSubmit?.(finalValue); return; } } }, { isActive: node.state === "active" && !submitted }); if (node.state === "completed" && node.completedValue !== undefined) { const opt = (options.options || []).find((o) => o.value === node.completedValue); return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: options.label }), _jsx(Text, { color: "blue", children: opt ? opt.label : node.completedValue })] })); } const renderLabel = (option, isSelected) => { if (!isSearchActive || !internalSearchQuery.trim()) return option.label; const query = internalSearchQuery.toLowerCase(); const label = option.label; const matchIndex = label.toLowerCase().indexOf(query); if (matchIndex === -1) return label; return (_jsxs(_Fragment, { children: [label.slice(0, matchIndex), _jsx(Text, { underline: true, color: isSelected ? "cyan" : option.color || "white", children: label.slice(matchIndex, matchIndex + query.length) }), label.slice(matchIndex + query.length)] })); }; const renderOption = (option, actualIndex, isInline) => { const isSelected = actualIndex === selectedIndex; const color = isSelected ? "cyan" : option.color || "gray"; return (_jsx(_Fragment, { children: _jsxs(Text, { color: color, children: [isSelected ? "●" : "○", " ", options.showNumbers && `${actualIndex + 1}. `, renderLabel(option, isSelected)] }) })); }; const navigateUp = () => { const maxIndex = filteredOptions.length - 1; setSelectedIndex(options.allowLoop && selectedIndex === 0 ? maxIndex : Math.max(0, selectedIndex - 1)); }; const navigateDown = () => { const maxIndex = filteredOptions.length - 1; setSelectedIndex(options.allowLoop && selectedIndex === maxIndex ? 0 : Math.min(maxIndex, selectedIndex + 1)); }; const handleShiftF = () => { if (hasFilterMode) { const wasActive = filterModeActive; // Clear search first, then toggle mode if (wasActive) { setInternalSearchQuery(""); setSearchCursorPosition(0); setValidationError(null); } setFilterModeActive(!filterModeActive); } }; return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", marginTop: node.isFirstInGroup ? 0 : 0, children: [_jsx(Text, { children: options.label }), showSearchInput && (_jsx(Box, { children: _jsx(TextInput, { value: internalSearchQuery, onChange: setInternalSearchQuery, cursorPosition: searchCursorPosition, onCursorPositionChange: setSearchCursorPosition, isActive: node.state === "active" && !submitted, color: "cyan", onUpArrow: navigateUp, onDownArrow: navigateDown, onShiftF: handleShiftF }) })), options.hintPosition === "side" ? (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexDirection: "column", width: 25, children: (() => { const { visibleOptions, startIndex, showStartEllipsis, showEndEllipsis, } = getVisibleOptions(); return (_jsxs(_Fragment, { children: [showStartEllipsis && (_jsx(Text, { color: "gray", children: "\u22EF" })), visibleOptions.map((option, visibleIndex) => { const actualIndex = startIndex + visibleIndex; return (_jsx(Box, { children: renderOption(option, actualIndex, false) }, option.value)); }), showEndEllipsis && (_jsx(Text, { color: "gray", children: "\u22EF" }))] })); })() }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: (() => { const { visibleOptions, startIndex, showStartEllipsis, showEndEllipsis, } = getVisibleOptions(); return (_jsxs(_Fragment, { children: [showStartEllipsis && (_jsx(Text, { color: "gray" })), visibleOptions.map((option, visibleIndex) => { const actualIndex = startIndex + visibleIndex; const isSelected = actualIndex === selectedIndex; return (_jsx(Text, { color: "gray", children: isSelected && option.hint ? option.hint : "" }, option.value)); }), showEndEllipsis && (_jsx(Text, { color: "gray" }))] })); })() })] })) : options.hintPosition === "inline-fixed" ? ((() => { const { visibleOptions, startIndex, showStartEllipsis, showEndEllipsis, } = getVisibleOptions(); return (_jsxs(_Fragment, { children: [showStartEllipsis && (_jsx(Text, { color: "gray", children: "\u22EF" })), visibleOptions.map((option, visibleIndex) => { const actualIndex = startIndex + visibleIndex; const isSelected = actualIndex === selectedIndex; return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { width: 25, children: renderOption(option, actualIndex, false) }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { color: "gray", children: isSelected && option.hint ? option.hint : "" }) })] }, option.value)); }), showEndEllipsis && (_jsx(Text, { color: "gray", children: "\u22EF" }))] })); })()) : ((() => { const { visibleOptions, startIndex, showStartEllipsis, showEndEllipsis, } = getVisibleOptions(); return (_jsxs(_Fragment, { children: [showStartEllipsis && (_jsx(Text, { color: "gray", children: "\u22EF" })), visibleOptions.map((option, visibleIndex) => { const actualIndex = startIndex + visibleIndex; const isSelected = actualIndex === selectedIndex; return (_jsxs(Box, { flexDirection: "row", children: [renderOption(option, actualIndex, false), options.hintPosition === "inline" && isSelected && option.hint && (_jsxs(Text, { color: "gray", children: [" ", option.hint] }))] }, option.value)); }), showEndEllipsis && (_jsx(Text, { color: "gray", children: "\u22EF" }))] })); })()), isSearchActive && !hasMatchingOptions && internalSearchQuery.trim() && (_jsxs(Text, { color: "red", children: ["No options match \"", internalSearchQuery, "\""] })), options.hintPosition === "bottom" && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "gray", children: filteredOptions[selectedIndex]?.hint || " " }) }, `hint-${selectedIndex}`))] }), validationError && (_jsx(Box, { children: _jsx(Text, { color: "red", children: validationError }) }))] })); }, }); //# sourceMappingURL=index.js.map