UNPKG

askeroo

Version:

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

543 lines 27.9 kB
import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime"; import { useState, useEffect, useRef, useCallback, useMemo, } from "react"; import { flushSync } from "react-dom"; import { useInput } from "ink"; import { RecursiveGroupContainer } from "./RecursiveGroupContainer.js"; import { RootContainer } from "./RootContainer.js"; import { globalRegistry } from "../core/registry.js"; import { PromptTreeManager } from "../core/prompt-tree.js"; import { setTreeManager, notifyTreeChanged, } from "../built-ins/completed-fields/completed-fields-store.js"; import { applyInkRenderingFix } from "../utils/ink-rendering-fix.js"; import { notifyExternalStateChange } from "../core/plugin-state-context.js"; // Tree-based state management export function PromptApp({ onReady, runtime }) { // Core UI state const [currentPrompt, setCurrentPrompt] = useState(null); const [treeRevision, setTreeRevision] = useState(0); const [renderKey, setRenderKey] = useState(0); // Force complete remount on back navigation // Helper function to clone tree structure without circular references const cloneTreeForFreezing = useCallback((tree) => { const clonedNodeIndex = new Map(); // First pass: clone all nodes without parent/children references const cloneNode = (node) => { if (clonedNodeIndex.has(node.id)) { return clonedNodeIndex.get(node.id); } const clonedNode = { ...node, children: [], // Will be populated in second pass parent: undefined, // Will be set in second pass }; clonedNodeIndex.set(node.id, clonedNode); return clonedNode; }; // Clone all nodes tree.nodeIndex.forEach((node) => { cloneNode(node); }); // Second pass: restore relationships tree.nodeIndex.forEach((originalNode) => { const clonedNode = clonedNodeIndex.get(originalNode.id); // Restore children clonedNode.children = originalNode.children.map((child) => clonedNodeIndex.get(child.id)); // Restore parent if (originalNode.parent) { clonedNode.parent = clonedNodeIndex.get(originalNode.parent.id); } }); // Clone the tree structure return { root: clonedNodeIndex.get(tree.root.id), nodeIndex: clonedNodeIndex, history: tree.history.map((node) => clonedNodeIndex.get(node.id)), }; }, []); // Function to freeze the current prompt const freezeCurrentPrompt = useCallback(() => { if (currentPrompt) { // Get the current active node const activeNode = treeManagerRef.current.getActiveNode(); if (activeNode) { // Mark the node as frozen (keeping it active but frozen) activeNode.frozen = true; // Keep it active so it renders in its active state // activeNode.active remains true } // Clear current prompt to allow new prompts to be displayed after the frozen one setCurrentPrompt(null); // Trigger re-render to reflect the frozen state setTreeRevision((prev) => prev + 1); } }, [currentPrompt]); // Global Ctrl+C handler useInput((input, key) => { if (key.ctrl && input === "c") { // Only freeze current prompt if there are cancel callbacks // If no onCancel is defined, let the CLI exit immediately if (runtime && runtime.hasCancelCallbacks && runtime.hasCancelCallbacks()) { freezeCurrentPrompt(); } // Then call the runtime's cancel handler if (runtime && runtime.handleCtrlC) { runtime.handleCtrlC(); } } }); // Tree management const treeManagerRef = useRef(new PromptTreeManager()); // Consolidated refs - grouped by purpose const internalRefs = useRef({ resolver: null, resolversByPromptId: new Map(), // Store resolvers by prompt ID to handle async auto-submissions isNavigatingBack: false, firstFieldId: null, hintsByPromptId: new Map(), }); // Memoized tree to ensure UI updates when tree structure changes const currentTree = useMemo(() => { return treeManagerRef.current.getTree(); }, [treeRevision]); // Set tree manager for CompletedFields plugin (once on mount) useEffect(() => { setTreeManager(treeManagerRef.current); }, []); // Handler for when fields provide hint text - stores per prompt ID const handleHintChange = useCallback((hint) => { if (currentPrompt?.id) { // Only store non-null hints to prevent flicker during back navigation // When a field becomes inactive, plugins send null, but we want to // preserve the last known hint so it displays immediately on back nav if (hint !== null) { internalRefs.current.hintsByPromptId.set(currentPrompt.id, hint); // Use flushSync to make hint update synchronous and prevent blinking flushSync(() => { setTreeRevision((prev) => prev + 1); notifyTreeChanged(); }); } } }, [currentPrompt?.id]); // Get hint for the ACTIVE node from the tree (not currentPrompt) // This ensures hint stays in sync with tree state during navigation // The flushSync in handleHintChange ensures new hint appears synchronously const activeNode = treeManagerRef.current.getActiveNode(); const currentHintText = activeNode?.id ? internalRefs.current.hintsByPromptId.get(activeNode.id) || null : null; useEffect(() => { const promptFn = (request) => { return new Promise((resolve) => { // Handle flow completion first if (request.type === "completeFlow") { // Handle flow completion - mark all fields with values as completed treeManagerRef.current.traverseDepthFirst((node) => { if (node.type === "field" && node.value !== undefined) { node.completed = true; // excludeFromCompleted only affects completedFields component, not completion state } }); // Deactivate the current active node so it can show in completed state const activeNode = treeManagerRef.current.getActiveNode(); if (activeNode) { activeNode.active = false; } // Trigger re-render setTreeRevision((prev) => prev + 1); notifyTreeChanged(); // Clear the current prompt so the active field transitions to completed state setCurrentPrompt(null); // Resolve immediately resolve(undefined); return; } // Handle group completion event (to trigger re-render) if (request.type === "groupCompleted") { // Mark the group as completed in the UI tree (since runtime tree is separate) const groupNode = treeManagerRef.current.getNode(request.id); if (groupNode) { groupNode.completed = true; groupNode.active = false; // For phased groups, ensure the last field is marked as completed if (groupNode.flow === "phased") { const allFields = groupNode.children.filter((child) => child.type === "field"); const lastField = allFields[allFields.length - 1]; const activeField = allFields.find((field) => field.active); // Deactivate any currently active field if (activeField) { activeField.active = false; } // Mark the last field as completed if (lastField && !lastField.completed) { lastField.completed = true; lastField.active = false; } } // In phased flows, activate the next sibling when this group completes const parent = groupNode.parent; if (parent && parent.flow === "phased") { const siblings = parent.children; const currentIndex = siblings.indexOf(groupNode); const nextSibling = siblings[currentIndex + 1]; if (nextSibling && !nextSibling.active && !nextSibling.completed) { nextSibling.active = true; nextSibling.visited = true; } } } // Trigger re-render to show updated tree state setTreeRevision((prev) => prev + 1); // Notify prompt state context (useExternalState handles caching) notifyExternalStateChange(); resolve(undefined); return; } if (request.type !== "group" && internalRefs.current.firstFieldId === null && !globalRegistry.shouldAutoSubmit(request.type)) { internalRefs.current.firstFieldId = request.id; } // IMPORTANT: Assign resolver BEFORE updating state // This ensures the useEffect can access the resolver immediately internalRefs.current.resolver = resolve; // Also store by prompt ID to handle async auto-submissions correctly internalRefs.current.resolversByPromptId.set(request.id, resolve); // Use flushSync to ensure both updates happen atomically in a single render flushSync(() => { // Update currentPrompt and handle back navigation cleanup const shouldCleanup = internalRefs.current.isNavigatingBack && request.type !== "group"; if (shouldCleanup) { internalRefs.current.isNavigatingBack = false; // Update tree: un-complete the field we're navigating back to const node = treeManagerRef.current.getNode(request.id); if (node && node.completed) { node.completed = false; // completionHistoryRef removed - tree tracks completion } setCurrentPrompt(request); } else { setCurrentPrompt(request); } }); // NEW: Add prompt to tree structure and activate it try { // SIMPLIFIED: Runtime provides explicit parent via request.groupName // This is the groupStack context from the runtime (which knows the exact parent) // However, if we're in cancel mode, force all prompts to root level const isInCancelMode = runtime && runtime.isInCancelMode && runtime.isInCancelMode(); const explicitParent = isInCancelMode ? null : request.groupName || null; const addedNode = treeManagerRef.current.addPromptRequest(request, explicitParent); // Mark the node as a cancel prompt if we're in cancel mode if (isInCancelMode && addedNode) { addedNode.isCancelPrompt = true; } // Activate the prompt in the tree (crucial for rendering) if (request.type !== "group") { // Special handling: if there's a frozen prompt, don't deactivate it // Let both the frozen prompt and new prompt be active const activeNode = treeManagerRef.current.getActiveNode(); if (activeNode && activeNode.frozen) { // Don't navigate away from frozen prompt, just add the new prompt as active too const newNode = treeManagerRef.current.getNode(request.id); if (newNode) { newNode.active = true; newNode.visited = true; // Add to history without deactivating the frozen node const currentHistory = treeManagerRef.current.getNavigationPath(); if (!currentHistory.includes(newNode)) { // Manually add to history without using navigateTo treeManagerRef.current .getTree() .history.push(newNode); } } } else { treeManagerRef.current.navigateTo(request.id); } } else { // Mark group as visited and active so it shows up in the tree const groupNode = treeManagerRef.current.getNode(request.id); if (groupNode) { groupNode.visited = true; groupNode.active = true; // Mark as active so it renders } } // Force re-render to reflect tree changes setTreeRevision((prev) => prev + 1); notifyTreeChanged(); } catch (error) { console.warn("Tree management error (non-critical during migration):", error); } // All field properties are now tracked in the tree automatically // Legacy tracking code removed - tree already has all this information // Group order is tracked in tree structure - no need for separate tracking // staticGroupsRef removed - use node.flow === "static" from tree instead // currentGroup removed - use treeManager.getCurrentGroupId() if needed }); }; onReady(promptFn); }, [onReady]); // Extract tree navigation and synchronization logic to be used by all back navigation paths const performTreeBackNavigation = useCallback(() => { try { const canGoBack = treeManagerRef.current.canGoBack(); if (canGoBack) { // Perform tree navigation const result = treeManagerRef.current.goBack(); if (result.success) { // Apply Ink rendering timing fix AFTER tree mutation but BEFORE React update // This ensures tree is in consistent state before triggering re-render applyInkRenderingFix(); // Use flushSync to ensure tree state updates are applied synchronously flushSync(() => { setTreeRevision((prev) => prev + 1); // Force a complete remount to prevent duplication issues // when Ink has to redraw the entire terminal (e.g., in short terminals) setRenderKey((prev) => prev + 1); }); // Notify stores about tree changes notifyTreeChanged(); notifyExternalStateChange(); // Legacy compatibility } } } catch (error) { console.warn("Tree navigation error (non-critical during migration):", error); } }, []); // ========== NEW UNIFIED HANDLER ========== // Consolidates all field actions (submit, back, preserve-back, clear-group-back) const handleFieldAction = useCallback((action) => { // Extract prompt ID from action if available (for async auto-submissions) const promptId = action.__promptId; let effectivePrompt = promptId ? { id: promptId, type: currentPrompt?.type } : currentPrompt; // If we have a promptId but no current prompt (e.g., during cancel), look up the node in the tree if (promptId && !currentPrompt) { const node = treeManagerRef.current.getNode(promptId); if (node) { effectivePrompt = { id: promptId, type: node.type }; } } // Skip if no effective prompt or if it's a group if (!effectivePrompt || effectivePrompt.type === "group") { return; } const nodeId = effectivePrompt.id; let resolveValue; // Update tree based on action type switch (action.type) { case "submit": { // Regular submit - update tree with value // All submitted fields should be marked as completed // excludeFromCompleted only affects completedFields component, not the field's own completion state try { // Determine submission type based on submitted value // Components submit with format: { type: "auto" | "skip" | "programmatic", value?: any, deferCompletion?: boolean } // Regular values (non-objects or objects without type) are treated as manual submissions let submissionType = "manual"; let actualValue = action.value; let deferCompletion = false; if (typeof action.value === "object" && action.value !== null && "type" in action.value) { // New consistent format: { type: "auto", value?: any, deferCompletion?: boolean } submissionType = action.value.type; actualValue = action.value.value; deferCompletion = action.value.deferCompletion === true; } else { // Regular value = manual submission submissionType = "manual"; actualValue = action.value; } // Update node - only mark as completed if deferCompletion is false treeManagerRef.current.updateNode(nodeId, { value: actualValue, visited: true, completed: !deferCompletion, // Defer completion if requested submissionType: submissionType, }); } catch (error) { console.warn("Tree update error (non-critical):", error); } internalRefs.current.isNavigatingBack = false; // Extract actual value from submission object or use as-is resolveValue = typeof action.value === "object" && action.value !== null && "type" in action.value ? action.value.value : action.value; break; } case "back": { // Back navigation - performTreeBackNavigation handles its own state update performTreeBackNavigation(); internalRefs.current.isNavigatingBack = true; resolveValue = { __back: true }; // IMPORTANT: Don't trigger additional re-render, performTreeBackNavigation already did it // Resolve promise and return early const resolver = internalRefs.current.resolver; internalRefs.current.resolver = null; resolver?.(resolveValue); return; } case "preserve-back": { // Preserve value and go back - performTreeBackNavigation handles its own state update const node = treeManagerRef.current.getNode(nodeId); if (node) { node.value = action.value; node.visited = true; node.completed = true; // Mark as completed when preserving // Mark as manual since user is explicitly preserving node.submissionType = "manual"; } performTreeBackNavigation(); internalRefs.current.isNavigatingBack = true; resolveValue = { __back: true }; // IMPORTANT: Don't trigger additional re-render, performTreeBackNavigation already did it // Resolve promise and return early const resolver = internalRefs.current.resolver; internalRefs.current.resolver = null; resolver?.(resolveValue); return; } case "clear-group-back": { // Clear group and go back treeManagerRef.current.clearGroupAndGoBack(nodeId); // Apply Ink rendering timing fix AFTER tree mutation but BEFORE React update applyInkRenderingFix(); // Use flushSync for immediate state update flushSync(() => { setTreeRevision((prev) => prev + 1); // Force a complete remount to prevent duplication issues setRenderKey((prev) => prev + 1); }); // Notify stores about tree changes notifyTreeChanged(); notifyExternalStateChange(); // Legacy compatibility internalRefs.current.isNavigatingBack = true; resolveValue = { __back: true }; // Resolve promise and return early (already triggered re-render) const resolver = internalRefs.current.resolver; internalRefs.current.resolver = null; resolver?.(resolveValue); return; } } // Trigger re-render for submit actions only setTreeRevision((prev) => prev + 1); // Notify stores about tree changes notifyTreeChanged(); notifyExternalStateChange(); // Legacy compatibility // Resolve the promise // For async auto-submissions, use the resolver from the map const resolver = nodeId && internalRefs.current.resolversByPromptId.has(nodeId) ? internalRefs.current.resolversByPromptId.get(nodeId) : internalRefs.current.resolver; // Clean up if (internalRefs.current.resolver === resolver) { internalRefs.current.resolver = null; } if (nodeId) { internalRefs.current.resolversByPromptId.delete(nodeId); } resolver?.(resolveValue); }, [currentPrompt, performTreeBackNavigation]); // ========== SUBMIT HANDLER (with special value handling) ========== const handleSubmit = useCallback((value) => { // Check if submission includes a captured prompt ID (for async auto-submissions) const promptId = value?.__promptId || currentPrompt?.id; // Use the resolver from the map if available, otherwise fall back to current resolver const resolver = promptId ? internalRefs.current.resolversByPromptId.get(promptId) || internalRefs.current.resolver : internalRefs.current.resolver; if (!resolver) return; // Groups resolve immediately without going through field action if (currentPrompt && currentPrompt.type === "group") { const r = internalRefs.current.resolver; internalRefs.current.resolver = null; r?.(value); return; } // Handle special navigation values that plugins might send if (typeof value === "object" && value?.__clearGroupAndBack) { handleFieldAction({ type: "clear-group-back" }); return; } if (typeof value === "object" && value?.__preserveAndBack) { handleFieldAction({ type: "preserve-back", value: value.value, }); return; } // Regular submit - pass along the captured prompt ID if available handleFieldAction({ type: "submit", value, __promptId: value?.__promptId, // Pass the captured prompt ID for async auto-submissions }); }, [currentPrompt, handleFieldAction]); const handleBack = useCallback(() => { handleFieldAction({ type: "back" }); }, [handleFieldAction]); // Handler to mark a node as completed (for deferred completion) const handleComplete = useCallback((promptId) => { try { const node = treeManagerRef.current.getNode(promptId); if (node && !node.completed) { treeManagerRef.current.updateNode(promptId, { completed: true, }); // Trigger re-render setTreeRevision((prev) => prev + 1); notifyTreeChanged(); notifyExternalStateChange(); } } catch (error) { console.warn("Error marking node as completed:", error); } }, []); // Auto-resolve group prompts useEffect(() => { if (currentPrompt?.type === "group" && internalRefs.current.resolver) { const r = internalRefs.current.resolver; internalRefs.current.resolver = null; r(undefined); } }, [currentPrompt]); // Note: Hints are now stored per prompt ID, so they don't leak between prompts // Auto-submit prompts simply won't set a hint, so currentHintText will be null for them // Primary rendering: Tree-based recursive rendering with support for frozen prompts // When custom root container exists, RecursiveGroupContainer will use it internally for the root node // Main content - renders both frozen prompt (if any) and current tree const content = (_jsx(_Fragment, { children: currentTree.root && (_jsx(RecursiveGroupContainer, { item: currentTree.root, treeManager: treeManagerRef.current, onSubmit: handleSubmit, onBack: handleBack, onComplete: handleComplete, onHintChange: handleHintChange, hintText: currentHintText, hintsByPromptId: internalRefs.current.hintsByPromptId }, renderKey)) })); // Otherwise, wrap in the default RootContainer const hasCustomContainer = !!globalThis.__customRootContainer; if (hasCustomContainer) { return content; } // Default rendering with RootContainer wrapper return _jsx(RootContainer, { children: content }); } //# sourceMappingURL=PromptApp.js.map