UNPKG

@kya-os/cli

Version:

CLI for KYA-OS MCP-I setup and management

255 lines 9.54 kB
/** * Effect Runner * Orchestrates terminal effects with proper management */ import { ANSI, getTerminalDimensions, calculateFrameDelay } from "./utils.js"; import { getConfig } from "./config.js"; import * as readline from "readline"; import { stdout } from "process"; /** * Effect runner for executing terminal effects */ export class EffectRunner { constructor() { this.isRunning = false; this.currentEffect = null; this.animationHandle = null; this.terminalState = { cursorHidden: false, originalDimensions: getTerminalDimensions(), cleanupHandlers: [], }; } /** * Run an effect */ async run(options) { if (this.isRunning) { throw new Error("Another effect is already running"); } const config = await getConfig(); const outputStream = options.outputStream || stdout; // Check if effects are enabled or if terminal doesn't support effects if (!config.enabled || !this.supportsEffects(outputStream)) { // Use fallback const fallbackLines = options.effect.renderFallback(); outputStream.write(fallbackLines.join("\n") + "\n"); options.onComplete?.(); return; } try { this.isRunning = true; this.currentEffect = options.effect; // Setup terminal await this.setupTerminal(outputStream, options.persistent); // Initialize effect const effectConfig = { frameRate: 30 * config.speedMultiplier, useXTermColors: false, noColor: false, canvas: this.terminalState.originalDimensions, ...options.config, }; options.effect.initialize(options.text, effectConfig); // Execute with safety wrapper // Since we're handling the animation loop ourselves, we bypass the executor's execute method // and use the animation loop directly with try-catch for safety try { await this.runAnimation(options.effect, outputStream, effectConfig.frameRate, options.skipExitPrompt, options.persistent); } catch (animationError) { // If animation fails, try fallback const fallbackLines = options.effect.renderFallback(); outputStream.write(fallbackLines.join("\n") + "\n"); throw animationError; } // Clean up await this.cleanup(outputStream, options.persistent); options.onComplete?.(); } catch (error) { await this.cleanup(outputStream, options.persistent); const err = error instanceof Error ? error : new Error(String(error)); options.onError?.(err); throw err; } finally { this.isRunning = false; this.currentEffect = null; } } /** * Stop the currently running effect */ async stop() { if (!this.isRunning || !this.currentEffect) { return; } // Cancel animation if (this.animationHandle) { clearTimeout(this.animationHandle); this.animationHandle = null; } // Clean up effect this.currentEffect.cleanup(); await this.cleanup(stdout, false); this.isRunning = false; this.currentEffect = null; } /** * Check if terminal supports effects */ supportsEffects(stream) { // Check if we have a TTY and basic terminal capabilities if (!stream.isTTY) return false; if (!process.env.TERM || process.env.TERM === 'dumb') return false; // Check for common CI environments that might not support effects well if (process.env.CI || process.env.GITHUB_ACTIONS) return false; return true; } /** * Setup terminal for effect rendering */ async setupTerminal(stream, persistent) { // Only use alternate screen buffer if not in persistent mode if (!persistent) { // Enter alternate screen buffer (prevents scrolling) stream.write("\x1b[?1049h"); } // Hide cursor stream.write(ANSI.cursor.hide); this.terminalState.cursorHidden = true; // Clear screen stream.write(ANSI.clear.screen); stream.write(ANSI.cursor.home); // Setup cleanup handlers const handleExit = () => { this.cleanup(stream, persistent).catch(() => { }); }; const handleResize = () => { this.terminalState.originalDimensions = getTerminalDimensions(); }; process.on("exit", handleExit); process.on("SIGINT", handleExit); process.on("SIGTERM", handleExit); process.stdout.on("resize", handleResize); this.terminalState.cleanupHandlers.push(() => process.off("exit", handleExit), () => process.off("SIGINT", handleExit), () => process.off("SIGTERM", handleExit), () => process.stdout.off("resize", handleResize)); // Setup keyboard handling for cancellation if (stream === stdout) { readline.emitKeypressEvents(process.stdin); if (process.stdin.isTTY) { process.stdin.setRawMode(true); } const handleKeypress = async (_str, key) => { if (key && (key.name === "escape" || (key.ctrl && key.name === "c") || key.name === "x" || key.name === "q")) { await this.stop(); // Ensure we exit alternate screen stream.write("\x1b[?1049l"); process.exit(0); } }; process.stdin.on("keypress", handleKeypress); this.terminalState.cleanupHandlers.push(() => process.stdin.off("keypress", handleKeypress)); } } /** * Run the animation loop */ async runAnimation(effect, stream, frameRate, skipExitPrompt, persistent) { const frameDelay = calculateFrameDelay(frameRate); while (!effect.isComplete()) { const startTime = Date.now(); // Render frame const lines = await effect.render(); // Clear screen and render stream.write(ANSI.cursor.home); stream.write(lines.join("\n")); stream.write(ANSI.clear.toEnd); // Calculate actual delay needed const renderTime = Date.now() - startTime; const delay = Math.max(0, frameDelay - renderTime); // Wait for next frame if (delay > 0) { await new Promise((resolve) => { this.animationHandle = setTimeout(resolve, delay); }); } } // Only show exit prompt if not skipped and not in persistent mode if (!skipExitPrompt && !persistent) { // After animation completes, show exit message stream.write("\n\n"); stream.write("\x1b[2m"); // Dim text stream.write("Press any key to exit..."); stream.write("\x1b[0m"); // Reset stream.write("\n"); // Wait for keypress - ensure we're in raw mode if (process.stdin.isTTY && !process.stdin.isRaw) { process.stdin.setRawMode(true); } await new Promise((resolve) => { const handleKeypress = (_str, _key) => { process.stdin.off("keypress", handleKeypress); resolve(); }; process.stdin.on("keypress", handleKeypress); }); } else if (persistent) { // In persistent mode, render the final frame one more time to ensure it's visible const finalLines = await effect.render(); stream.write(ANSI.cursor.home); stream.write(finalLines.join("\n")); stream.write(ANSI.clear.toEnd); stream.write("\n"); } } /** * Clean up terminal state */ async cleanup(stream, persistent) { // Clear animation handle if (this.animationHandle) { clearTimeout(this.animationHandle); this.animationHandle = null; } // Restore cursor if (this.terminalState.cursorHidden) { stream.write(ANSI.cursor.show); this.terminalState.cursorHidden = false; } // Reset terminal stream.write(ANSI.reset); // Only exit alternate screen buffer if not in persistent mode if (!persistent) { // Exit alternate screen buffer stream.write("\x1b[?1049l"); } stream.write("\n"); // Remove event handlers for (const handler of this.terminalState.cleanupHandlers) { handler(); } this.terminalState.cleanupHandlers = []; // Restore stdin mode if (process.stdin.isTTY && stream === stdout) { process.stdin.setRawMode(false); } } /** * Check if an effect is currently running */ isEffectRunning() { return this.isRunning; } /** * Get current effect info */ getCurrentEffect() { return this.currentEffect; } } //# sourceMappingURL=effect-runner.js.map