UNPKG

askeroo

Version:

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

572 lines 21.5 kB
/** * PromptRuntime - Main runtime class for executing prompt flows * * Orchestrates interactive CLI prompts with clean state management, * navigation, discovery, and plugin integration. */ import { debugLogger } from "../utils/logging.js"; import { globalRegistry } from "./registry.js"; import { IdGenerator } from "./id-generator.js"; import { RuntimeState } from "./runtime-state.js"; import { FieldDiscoveryService } from "./discovery-service.js"; import { PromptTreeManager } from "./prompt-tree.js"; const BACK = { __back: true }; export class PromptRuntime { // Core services idGenerator; state; fieldDiscovery; ui; tree; // For UI visualization // Engine for step execution engine; // Plugin prompt functions pluginPrompts = {}; // Cancel handling cancelCallbacks = []; sigintHandler = null; cancelModeActive = false; ctrlCPressCount = 0; // Public BACK token BACK = BACK; constructor(ui) { debugLogger.log("RUNTIME_CREATE", { ui: typeof ui }); this.ui = ui; this.idGenerator = new IdGenerator(); this.state = new RuntimeState(); this.tree = new PromptTreeManager(); this.fieldDiscovery = new FieldDiscoveryService(this.state); // Create engine with bound methods this.engine = { BACK, step: this.processPromptStep.bind(this), }; // Create dynamic prompt functions for plugins this.initializePluginPrompts(); // Set runtime reference in UI for re-discovery this.ui.setRuntime?.(this); // Set up SIGINT handler immediately so Ctrl+C always works this.setupCancelHandler(); } // ========== PUBLIC API ========== /** * Execute a prompt flow */ async executeFlow(flowDefinition) { debugLogger.log("FLOW_START", { currentIndex: this.state.getCurrentPromptIndex(), answersCount: this.state.getAnswerCount(), }); try { while (true) { // Prepare for replay this.state.prepareForReplay(); this.idGenerator.reset(); try { this.state.beginFlowExecution(); debugLogger.log("FLOW_EXECUTING", { isReplaying: this.state.isReplayingAnswers(), currentIndex: this.state.getCurrentPromptIndex(), }); const result = await flowDefinition({ BACK, ...this.pluginPrompts, }); this.state.endFlowExecution(); // If we've reached the end, we're done if (this.state.hasReachedEndOfFlow()) { debugLogger.log("FLOW_COMPLETE", { result, totalPrompts: this.state.getTotalPromptCount(), }); // Notify UI that the flow is complete this.ui.completeFlow?.(); // Add a small delay to allow the completion state to update await new Promise((resolve) => setTimeout(resolve, 100)); this.ui.cleanup?.(); return result; } } catch (e) { this.state.endFlowExecution(); if (e === BACK) { debugLogger.log("NAVIGATION_BACK", { currentIndex: this.state.getCurrentPromptIndex(), totalPrompts: this.state.getTotalPromptCount(), }); // Go back one step if (this.state.getCurrentPromptIndex() > 0) { this.state.returnToPreviousPrompt(); this.state.clearAnswersAfterCurrentPosition(); this.tree.clearFutureAnswers(this.state.getCurrentPromptIndex()); debugLogger.log("BACK_NAVIGATION_STATE", { newIndex: this.state.getCurrentPromptIndex(), remainingAnswers: this.state.getAnswerCount(), }); } } else { throw e; } } // Clean up answers for prompts that were not reached in this replay this.state.clearUnreachableAnswers(); this.tree.clearUnreachableAnswers(); } } finally { // Always clean up the SIGINT handler when flow ends this.cleanupCancelHandler(); } } /** * Ask a prompt question (wrapper for executeFlow compatibility) */ async ask(opts) { if (!this.state.isExecutingFlow()) { throw new Error("ask() must be called inside executeFlow()"); } return this.engine.step(opts.message ? "text" : "confirm", opts, async (id) => { const currentGroup = this.state.getCurrentGroupId(); return this.ui[opts.message ? "text" : "confirm"](opts, currentGroup, id); }); } /** * Create a group of prompts (public API wrapper) */ /** * Create a group of prompts (called by the group plugin) * * Note: The public group() API is now exposed through the group plugin. * This method is called internally by the plugin to handle group execution. */ async group(meta, body, opts) { return this.createGroup(meta, body, opts); } /** * Create a group of prompts (internal implementation) */ async createGroup(meta, body, opts) { // Group creation logic if (!this.state.isExecutingFlow()) { throw new Error("group() must be called inside executeFlow()"); } // Combine meta and opts const combinedOpts = { ...meta, ...(opts || {}) }; // For static groups, pre-scan to find fields if (opts?.flow === "static") { const nextGroupCount = this.idGenerator.getGroupCount() + 1; const groupId = this.idGenerator.generateGroupId({ groupStack: this.state.getGroupHierarchy(), groupCount: nextGroupCount, flowType: combinedOpts.flow, customId: combinedOpts.id, }); // Store the body function for re-scanning this.fieldDiscovery.storeGroupBodyFunction(groupId, body); // Run field scanning await this.fieldDiscovery.scanGroupFields(groupId, body); } await this.engine.step("group", combinedOpts, async () => undefined); try { const result = await body(); // Mark the group as completed when body finishes successfully // We need to get the current group ID before we exit it const completedGroupId = this.state.getCurrentGroupId(); if (completedGroupId) { // Groups are stored in UI tree, not runtime tree // Runtime and UI maintain separate tree instances // So we need to notify UI to handle the completion this.ui.onGroupCompleted?.(completedGroupId); } return result; } finally { // Exit the group when the group body completes this.state.exitGroup(); this.ui.clearGroup?.(); } } /** * Execute group body (called by group plugin) * This is the main entry point for the group plugin's execute hook */ async executeGroupBody(opts, body) { const meta = { label: opts.label, id: opts.id, }; const groupOpts = { flow: opts.flow, enableArrowNavigation: opts.enableArrowNavigation, hideOnCompletion: opts.hideOnCompletion, }; return this.createGroup(meta, body, groupOpts); } /** * Re-scan fields for a static group * Used by UI for field re-rendering */ async rescanStaticGroupFields(groupId) { return this.fieldDiscovery.rescanGroupFields(groupId); } // ========== INTERNAL METHODS ========== /** * Process a single prompt step - handles both groups and fields */ async processPromptStep(kind, opts, askUserFunction) { debugLogger.log("PROMPT_STEP", { kind, opts, currentIndex: this.state.getCurrentPromptIndex(), groupStack: this.state.getGroupHierarchy(), isReplaying: this.state.isReplayingAnswers(), }); if (kind === "group") { return this.handleGroupStep(opts, askUserFunction); } return this.handleFieldStep(kind, opts, askUserFunction); } /** * Handle a group step */ async handleGroupStep(groupOpts, askUserFunction) { // Increment group count for stable ID generation const groupCount = this.idGenerator.incrementGroupCount(); const groupId = this.idGenerator.generateGroupId({ groupStack: this.state.getGroupHierarchy(), groupCount, flowType: groupOpts.flow, customId: groupOpts.id, }); // Show group if not already processed const shouldShowGroup = !this.state.isGroupProcessed(groupId); if (shouldShowGroup) { debugLogger.log("GROUP_SHOW", { groupId, groupLabel: groupOpts.label, flow: groupOpts.flow, }); const fields = groupOpts.flow === "static" ? this.fieldDiscovery.getDiscoveredFields(groupId) : undefined; const groupDepth = this.state.getGroupDepth(); const currentGroup = this.state.getCurrentGroupId(); await this.ui.showGroup?.(groupOpts.label, groupOpts.flow || "progressive", groupId, fields, groupOpts.enableArrowNavigation, groupDepth, currentGroup, groupOpts // Pass all group options including hideOnCompletion ); this.state.markGroupAsProcessed(groupId); await askUserFunction(groupId); } else { debugLogger.log("GROUP_SKIP", { groupId, groupLabel: groupOpts.label, isReplaying: this.state.isReplayingAnswers(), }); } // Enter group context this.state.enterGroup(groupId); return undefined; } /** * Handle a field step */ async handleFieldStep(kind, opts, askUserFunction) { const stepIndex = this.state.getTotalPromptCount(); // Generate stable, deterministic ID const customId = "id" in opts ? opts.id : undefined; const message = ("message" in opts && opts.message ? opts.message : undefined) || ("label" in opts && opts.label ? opts.label : undefined) || `${kind}-${stepIndex}`; const promptId = this.idGenerator.generateFieldId({ kind, message, groupStack: this.state.getGroupHierarchy(), stepIndex, customId, }); // In field scanning mode, just track the field if (this.fieldDiscovery.isCurrentlyScanning()) { return this.handleFieldDuringScanning(promptId, kind, message); } this.state.registerPrompt(promptId); // If we have an answer and we're replaying past this step, use it if (stepIndex < this.state.getCurrentPromptIndex() && this.state.hasStoredAnswer(promptId)) { const answer = this.state.retrieveAnswer(promptId); debugLogger.log("PROMPT_REPLAY", { id: promptId, stepIndex, currentIndex: this.state.getCurrentPromptIndex(), answer, }); return answer; } // If this is the current step, ask the user if (stepIndex === this.state.getCurrentPromptIndex()) { return await this.promptUserForAnswer(promptId, stepIndex, askUserFunction); } // If we have a cached answer, use it if (this.state.hasStoredAnswer(promptId)) { const answer = this.state.retrieveAnswer(promptId); debugLogger.log("PROMPT_CACHED", { id: promptId, stepIndex, answer, }); return answer; } // Fallback: ask user const result = await askUserFunction(promptId); if (this.isBackToken(result)) throw BACK; this.state.storeAnswer(promptId, result); this.state.setPromptIndex(stepIndex + 1); return result; } /** * Handle field registration during scanning mode */ handleFieldDuringScanning(promptId, kind, message) { const currentGroupId = this.state.getCurrentGroupId(); debugLogger.log("DISCOVERY_FIELD", { currentGroupId, id: promptId, label: message, kind, }); if (currentGroupId) { this.fieldDiscovery.registerDiscoveredField(currentGroupId, { id: promptId, label: message || `${kind} field`, type: kind, }); } // Use current field value if available, otherwise use placeholder if (this.state.hasStoredAnswer(promptId)) { const currentValue = this.state.retrieveAnswer(promptId); debugLogger.log("DISCOVERY_CURRENT_VALUE", { id: promptId, currentValue, }); return currentValue; } // Use smart placeholder for discovery const placeholderValue = false; debugLogger.log("DISCOVERY_PLACEHOLDER", { kind, label: message, placeholderValue, }); return placeholderValue; } /** * Prompt the user for an answer */ async promptUserForAnswer(promptId, stepIndex, askUserFunction) { debugLogger.log("PROMPT_ASK", { id: promptId, stepIndex, }); const result = await askUserFunction(promptId); if (this.isBackToken(result)) { debugLogger.log("PROMPT_BACK", { id: promptId, stepIndex }); throw BACK; } debugLogger.log("PROMPT_ANSWER", { id: promptId, stepIndex, result }); this.state.storeAnswer(promptId, result); // Also update tree for UI visualization const node = this.tree.getNode(promptId); if (node) { this.tree.updateNode(promptId, { value: result }); } this.state.advanceToNextPrompt(); // Check if this was the last field if (this.state.hasReachedEndOfFlow()) { debugLogger.log("LAST_FIELD_COMPLETE", { id: promptId, stepIndex, totalPrompts: this.state.getTotalPromptCount(), }); this.ui.completeFlow?.(); } return result; } /** * Initialize plugin prompt functions */ initializePluginPrompts() { for (const plugin of globalRegistry.getAll()) { this.pluginPrompts[plugin.type] = async (opts) => { if (!this.state.isExecutingFlow()) { throw new Error(`${plugin.type}() must be called inside executeFlow()`); } return this.engine.step(plugin.type, opts, async (id) => { const currentGroup = this.state.getCurrentGroupId(); // If plugin has a transform function, use it to process opts // Otherwise, just pass opts through unchanged const processedOpts = plugin.transform ? plugin.transform(opts, { currentGroup }, id) : opts; return this.ui[plugin.type](processedOpts, currentGroup, id); }); }; } } /** * Check if a value is a BACK token */ isBackToken(x) { return (typeof x === "object" && x !== null && x.__back === true); } // ========== PUBLIC ACCESSORS ========== /** * Get plugin prompts for external access */ getPluginPrompts() { return this.pluginPrompts; } /** * Get the tree manager (for UI access) */ getTree() { return this.tree; } /** * Get current runtime state snapshot (for debugging) */ getDebugInfo() { return { state: this.state.getDebugInfo(), tree: { nodeCount: this.tree.getTree().nodeIndex.size, historyLength: this.tree.getNavigationPath().length, activeNode: this.tree.getActiveNode()?.id, }, fieldDiscovery: this.fieldDiscovery.getDebugInfo(), idGenerator: { groupCount: this.idGenerator.getGroupCount(), }, }; } /** * Set up SIGINT handler to call cancel callbacks when Ctrl+C is pressed */ setupCancelHandler() { // Only set up if not already registered if (this.sigintHandler) { return; } this.sigintHandler = () => { debugLogger.log("FLOW_CANCELLED", { callbackCount: this.cancelCallbacks.length, }); // Use the same logic as handleCtrlC this.handleCtrlC(); }; // Register SIGINT handler - use prependListener to run BEFORE Ink's handler process.prependListener("SIGINT", this.sigintHandler); } /** * Clean up SIGINT handler and cancel callbacks */ cleanupCancelHandler() { if (this.sigintHandler) { process.off("SIGINT", this.sigintHandler); this.sigintHandler = null; } this.cancelCallbacks = []; this.ctrlCPressCount = 0; } /** * Register a cancel callback */ registerCancelCallback(callback) { this.cancelCallbacks.push(callback); } /** * Check if any cancel callbacks are registered */ hasCancelCallbacks() { return this.cancelCallbacks.length > 0; } /** * Check if we're currently in cancel mode */ isInCancelMode() { return this.cancelModeActive; } /** * Handle Ctrl+C from UI (called by useInput hook in PromptApp) */ handleCtrlC() { // Increment Ctrl+C press count this.ctrlCPressCount++; // Force quit on second Ctrl+C press if (this.ctrlCPressCount >= 2) { debugLogger.log("FORCE_QUIT", { message: "Second Ctrl+C detected, forcing exit", }); // console.log("\nForce quitting..."); process.exit(1); return; } // First Ctrl+C - proceed with graceful cancellation debugLogger.log("FIRST_CTRL_C", { message: "First Ctrl+C detected, running cancel callbacks", }); // Inform user they can press Ctrl+C again to force quit // if (this.cancelCallbacks.length > 0) { // console.log("\n(Press Ctrl+C again to force quit)"); // } // Prepare results from collected answers const allAnswers = this.state.getAllAnswers(); const results = {}; for (const [promptId, answer] of Object.entries(allAnswers)) { // Use a simplified key (remove group prefixes for cleaner results) const key = promptId.split("|").pop() || promptId; results[key] = answer; } // Create cleanup function that user can call const cleanup = () => { try { this.ui.cleanup?.(); } catch (cleanupError) { // Ignore cleanup errors } }; // If no cancel callbacks, exit immediately if (this.cancelCallbacks.length === 0) { cleanup(); process.exit(0); return; } // Set cancel mode before executing callbacks so new prompts go to root this.cancelModeActive = true; // Call cancel callbacks with context - user controls cleanup and exit (async () => { for (const callback of this.cancelCallbacks) { try { await callback({ results, cleanup }); } catch (error) { debugLogger.log("CANCEL_CALLBACK_ERROR", { error: error instanceof Error ? error.message : String(error), }); } } // Don't automatically exit - let user control exit timing // If user doesn't exit, fallback after a delay setTimeout(() => { cleanup(); process.exit(0); }, 60000); // 5 second fallback })(); } } //# sourceMappingURL=prompt-runtime.js.map