/** * Enhanced spinner utilities for CLI operations. * * @module */ import chalk from "chalk"; import ora, { Ora } from "ora"; export type SpinnerStatus = "loading" | "success" | "error" | "warning" | "info"; export interface ISpinnerOptions { /** Initial text to display */ text: string; /** Spinner color */ color?: "cyan" | "yellow" | "green" | "red" | "blue" | "magenta"; /** Hide the cursor */ hideCursor?: boolean; /** Use a specific spinner style */ spinner?: "dots" | "line" | "arrow" | "bouncingBar" | "simpleDots"; } const spinnerStyles: Record< Required["spinner"], any > = { dots: "dots", line: "line", arrow: "arrow2", bouncingBar: "bouncingBar", simpleDots: "simpleDots", }; /** * Create an enhanced spinner with better defaults. */ export function createSpinner(options: ISpinnerOptions): Ora { const { text, color = "cyan", hideCursor = true, spinner = "dots" } = options; return ora({ text, color, hideCursor, spinner: spinnerStyles[spinner], }); } /** * Multi-spinner manager for parallel operations. */ export class MultiSpinner { private spinners: Map = new Map(); private interval: ReturnType | null = null; constructor(private options: { interval?: number } = {}) {} /** * Add a new spinner for a named operation. */ add(name: string, text: string, status: SpinnerStatus = "loading"): void { const spinner = ora({ text, prefixText: this.getPrefixText(name), }).start(); this.spinners.set(name, spinner); } /** * Update a spinner's text. */ update(name: string, text: string): void { const spinner = this.spinners.get(name); if (spinner) { spinner.text = text; } } /** * Mark a spinner as succeeded. */ succeed(name: string, text?: string): void { const spinner = this.spinners.get(name); if (spinner) { spinner.succeed(text); } } /** * Mark a spinner as failed. */ fail(name: string, text?: string): void { const spinner = this.spinners.get(name); if (spinner) { spinner.fail(text); } } /** * Mark a spinner with a warning. */ warn(name: string, text?: string): void { const spinner = this.spinners.get(name); if (spinner) { spinner.warn(text); } } /** * Stop all spinners. */ stopAll(persist = false): void { for (const spinner of this.spinners.values()) { if (persist) { spinner.stop(); } else { spinner.stopAndPersist({ symbol: " ", text: "" }); } } this.spinners.clear(); } /** * Get the prefix text for a spinner. */ private getPrefixText(name: string): string { return chalk.gray(`[${name}]`); } } /** * Create a progress bar for long-running operations. */ export class ProgressBar { private current = 0; private total: number; private width: number; private label: string; private startTime: number; constructor(total: number, options: { width?: number; label?: string } = {}) { this.total = total; this.width = options.width ?? 40; this.label = options.label ?? "Progress"; this.startTime = Date.now(); } /** * Update the progress. */ update(current: number): void { this.current = Math.min(current, this.total); this.render(); } /** * Increment the progress by 1. */ increment(): void { this.current = Math.min(this.current + 1, this.total); this.render(); } /** * Render the progress bar. */ private render(): void { const percentage = this.current / this.total; const filled = Math.round(this.width * percentage); const empty = this.width - filled; const filledBar = chalk.green("█".repeat(filled)); const emptyBar = chalk.gray("░".repeat(empty)); const elapsed = Date.now() - this.startTime; const elapsedSec = (elapsed / 1000).toFixed(1); const eta = this.current > 0 ? ((elapsed / this.current) * (this.total - this.current) / 1000).toFixed( 1, ) : "—"; process.stdout.write( `\r${this.label}: [${filledBar}${emptyBar}] ${Math.round(percentage * 100)}% (${this.current}/${this.total}) ETA: ${eta}s `, ); if (this.current >= this.total) { process.stdout.write("\n"); } } } /** * Create a stylized status indicator. */ export function createStatusIndicator( status: SpinnerStatus, text: string, ): string { const icons = { loading: chalk.cyan("●"), success: chalk.green("✓"), error: chalk.red("✗"), warning: chalk.yellow("⚠"), info: chalk.blue("ℹ"), }; const colors = { loading: chalk.cyan, success: chalk.green, error: chalk.red, warning: chalk.yellow, info: chalk.blue, }; return `${icons[status]} ${colors[status](text)}`; } /** * Create a step-by-step progress display. */ export class StepProgress { private steps: Array<{ name: string; status: SpinnerStatus }> = []; addStep(name: string): void { this.steps.push({ name, status: "loading" }); this.render(); } completeStep(name: string, success = true): void { const step = this.steps.find((s) => s.name === name); if (step) { step.status = success ? "success" : "error"; this.render(); } } private render(): void { const lines = ["", chalk.bold("Progress:"), ""]; for (const step of this.steps) { const status = createStatusIndicator(step.status, step.name); lines.push(` ${status}`); } // Clear previous output and render process.stdout.write("\x1b[2K"); // Clear line for (let i = 0; i < this.steps.length + 3; i++) { process.stdout.write("\x1b[1A"); // Move up } process.stdout.write(lines.join("\n") + "\n"); } }