/** * Fuel-Gauge Progress Indicator * * Custom Cylon-style progress bar with scrolling output preview * Features: * - Back-and-forth animated bar (like Battlestar Galactica Cylon eye) * - 3-4 lines of scrolling greyed-out output above the bar * - On success: clears output, shows only success message * - On error: shows last 8 lines of output for debugging */ import { stdout } from 'node:process'; import { log } from '@celilo/cli-display'; import { getActiveDisplay } from './prompts'; /** * ANSI color codes for terminal output */ const colors = { cyan: (text: string) => `\x1b[36m${text}\x1b[0m`, dim: (text: string) => `\x1b[2m${text}\x1b[0m`, mutedPurple: (text: string) => `\x1b[38;5;238m${text}\x1b[0m`, // 256-color very dark gray-purple (barely visible) reset: '\x1b[0m', }; export interface FuelGaugeOptions { output?: NodeJS.WriteStream; skipAnimation?: boolean; // For testing onBackground?: () => void; // Callback when ESC pressed } /** * Fuel-Gauge progress indicator with Cylon-style animation * * Rendering strategy: builds the entire frame as a single string and * writes it in one output.write() call to prevent flicker. Uses * "move cursor up N lines" + overwrite rather than clear-then-draw. */ export class FuelGauge { private title: string; private outputLines: string[] = []; private barPosition = 0; private barDirection = 1; // 1 = right, -1 = left private intervalId: NodeJS.Timeout | null = null; private readonly maxDisplayLines = 4; // Show 3-4 lines of output private readonly errorDisplayLines = 100; // Show full output on error private readonly output: NodeJS.WriteStream; private readonly skipAnimation: boolean; private readonly onBackground?: () => void; private running = false; private hasNewOutput = false; // Track if new output arrived private pulseState = 0; // Track pulse animation state (0-3) private keyListener?: (chunk: Buffer) => void; private sigintHandler?: () => void; private alreadyCleanedUp = false; // Track if we've already cleaned up terminal private linesDrawn = 0; // Track exactly how many lines were drawn last frame private startTime = Date.now(); private lastOutputTime = Date.now(); /** * Whether this gauge may draw frames on its output stream. A redirected * or piped stream is a data channel, not a terminal (celilo#699), so the * gauge tracks state silently there instead of writing frames into * whatever file or protocol lies downstream (celilo#1362). */ private readonly interactive: boolean; constructor(title: string, options: FuelGaugeOptions = {}) { this.title = title; this.output = options.output || stdout; this.skipAnimation = options.skipAnimation || false; this.interactive = !this.skipAnimation && Boolean(this.output.isTTY); this.onBackground = options.onBackground; } /** * Start the fuel-gauge animation */ start(): void { if (this.skipAnimation || !this.interactive) { // Test mode or non-interactive output: just track state this.running = true; return; } // When a ProgressDisplay is active (e.g. inside `module deploy`), // delegate to it instead of running the cursor-redraw animation — // the two would otherwise stomp on each other's output. const display = getActiveDisplay(); if (display) { this.running = true; display.startStep(this.title, this.title); return; } this.running = true; // Hide cursor this.output.write('\x1B[?25l'); // Set up SIGINT handler to restore terminal on Ctrl+C (not in test mode) if (!this.skipAnimation) { this.sigintHandler = () => { if (this.alreadyCleanedUp) { process.exit(130); } this.alreadyCleanedUp = true; // Restore terminal: clear gauge area and show cursor this.writeClearSequence(); this.output.write('\x1B[?25h'); if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } if (this.keyListener && process.stdin.isTTY) { process.stdin.off('data', this.keyListener); process.stdin.setRawMode(false); process.stdin.pause(); } process.exit(130); }; process.on('SIGINT', this.sigintHandler); } // Set up keyboard listener for ESC key and Ctrl+C if (this.onBackground && process.stdin.isTTY) { process.stdin.setRawMode(true); process.stdin.resume(); this.keyListener = (chunk: Buffer) => { if (chunk[0] === 0x03) { // Ctrl+C — raw mode swallows SIGINT, handle manually if (this.sigintHandler) this.sigintHandler(); } else if (chunk[0] === 0x1b && chunk.length === 1) { this.background(); } }; process.stdin.on('data', this.keyListener); } // Draw initial frame this.writeFrame(); // Animate at 100ms intervals this.intervalId = setInterval(() => { this.render(); }, 100); } /** * Add output line (will be shown in scrolling preview) */ addOutput(line: string): void { const cleaned = this.stripAnsi(line); this.outputLines.push(cleaned); if (this.outputLines.length > 100) { this.outputLines.shift(); } const display = getActiveDisplay(); if (display) { // Pass the original line (ANSI preserved) so colour/dim codes from // the hook logger render in the display. Skip blank lines that the // child process may emit between output chunks. if (line.trim()) display.subEvent(line); return; } this.hasNewOutput = true; this.lastOutputTime = Date.now(); } /** * Background the animation (user pressed ESC) */ private background(): void { if (!this.running) return; this.cleanup(); log.info(`${this.title} (backgrounded)`); if (this.onBackground) { this.onBackground(); } } /** * Stop the animation and finalize */ stop(success: boolean): void { if (!this.running) return; this.running = false; if (!this.interactive) { return; } const display = getActiveDisplay(); if (display) { if (success) { display.doneStep(); } else { // failStep no longer collapses sub-events, so the lines we'd // re-print as "last output" are already visible above the // "✗ msg" line. Just call failStep and let them stand. display.failStep(this.title); } return; } this.cleanup(); if (success) { log.success(this.title); } else { log.error(this.title); console.log(''); console.log(colors.dim('Last output:')); const errorLines = this.outputLines.slice(-this.errorDisplayLines); for (const line of errorLines) { if (line.trim()) { console.log(colors.dim(` ${line}`)); } } console.log(''); } } /** * Tear down the animation without printing a success or failure stamp. * Used when the gauge needs to step out of the way temporarily — e.g. * for the cross-module ensure interview, where the next prompt would * otherwise collide with the running animation. Pair with a fresh * FuelGauge for any subsequent work. */ stopSilent(): void { if (!this.running) return; this.running = false; if (!this.interactive) return; const display = getActiveDisplay(); if (display) { display.abandon(); return; } this.cleanup(); } /** * Clean up resources (keyboard listener, animation, cursor) */ private cleanup(): void { if (this.alreadyCleanedUp) return; this.alreadyCleanedUp = true; if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } if (this.sigintHandler) { process.off('SIGINT', this.sigintHandler); this.sigintHandler = undefined; } if (this.keyListener && process.stdin.isTTY) { process.stdin.off('data', this.keyListener); process.stdin.setRawMode(false); process.stdin.pause(); this.keyListener = undefined; } if (!this.skipAnimation) { this.writeClearSequence(); this.output.write('\x1B[?25h'); } } /** * Get all captured output */ getOutput(): string[] { return [...this.outputLines]; } /** * Build progress bar string (pure function for testing) */ buildProgressBar(position: number, width: number, direction: number, pulse: boolean): string { const gradient = ['█', '▓', '▒', '░']; const barEmpty = '·'; const barLength = gradient.length; let bar = ''; for (let i = 0; i < width; i++) { const offset = i - position; if (offset >= 0 && offset < barLength) { let charIndex: number; let isFrontBlock = false; if (direction === 1) { charIndex = barLength - 1 - offset; isFrontBlock = offset === barLength - 1; } else { charIndex = offset; isFrontBlock = offset === 0; } if (pulse && isFrontBlock) { const pulseToggle = Math.floor(this.pulseState / 6) % 2; bar += gradient[pulseToggle]; } else { bar += gradient[charIndex]; } } else { bar += barEmpty; } } return bar; } /** * Format output lines for display (pure function for testing) */ formatOutputLines(lines: string[], maxLines: number): string[] { const recentLines = lines.slice(-maxLines); const termWidth = this.output.columns || 80; const maxLen = termWidth - 4; return recentLines.map((line) => { const truncated = line.length > maxLen ? `${line.slice(0, maxLen - 3)}...` : line; return ` ${truncated}`; }); } /** * Strip ANSI codes from text */ private stripAnsi(text: string): string { return text.replace(/\u001b\[[0-9;]*m/g, ''); } /** * Render one frame (called by animation loop) */ private render(): void { const termWidth = this.output.columns || 80; const barWidth = Math.max(termWidth - 4, 20); if (this.hasNewOutput) { this.barPosition += this.barDirection; if (this.barPosition >= barWidth - 4) { this.barDirection = -1; } else if (this.barPosition <= 0) { this.barDirection = 1; } } this.pulseState++; // Build frame, move cursor up over previous frame, write new frame // All in a single output.write() to prevent flicker const frame = this.buildFrame(); const moveUp = this.linesDrawn > 0 ? `\x1b[${this.linesDrawn}A\r` : ''; this.output.write(moveUp + frame); this.hasNewOutput = false; } /** * Write the initial frame (no cursor movement needed) */ private writeFrame(): void { const frame = this.buildFrame(); this.output.write(frame); } /** * Write a sequence to clear the gauge area (for cleanup/stop) */ private writeClearSequence(): void { if (this.linesDrawn <= 0) return; const termWidth = this.output.columns || 80; const blankLine = ' '.repeat(termWidth); // Move up, then overwrite each line with blanks let seq = `\x1b[${this.linesDrawn}A\r`; for (let i = 0; i < this.linesDrawn; i++) { seq += `${blankLine}\n`; } // Move back up to where we started seq += `\x1b[${this.linesDrawn}A\r`; this.output.write(seq); this.linesDrawn = 0; } /** * Build the entire frame as a single string. * Each line is padded to terminal width to overwrite previous content. */ private buildFrame(): string { const termWidth = this.output.columns || 80; let lineCount = 0; let frame = ''; const pad = (s: string, visibleLen: number) => { // Pad with spaces to fill the terminal width, clearing any leftover chars const remaining = Math.max(0, termWidth - visibleLen); return s + ' '.repeat(remaining); }; // Title line with elapsed time const elapsedSecs = Math.floor((Date.now() - this.startTime) / 1000); const elapsedStr = elapsedSecs > 0 ? ` (${elapsedSecs}s)` : ''; const titleText = `▸ ${this.title}${elapsedStr}`; frame += `${pad(colors.cyan(titleText), titleText.length + 2)}\n`; lineCount++; // If process has been silent for >5s, inject a status line so user knows it's alive const silentSecs = Math.floor((Date.now() - this.lastOutputTime) / 1000); const silentLines = silentSecs >= 5 ? [' Status: running'] : []; // Output preview lines const sourceLines = silentLines.length > 0 ? [...this.outputLines, ...silentLines] : this.outputLines; const displayLines = this.formatOutputLines(sourceLines, this.maxDisplayLines); for (const line of displayLines) { frame += `${pad(colors.dim(line), line.length)}\n`; lineCount++; } // Padding lines const paddingLines = this.maxDisplayLines - displayLines.length; for (let i = 0; i < paddingLines; i++) { frame += `${pad('', 0)}\n`; lineCount++; } // Progress bar const hintText = '(esc to bg; ^C to cancel)'; const barWidth = Math.max(termWidth - 4 - hintText.length - 2, 20); const shouldPulse = !this.hasNewOutput; const plainBar = this.buildProgressBar( this.barPosition, barWidth, this.barDirection, shouldPulse, ); // Colorize bar let coloredBar = ' '; let visibleBarLen = 2; // leading spaces for (let i = 0; i < plainBar.length; i++) { const char = plainBar[i]; if (char === '·') { coloredBar += colors.mutedPurple('·'); } else { coloredBar += colors.cyan(char); } visibleBarLen++; } coloredBar += ` ${colors.mutedPurple(hintText)}`; visibleBarLen += 2 + hintText.length; frame += `${pad(coloredBar, visibleBarLen)}\n`; lineCount++; this.linesDrawn = lineCount; return frame; } }