/** * Simple Progress Indicator * * Features: * - ASCII-only spinner frames (cross-platform compatible) * - TTY detection (no spinners in pipes/logs) * - Elapsed time display * - CI environment detection * - Color support via UI system */ import { color } from './ui/colors'; interface ProgressOptions { frames?: string[]; interval?: number; } export class ProgressIndicator { private message: string; private frames: string[]; private frameIndex: number; private interval: NodeJS.Timeout | null; private startTime: number; private isTTY: boolean; /** * Create a progress indicator * @param message - Message to display * @param options - Options */ constructor(message: string, options: ProgressOptions = {}) { this.message = message; // ASCII-only frames for cross-platform compatibility this.frames = options.frames || ['|', '/', '-', '\\']; this.frameIndex = 0; this.interval = null; this.startTime = Date.now(); // TTY detection: only animate if stderr is TTY and not in CI this.isTTY = process.stderr.isTTY === true && !process.env.CI && !process.env.NO_COLOR; } /** * Start the spinner */ start(): void { if (!this.isTTY) { // Non-TTY: just print message once process.stderr.write(`${color('[i]', 'info')} ${this.message}...\n`); return; } // TTY: animate spinner this.interval = setInterval(() => { const frame = this.frames[this.frameIndex]; const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1); process.stderr.write(`\r${color(`[${frame}]`, 'info')} ${this.message}... (${elapsed}s)`); this.frameIndex = (this.frameIndex + 1) % this.frames.length; }, 80); // 12.5fps for smooth animation } /** * Stop spinner with success message * @param message - Optional success message (defaults to original message) */ succeed(message?: string): void { this.stop(); const finalMessage = message || this.message; const elapsed = ((Date.now() - this.startTime) / 1000).toFixed(1); if (this.isTTY) { // Clear spinner line and show success process.stderr.write(`\r${color('[OK]', 'success')} ${finalMessage} (${elapsed}s)\n`); } else { // Non-TTY: just show completion process.stderr.write(`${color('[OK]', 'success')} ${finalMessage}\n`); } } /** * Stop spinner with failure message * @param message - Optional failure message (defaults to original message) */ fail(message?: string): void { this.stop(); const finalMessage = message || this.message; if (this.isTTY) { // Clear spinner line and show failure process.stderr.write(`\r${color('[X]', 'error')} ${finalMessage}\n`); } else { // Non-TTY: just show failure process.stderr.write(`${color('[X]', 'error')} ${finalMessage}\n`); } } /** * Update spinner message (while running) * @param newMessage - New message to display */ update(newMessage: string): void { this.message = newMessage; } /** * Stop the spinner without showing success/failure */ stop(): void { if (this.interval) { clearInterval(this.interval); this.interval = null; if (this.isTTY) { // Clear the spinner line process.stderr.write('\r\x1b[K'); } } } } export default ProgressIndicator;