@kya-os/cli
Version:
CLI for KYA-OS MCP-I setup and management
255 lines • 9.54 kB
JavaScript
/**
* 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