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