askeroo
Version:
A modern CLI prompt library with flow control, history navigation, and conditional prompts
630 lines • 23.6 kB
JavaScript
// 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