UNPKG

askeroo

Version:

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

630 lines 23.6 kB
// Unified prompt tree structure for managing prompt state and navigation export class PromptTreeManager { tree; constructor() { // Initialize with empty root node const rootNode = { id: "root", type: "group", completed: false, visited: false, active: false, depth: 0, children: [], properties: {}, }; this.tree = { root: rootNode, nodeIndex: new Map([["root", rootNode]]), history: [], }; } // Tree access getTree() { return this.tree; } setTree(tree) { this.tree = tree; } getActiveNode() { // Active node is always the last item in history // This eliminates the need for a separate activeNode pointer return this.tree.history.length > 0 ? this.tree.history[this.tree.history.length - 1] : null; } getNode(id) { return this.tree.nodeIndex.get(id); } // Tree building addNode(node, parentId) { // Check if node already exists const existingNode = this.tree.nodeIndex.get(node.id); if (existingNode) { return existingNode; } const newNode = { ...node, children: [], parent: undefined, }; // Find parent and add as child const parent = parentId ? this.tree.nodeIndex.get(parentId) : this.tree.root; if (parent) { newNode.parent = parent; // Use provided depth if available, otherwise calculate from parent newNode.depth = node.depth !== undefined ? node.depth : parent.depth + 1; parent.children.push(newNode); } else { // If specified parent doesn't exist, fall back to root if (parentId && parentId !== "root") { console.warn(`PromptTree: Parent '${parentId}' not found for node '${node.id}', adding to root`); } if (node.id !== "root") { newNode.parent = this.tree.root; newNode.depth = node.depth !== undefined ? node.depth : 1; this.tree.root.children.push(newNode); } } // Add to index after establishing parent relationship this.tree.nodeIndex.set(node.id, newNode); return newNode; } updateNode(id, updates) { const node = this.tree.nodeIndex.get(id); if (!node) return false; // Check if this is a value update that might affect conditional logic const isValueUpdate = updates.value !== undefined; const wasValueChanged = isValueUpdate && node.value !== updates.value; Object.assign(node, updates); // If this is a field value change, remove any future nodes that might be conditional // This handles the case where changing a field affects conditional groups if (wasValueChanged && node.type === "field") { this.clearAllNodesAddedAfterField(node); } return true; } // ========== NAVIGATION ========== navigateTo(nodeId) { const node = this.tree.nodeIndex.get(nodeId); if (!node) { return { success: false, reason: `Node ${nodeId} not found` }; } // Clean up if we're revisiting from a different path if (this.shouldClearStateOnNavigateTo(node)) { this.clearFutureStateFrom(node); } // Activate the target node this.activateNode(node); return { success: true, node }; } goBack() { if (this.tree.history.length <= 1) { return { success: false, reason: "No previous node in history" }; } // Get current and previous nodes const currentNode = this.tree.history.pop(); const previousNode = this.tree.history[this.tree.history.length - 1]; if (!previousNode) { // Restore current node to history if we can't go back if (currentNode) { this.tree.history.push(currentNode); } return { success: false, reason: "No previous node available" }; } // Check if navigation is allowed if (currentNode?.allowBack === false) { // Restore current node to history if (currentNode) { this.tree.history.push(currentNode); } return { success: false, reason: "Navigation back not allowed from current node", }; } // Activate previous node first so cleanup methods know which node to preserve const currentActive = this.getActiveNode(); if (currentActive) { currentActive.active = false; } previousNode.active = true; previousNode.completed = false; // Reset completed state when going back to this node // Note: previousNode is already the last item in history after pop() // Clear future state: Remove all nodes that were added after the previous node this.clearFutureStateFrom(previousNode); return { success: true, node: previousNode }; } // ========== NAVIGATION HELPER METHODS ========== /** Determine if we should clear state when navigating to a node */ shouldClearStateOnNavigateTo(node) { // Already in history = same path, no cleanup needed if (this.tree.history.includes(node)) return false; // Not visited before = first time, no cleanup needed if (!node.visited) return false; // Forward navigation = not a revisit, no cleanup needed if (this.isForwardNavigation(node)) return false; // Everything else is a revisit from different path = cleanup needed return true; } /** Check if navigating to this node is forward navigation */ isForwardNavigation(node) { const active = this.getActiveNode(); if (!active) return true; // No active node = treat as forward // Same parent, check sibling order if (node.parent === active.parent) { const siblings = node.parent?.children || this.tree.root.children; const activeIndex = siblings.indexOf(active); const nodeIndex = siblings.indexOf(node); return nodeIndex > activeIndex; } // Moving to shallower depth (leaving nested group) if (node.depth < active.depth) { return this.isAncestorSibling(node, active); } return false; } /** Check if node is a sibling of an ancestor of active */ isAncestorSibling(node, active) { let ancestor = active.parent; while (ancestor) { if (ancestor.id === node.parent?.id) { // Target node is a sibling of our ancestor return true; } ancestor = ancestor.parent; } return false; } /** Activate a node (handles deactivation and history tracking) */ activateNode(node) { // Deactivate current active node const currentActive = this.getActiveNode(); if (currentActive) { currentActive.active = false; } // Activate new node node.active = true; node.visited = true; // Add to history if not already the last item const lastInHistory = this.tree.history[this.tree.history.length - 1]; if (!lastInHistory || lastInHistory.id !== node.id) { this.tree.history.push(node); } // Note: activeNode is now implicitly the last item in history } /** * Clear all fields in a group and go back * Used when user wants to clear a group and navigate back */ clearGroupAndGoBack(fieldId) { const fieldNode = this.getNode(fieldId); if (!fieldNode || fieldNode.type !== "field") { return { success: false, reason: "Field not found" }; } const groupNode = fieldNode.parent; if (!groupNode || groupNode.type !== "group" || groupNode.id === "root") { return { success: false, reason: "Field is not in a group" }; } // Remove all children from the group const childrenToRemove = [...groupNode.children]; childrenToRemove.forEach((child) => { this.removeNodeFromTree(child); }); // Navigate back return this.goBack(); } canGoBack() { if (this.tree.history.length <= 1) return false; const currentNode = this.getActiveNode(); return currentNode?.allowBack !== false; } // Find next active node in tree traversal order findNextActiveNode(fromNode) { const startNode = fromNode || this.getActiveNode(); if (!startNode) return null; // For group nodes, go to first child if (startNode.type === "group" && startNode.children.length > 0) { return startNode.children[0]; } // For field nodes, find next sibling or parent's next sibling return (this.findNextSibling(startNode) || this.findNextInParentChain(startNode)); } findNextSibling(node) { if (!node.parent) return null; const siblings = node.parent.children; const currentIndex = siblings.indexOf(node); if (currentIndex >= 0 && currentIndex < siblings.length - 1) { return siblings[currentIndex + 1]; } return null; } findNextInParentChain(node) { let current = node.parent; while (current) { const nextSibling = this.findNextSibling(current); if (nextSibling) { return nextSibling; } current = current.parent; } return null; } removeNodeFromTree(node) { // First remove all descendants recursively const childrenToRemove = [...node.children]; childrenToRemove.forEach((child) => this.removeNodeFromTree(child)); // Remove from parent's children array if (node.parent) { const siblingIndex = node.parent.children.indexOf(node); if (siblingIndex >= 0) { node.parent.children.splice(siblingIndex, 1); } } // Remove from node index this.tree.nodeIndex.delete(node.id); // Clear parent reference node.parent = undefined; // Clear children array node.children = []; // If this was the active node, remove from history const activeNode = this.getActiveNode(); if (activeNode === node) { this.tree.history.pop(); } } // ========== SIMPLIFIED NODE CLEANUP SYSTEM ========== /** * Clear future state from a node - keeps the node and everything before it in history * Used for back navigation and field value changes */ clearFutureStateFrom(nodeToKeep) { const nodesToKeep = new Set(["root"]); // Keep the node and its ancestor path this.addPathToRoot(nodeToKeep, nodesToKeep); // Keep all nodes in history up to this node (and their paths) const keepIndex = this.tree.history.indexOf(nodeToKeep); if (keepIndex !== -1) { for (let i = 0; i <= keepIndex; i++) { this.addPathToRoot(this.tree.history[i], nodesToKeep); } } // Remove all nodes not in the keep set const nodesToRemove = []; this.traverseDepthFirst((node) => { if (node.id !== "root" && !nodesToKeep.has(node.id)) { nodesToRemove.push(node); } }); // Remove deepest nodes first to maintain tree integrity nodesToRemove .sort((a, b) => b.depth - a.depth) .forEach((node) => this.removeNodeFromTree(node)); } /** * Clear all nodes added after a field's first visit (for value changes) */ clearAllNodesAddedAfterField(field) { const firstVisitIndex = this.tree.history.findIndex((n) => n.id === field.id); if (firstVisitIndex === -1) { // Node not in history, just clear from current state this.clearFutureStateFrom(field); return; } // Clear nodes and trim history to first visit this.clearFutureStateFrom(field); this.tree.history = this.tree.history.slice(0, firstVisitIndex + 1); // Reactivate the field const currentActive = this.getActiveNode(); if (currentActive && currentActive.id !== field.id) { currentActive.active = false; } field.active = true; } // ========== HELPER METHODS FOR CLEANUP ========== /** Add a node and all its ancestors to the keep set */ addPathToRoot(node, keepSet) { let current = node; while (current) { keepSet.add(current.id); current = current.parent; } } // Group-specific operations findParentGroup(node) { let current = node.parent; while (current) { if (current.type === "group") { return current; } current = current.parent; } return null; } getGroupChildren(groupId) { const group = this.tree.nodeIndex.get(groupId); if (!group || group.type !== "group") return []; return group.children; } // Tree traversal utilities traverseDepthFirst(visitor, startNode) { const start = startNode || this.tree.root; visitor(start); start.children.forEach((child) => this.traverseDepthFirst(visitor, child)); } findNodes(predicate) { const results = []; this.traverseDepthFirst((node) => { if (predicate(node)) { results.push(node); } }); return results; } // State queries getCompletedNodes() { return this.findNodes((node) => node.completed); } getVisitedNodes() { return this.findNodes((node) => node.visited); } getNodesByType(type) { return this.findNodes((node) => node.type === type); } getNodesByFieldType(fieldType) { return this.findNodes((node) => node.type === "field" && node.fieldType === fieldType); } // Utility methods getNavigationPath() { return [...this.tree.history]; } /** * Get the count of answers stored in the tree */ getAnswerCount() { let count = 0; for (const node of this.tree.nodeIndex.values()) { if (node.type === "field" && node.value !== undefined) { count++; } } return count; } /** * Clear future answers (for back navigation) * Removes answers from nodes that come after the current step */ clearFutureAnswers(currentStep) { const navigationPath = this.getNavigationPath(); const currentPrompts = new Set(navigationPath.slice(0, currentStep).map((node) => node.id)); for (const node of this.tree.nodeIndex.values()) { if (node.type === "field" && !currentPrompts.has(node.id)) { node.value = undefined; node.completed = false; } } } /** * Clear unreachable answers (after replay) * Removes answers from nodes that are not in the current flow */ clearUnreachableAnswers() { const reachableNodes = new Set(this.tree.nodeIndex.keys()); for (const node of this.tree.nodeIndex.values()) { if (node.type === "field" && node.value !== undefined) { // Keep answers only for nodes that are still in the tree if (!reachableNodes.has(node.id)) { node.value = undefined; node.completed = false; } } } } // Get the flow type of a group (for runtime queries) getGroupFlow(groupId) { return this.getNode(groupId)?.flow; } // Get the depth of a group (for runtime queries) getGroupDepth(groupId) { return this.getNode(groupId)?.depth; } clearHistoryAfter(nodeId) { const index = this.tree.history.findIndex((node) => node.id === nodeId); if (index >= 0) { this.tree.history = this.tree.history.slice(0, index + 1); } } // ========== Direct PromptRequest Handling ========== // These methods eliminate the need for PromptTreeAdapter /** * Add a prompt request (field or group) to the tree * @param request - The prompt request * @param explicitParentId - Explicit parent group ID from runtime (preferred) */ addPromptRequest(request, explicitParentId) { if (request.type === "group") { return this.addGroupRequest(request, explicitParentId); } else { return this.addFieldRequest(request, explicitParentId); } } /** * Add a group to the tree * @param request - Group request * @param explicitParentId - Explicit parent from runtime (which knows groupStack) */ addGroupRequest(request, explicitParentId) { // Determine parent - runtime provides explicit parent via groupStack let parentGroupId; if (explicitParentId && explicitParentId !== "root") { const parentExists = this.getNode(explicitParentId); if (parentExists) { parentGroupId = explicitParentId; } else { console.warn(`Explicit parent "${explicitParentId}" not found, using root`); parentGroupId = undefined; // Will default to root in addNode } } // If no explicit parent, it's a root-level group (parentGroupId = undefined) const groupNodeData = { id: request.id, type: "group", label: request.label, completed: false, visited: false, active: false, flow: request.flow || "progressive", enableArrowNavigation: request.enableArrowNavigation, discoveredFields: request.discoveredFields, allowBack: request.allowBack, autoSubmit: request.autoSubmit, properties: { ...request }, }; // Only include depth if explicitly provided, otherwise let addNode calculate it if (request.depth !== undefined) { groupNodeData.depth = request.depth; } const groupNode = this.addNode(groupNodeData, parentGroupId); return groupNode; } /** * Add a field to the tree * @param request - Field request * @param explicitParentId - Explicit parent from runtime (which knows groupStack) */ addFieldRequest(request, explicitParentId) { // Determine parent - prefer explicit parent from runtime, fallback to depth inference let parentId; if (explicitParentId && explicitParentId !== "root") { // Trust explicit parent from runtime (preferred path) const parentExists = this.getNode(explicitParentId); if (parentExists) { parentId = explicitParentId; } else { console.warn(`Explicit parent "${explicitParentId}" not found for field, using root`); parentId = "root"; } } else { // Use root if no explicit parent provided parentId = "root"; } const parentNode = this.getNode(parentId); const calculatedDepth = parentNode ? parentNode.depth + 1 : 1; const fieldNode = this.addNode({ id: request.id, type: "field", label: request.label, fieldType: request.type, completed: false, visited: false, active: false, depth: calculatedDepth, hideOnCompletion: request.hideOnCompletion, excludeFromCompleted: request.excludeFromCompleted, allowBack: request.allowBack, autoSubmit: request.autoSubmit, groupName: request.groupName, properties: { ...request }, }, parentId); return fieldNode; } getCurrentGroupId() { const activeNode = this.getActiveNode(); if (!activeNode) return null; if (activeNode.type === "group") { return activeNode.id; } const parentGroup = this.findParentGroup(activeNode); return parentGroup?.id || null; } // ========== State Synchronization ========== // Sync tree to legacy state format (for plugins that still need it) // syncToLegacyState() has been removed - use direct tree access instead // Use treeManager.traverseDepthFirst() and node properties to access state // ========== Submission Type Tracking ========== /** * Mark a node as submitted with a specific submission type * @param nodeId - The node ID to mark * @param submissionType - How the node was submitted */ markNodeSubmitted(nodeId, submissionType) { const node = this.getNode(nodeId); if (!node) return false; node.submissionType = submissionType; node.completed = true; return true; } /** * Get the submission type of a node * @param nodeId - The node ID to query * @returns The submission type, or undefined if not set */ getNodeSubmissionType(nodeId) { return this.getNode(nodeId)?.submissionType; } /** * Get the previous node in history (before the current active node) * @returns The previous node, or null if there is none */ getPreviousNode() { if (this.tree.history.length < 2) return null; return this.tree.history[this.tree.history.length - 2]; } /** * Check if back navigation should be allowed based on previous node's submission type * @returns true if back navigation is allowed, false otherwise */ canGoBackBasedOnSubmissionType() { const previousNode = this.getPreviousNode(); if (!previousNode) return false; // If previous node has no submission type, allow back navigation (backward compatible) if (!previousNode.submissionType) return true; // Auto-submitted prompts should NOT allow going back to them by default // (going back would just trigger them to auto-submit again) // Only allow if explicitly set to true if (previousNode.submissionType === "auto") { return previousNode.allowBack === true; } // All other submission types allow back navigation by default return previousNode.allowBack !== false; } /** * Enhanced canGoBack that considers submission types * @returns true if back navigation is allowed */ canGoBackEnhanced() { if (this.tree.history.length <= 1) return false; const currentNode = this.getActiveNode(); if (currentNode?.allowBack === false) return false; // Check previous node's submission type return this.canGoBackBasedOnSubmissionType(); } } //# sourceMappingURL=prompt-tree.js.map