@thinkeloquent/cli-progressor
Version:
Advanced CLI Progress Bar System with Strategy Pattern Architecture
1,120 lines (941 loc) • 26.6 kB
JavaScript
/**
* CLI Progress Tracking System with Enhanced Architecture
* Implements Strategy, Builder, Observer, and State Machine patterns
* Addresses floating-point precision, lifecycle management, and architectural concerns
*/
import process from "process";
import { performance } from "perf_hooks";
// ===== CORE INTERFACES =====
class IProgressRenderer {
render(progressData) {
throw new Error("render() must be implemented by subclass");
}
cleanup() {}
}
class IProgressCalculator {
calculate(current, total, startTime, lastUpdate) {
throw new Error("calculate() must be implemented by subclass");
}
}
// ===== UTILITIES =====
class TerminalUtils {
static get isInteractive() {
return process.stdout.isTTY && process.env.CI !== "true";
}
static get columns() {
return process.stdout.columns || 80;
}
static get supportsColor() {
return process.stdout.isTTY && process.env.FORCE_COLOR !== "0";
}
static moveCursor(dx, dy) {
if (this.isInteractive) {
process.stdout.write(`\x1b[${dy}A\x1b[${dx}G`);
}
}
static clearLine() {
if (this.isInteractive) {
process.stdout.write("\x1b[2K\r");
}
}
static hideCursor() {
if (this.isInteractive) {
process.stdout.write("\x1b[?25l");
}
}
static showCursor() {
if (this.isInteractive) {
process.stdout.write("\x1b[?25h");
}
}
}
class Colors {
static get codes() {
return {
reset: "\x1b[0m",
bright: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
white: "\x1b[37m",
};
}
static colorize(text, color) {
if (!TerminalUtils.supportsColor) return text;
return `${this.codes[color] || ""}${text}${this.codes.reset}`;
}
static success(text) {
return Colors.colorize(text, "green");
}
static error(text) {
return Colors.colorize(text, "red");
}
static warning(text) {
return Colors.colorize(text, "yellow");
}
static info(text) {
return Colors.colorize(text, "cyan");
}
static dim(text) {
return Colors.colorize(text, "dim");
}
}
// ===== ENHANCED PROGRESS CALCULATOR =====
class StandardProgressCalculator extends IProgressCalculator {
constructor() {
super();
this.speedHistory = [];
this.maxHistorySize = 10;
}
calculate(current, total, startTime, lastUpdate) {
const now = performance.now();
const elapsed = (now - startTime) / 1000;
// Fix floating-point precision issue
const percentage =
total > 0 ? Math.round((current / total) * 100 * 100) / 100 : 0;
// Calculate speed with moving average
const speed = this.calculateSpeed(current, elapsed);
// Calculate ETA
let eta = 0;
if (current > 0 && current < total && speed > 0) {
eta = (total - current) / speed;
}
return {
current,
total,
percentage,
elapsed,
eta,
speed,
isComplete: current >= total,
isIndeterminate: total <= 0,
};
}
calculateSpeed(current, elapsed) {
if (elapsed <= 0) return 0;
const currentSpeed = current / elapsed;
// Maintain speed history for smoothing
this.speedHistory.push(currentSpeed);
if (this.speedHistory.length > this.maxHistorySize) {
this.speedHistory.shift();
}
// Return moving average
return (
this.speedHistory.reduce((a, b) => a + b, 0) / this.speedHistory.length
);
}
}
// ===== ENHANCED PROGRESS TRACKER WITH STATE MANAGEMENT =====
class ProgressTracker {
constructor(total, description = "Progress", calculator = null) {
this.total = total;
this.current = 0;
this.description = description;
this.startTime = performance.now();
this.lastUpdateTime = this.startTime;
this.calculator = calculator || new StandardProgressCalculator();
this.observers = new Set();
this.state = "idle";
this.stateObservers = new Set();
}
addObserver(observer) {
this.observers.add(observer);
return () => this.observers.delete(observer); // Return cleanup function
}
removeObserver(observer) {
return this.observers.delete(observer);
}
addStateObserver(observer) {
this.stateObservers.add(observer);
return () => this.stateObservers.delete(observer);
}
removeStateObserver(observer) {
return this.stateObservers.delete(observer);
}
notifyObservers(progressData) {
this.observers.forEach((observer) => {
try {
if (typeof observer === "function") {
observer(progressData);
} else if (observer.onProgress) {
observer.onProgress(progressData);
}
} catch (error) {
console.error("Observer error:", error);
}
});
}
notifyStateChange(newState, oldState) {
this.stateObservers.forEach((observer) => {
try {
if (typeof observer === "function") {
observer({ newState, oldState, tracker: this });
} else if (observer.onStateChange) {
observer.onStateChange({ newState, oldState, tracker: this });
}
} catch (error) {
console.error("State observer error:", error);
}
});
}
setState(newState) {
const oldState = this.state;
if (oldState !== newState) {
this.state = newState;
this.notifyStateChange(newState, oldState);
}
}
getState() {
return this.state;
}
increment(amount = 1) {
if (this.state === "completed") return this.getProgress();
const previousCurrent = this.current;
this.current = Math.min(this.total, this.current + amount);
this.lastUpdateTime = performance.now();
const progress = this.getProgress();
// State transition detection
if (progress.isComplete && this.state !== "completed") {
this.setState("completed");
}
this.notifyObservers(progress);
return progress;
}
getProgress() {
const calculatedData = this.calculator.calculate(
this.current,
this.total,
this.startTime,
this.lastUpdateTime
);
return {
...calculatedData,
description: this.description,
state: this.state,
};
}
reset() {
this.current = 0;
this.startTime = performance.now();
this.lastUpdateTime = this.startTime;
this.setState("idle");
if (this.calculator.speedHistory) {
this.calculator.speedHistory = [];
}
}
complete() {
if (this.state === "completed") {
return this.getProgress();
}
this.current = this.total;
this.setState("completed");
return this.getProgress();
}
setTotal(total) {
this.total = total;
return this;
}
isCompleted() {
return this.state === "completed";
}
}
// ===== SPINNER UTILITY =====
class Spinner {
constructor(frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]) {
this.frames = frames;
this.current = 0;
}
next() {
const frame = this.frames[this.current];
this.current = (this.current + 1) % this.frames.length;
return frame;
}
static get presets() {
return {
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
line: ["-", "\\", "|", "/"],
arrow: ["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
bounce: ["⠁", "⠂", "⠄", "⠂"],
clock: [
"🕐",
"🕑",
"🕒",
"🕓",
"🕔",
"🕕",
"🕖",
"🕗",
"🕘",
"🕙",
"🕚",
"🕛",
],
};
}
}
// ===== ENHANCED RENDERERS =====
class ConsoleProgressRenderer extends IProgressRenderer {
constructor(config = {}) {
super();
this.config = {
barLength: Math.min(config.barLength || 40, TerminalUtils.columns - 30),
filledChar: config.filledChar || "█",
emptyChar: config.emptyChar || "░",
showETA: config.showETA !== false,
showSpeed: config.showSpeed !== false,
showPercentage: config.showPercentage !== false,
precision: config.precision || 1,
useColors: config.useColors !== false,
template: config.template || null,
updateThrottle: config.updateThrottle || 0,
testMode: config.testMode || false,
...config,
};
this.lastLineLength = 0;
this.spinner = null;
this.lastRenderTime = 0;
this.hasRenderedCompletion = false;
}
formatTime(seconds) {
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600)
return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
return `${Math.floor(seconds / 3600)}h ${Math.floor(
(seconds % 3600) / 60
)}m`;
}
formatSpeed(speed) {
if (speed < 1) return `${(speed * 1000).toFixed(0)}ms/item`;
if (speed < 100) return `${speed.toFixed(1)}/s`;
return `${Math.round(speed)}/s`;
}
render(progressData) {
const now = performance.now();
// Throttle updates if configured
if (
this.config.updateThrottle > 0 &&
now - this.lastRenderTime < this.config.updateThrottle
) {
return;
}
this.lastRenderTime = now;
const {
current,
total,
percentage,
eta,
speed,
description,
isComplete,
isIndeterminate,
state,
} = progressData;
// Prevent duplicate completion messages
if (isComplete && this.hasRenderedCompletion) {
return;
}
if (this.config.template) {
return this.renderTemplate(progressData);
}
let output = "";
if (isIndeterminate) {
if (!this.spinner) this.spinner = new Spinner();
const spinnerFrame = this.spinner.next();
output = `${description}: ${Colors.info(spinnerFrame)} Working...`;
} else {
const filledLength = Math.floor(
(percentage / 100) * this.config.barLength
);
const emptyLength = this.config.barLength - filledLength;
const filledColor = isComplete ? "green" : "cyan";
const filledBar = this.config.useColors
? Colors.colorize(
this.config.filledChar.repeat(filledLength),
filledColor
)
: this.config.filledChar.repeat(filledLength);
const emptyBar = this.config.useColors
? Colors.dim(this.config.emptyChar.repeat(emptyLength))
: this.config.emptyChar.repeat(emptyLength);
const bar = `[${filledBar}${emptyBar}]`;
let stats = "";
if (this.config.showPercentage) {
const pct = this.config.useColors
? Colors.colorize(
`${percentage.toFixed(this.config.precision)}%`,
"bright"
)
: `${percentage.toFixed(this.config.precision)}%`;
stats += ` ${pct}`;
}
stats += ` (${current}/${total})`;
if (this.config.showSpeed && speed > 0) {
stats += ` ${Colors.dim(this.formatSpeed(speed))}`;
}
if (this.config.showETA && eta > 0) {
stats += ` ETA: ${Colors.dim(this.formatTime(eta))}`;
}
output = `${description}: ${bar}${stats}`;
}
// Clear previous line and write new one
if (TerminalUtils.isInteractive) {
TerminalUtils.clearLine();
process.stdout.write(output);
if (isComplete && !this.hasRenderedCompletion) {
const completedMsg = this.config.useColors
? Colors.success(" ✓ Complete!")
: " Complete!";
process.stdout.write(completedMsg + "\n");
this.hasRenderedCompletion = true;
}
} else {
// Non-interactive mode - only show milestones
if (
isComplete ||
current === 0 ||
current % Math.ceil(total / 10) === 0
) {
console.log(output);
}
}
this.lastLineLength = output.length;
}
renderTemplate(progressData) {
const template = this.config.template;
return template.replace(/\{(\w+)\}/g, (match, key) => {
return progressData[key] !== undefined ? progressData[key] : match;
});
}
cleanup() {
if (TerminalUtils.isInteractive) {
TerminalUtils.showCursor();
}
this.hasRenderedCompletion = false;
}
reset() {
this.hasRenderedCompletion = false;
this.lastRenderTime = 0;
}
}
class SilentProgressRenderer extends IProgressRenderer {
constructor() {
super();
this.progressHistory = [];
this.lastProgress = null;
}
render(progressData) {
this.lastProgress = { ...progressData };
this.progressHistory.push({ ...progressData, timestamp: Date.now() });
}
getLastProgress() {
return this.lastProgress;
}
getHistory() {
return [...this.progressHistory]; // Return copy to prevent external mutation
}
clear() {
this.progressHistory = [];
this.lastProgress = null;
}
cleanup() {
// Silent renderer doesn't need cleanup
}
reset() {
this.clear();
}
}
class MultiProgressRenderer extends IProgressRenderer {
constructor(config = {}) {
super();
this.config = config;
this.renderers = new Map();
this.lineCount = 0;
}
addProgress(id, renderer) {
this.renderers.set(id, renderer);
this.lineCount++;
}
removeProgress(id) {
if (this.renderers.delete(id)) {
this.lineCount--;
}
}
render(progressData, id) {
const renderer = this.renderers.get(id);
if (!renderer) return;
renderer.render(progressData);
}
cleanup() {
this.renderers.forEach((renderer) => renderer.cleanup());
}
reset() {
this.renderers.forEach((renderer) => {
if (renderer.reset) renderer.reset();
});
}
}
// ===== PROCESS MANAGER (for signal handling) =====
class ProcessManager {
constructor() {
this.cleanupTasks = new Set();
this.isSetup = false;
}
setup() {
if (this.isSetup) return;
const cleanup = () => {
this.cleanup();
process.exit(0);
};
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
process.on("exit", () => this.cleanup());
this.isSetup = true;
}
addCleanupTask(task) {
this.cleanupTasks.add(task);
return () => this.cleanupTasks.delete(task);
}
cleanup() {
this.cleanupTasks.forEach((task) => {
try {
task();
} catch (error) {
console.error("Cleanup error:", error);
}
});
}
}
// Global process manager instance
const processManager = new ProcessManager();
// ===== ENHANCED PROGRESS BAR WITH STATE MANAGEMENT =====
class ProgressBar {
constructor(total, description = "Progress", renderer = null) {
this.tracker = new ProgressTracker(total, description);
this.renderer = renderer || this.createDefaultRenderer();
this.state = "idle"; // idle, active, completed, stopped
this.updateInterval = null;
this.cleanupFn = null;
// Setup process management
processManager.setup();
// Listen to tracker state changes
this.tracker.addStateObserver((stateData) => {
if (stateData.newState === "completed") {
this.state = "completed";
}
});
}
createDefaultRenderer() {
if (!TerminalUtils.isInteractive) {
return new SilentProgressRenderer();
}
return new ConsoleProgressRenderer();
}
start() {
if (this.state !== "idle") return this;
this.state = "active";
this.tracker.setState("active");
TerminalUtils.hideCursor();
// Register cleanup
this.cleanupFn = processManager.addCleanupTask(() => {
this.stop();
this.renderer.cleanup();
});
// For indeterminate progress, start auto-updating
if (this.tracker.total <= 0) {
this.updateInterval = setInterval(() => {
if (this.state === "active") {
this.renderer.render(this.tracker.getProgress());
}
}, 100);
}
return this;
}
stop() {
if (this.state === "idle" || this.state === "stopped") return this;
this.state = "stopped";
TerminalUtils.showCursor();
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
if (this.cleanupFn) {
this.cleanupFn();
this.cleanupFn = null;
}
return this;
}
update(increment = 1) {
if (this.state === "completed") return this.getProgress();
if (this.state === "idle") this.start();
const progress = this.tracker.increment(increment);
this.renderer.render(progress);
if (progress.isComplete && this.state !== "completed") {
this.state = "completed";
this.stop();
}
return progress;
}
setTotal(total) {
this.tracker.setTotal(total);
return this;
}
complete() {
if (this.state === "completed") {
return this.getProgress(); // Idempotent - no double rendering
}
this.state = "completed";
const progress = this.tracker.complete();
this.renderer.render(progress);
this.stop();
return progress;
}
getProgress() {
return this.tracker.getProgress();
}
reset() {
this.tracker.reset();
this.state = "idle";
if (this.renderer.reset) {
this.renderer.reset();
}
return this;
}
isCompleted() {
return this.state === "completed";
}
getState() {
return this.state;
}
// Observer pattern support
onProgress(callback) {
return this.tracker.addObserver(callback);
}
offProgress(callback) {
return this.tracker.removeObserver(callback);
}
onStateChange(callback) {
return this.tracker.addStateObserver(callback);
}
offStateChange(callback) {
return this.tracker.removeStateObserver(callback);
}
// Factory methods for common configurations
static createConsole(total, description, config = {}) {
const renderer = new ConsoleProgressRenderer(config);
return new ProgressBar(total, description, renderer);
}
static createSilent(total, description) {
const renderer = new SilentProgressRenderer();
return new ProgressBar(total, description, renderer);
}
static createSpinner(description, config = {}) {
const renderer = new ConsoleProgressRenderer({
...config,
showPercentage: false,
showETA: false,
});
return new ProgressBar(0, description, renderer);
}
}
// ===== ENHANCED BUILDER WITH TEST MODE =====
class ProgressBarBuilder {
constructor() {
this.config = {};
this.total = 100;
this.description = "Progress";
}
withTotal(total) {
this.total = total;
return this;
}
withDescription(description) {
this.description = description;
return this;
}
withBarLength(length) {
this.config.barLength = length;
return this;
}
withChars(filled, empty) {
this.config.filledChar = filled;
this.config.emptyChar = empty;
return this;
}
withColors(enabled = true) {
this.config.useColors = enabled;
return this;
}
withPrecision(precision) {
this.config.precision = precision;
return this;
}
showETA(show = true) {
this.config.showETA = show;
return this;
}
showSpeed(show = true) {
this.config.showSpeed = show;
return this;
}
showPercentage(show = true) {
this.config.showPercentage = show;
return this;
}
withTemplate(template) {
this.config.template = template;
return this;
}
withTestMode(enabled = true) {
this.config.testMode = enabled;
this.config.updateThrottle = enabled ? 100 : 0; // Slower updates for visibility
return this;
}
withUpdateThrottle(ms) {
this.config.updateThrottle = ms;
return this;
}
forSpinner() {
this.total = 0;
this.config.showPercentage = false;
this.config.showETA = false;
return this;
}
build() {
if (this.config.testMode) {
// Use a test-friendly renderer with throttling
const renderer = new ConsoleProgressRenderer({
...this.config,
updateThrottle: this.config.updateThrottle || 100,
});
return new ProgressBar(this.total, this.description, renderer);
}
const renderer = new ConsoleProgressRenderer(this.config);
return new ProgressBar(this.total, this.description, renderer);
}
buildSilent() {
const renderer = new SilentProgressRenderer();
return new ProgressBar(this.total, this.description, renderer);
}
}
// ===== MULTI-PROGRESS MANAGER =====
class MultiProgressManager {
constructor() {
this.progressBars = new Map();
this.isActive = false;
}
add(id, total, description, config = {}) {
const progressBar = new ProgressBarBuilder()
.withTotal(total)
.withDescription(description)
.build();
this.progressBars.set(id, progressBar);
return progressBar;
}
get(id) {
return this.progressBars.get(id);
}
remove(id) {
const progressBar = this.progressBars.get(id);
if (progressBar) {
progressBar.stop();
this.progressBars.delete(id);
}
}
update(id, increment = 1) {
const progressBar = this.progressBars.get(id);
return progressBar ? progressBar.update(increment) : null;
}
complete(id) {
const progressBar = this.progressBars.get(id);
return progressBar ? progressBar.complete() : null;
}
clear() {
this.progressBars.forEach((progressBar) => progressBar.stop());
this.progressBars.clear();
}
}
// ===== ENHANCED CLI INTEGRATION UTILITIES =====
class CLIProgressHelper {
static async withProgress(total, description, asyncTask, config = {}) {
const progressBar = new ProgressBarBuilder()
.withTotal(total)
.withDescription(description)
.build();
progressBar.start();
try {
const result = await asyncTask((increment = 1) => {
progressBar.update(increment);
});
// Only complete if not already completed (prevents double rendering)
if (!progressBar.isCompleted()) {
progressBar.complete();
}
return result;
} catch (error) {
progressBar.stop();
console.error(
Colors.error(`\n✗ ${description} failed: ${error.message}`)
);
throw error;
}
}
static async withSpinner(description, asyncTask) {
const spinner = ProgressBar.createSpinner(description);
spinner.start();
try {
const result = await asyncTask();
spinner.stop();
console.log(Colors.success(`✓ ${description} completed`));
return result;
} catch (error) {
spinner.stop();
console.error(Colors.error(`✗ ${description} failed: ${error.message}`));
throw error;
}
}
static async withProgressAndState(
total,
description,
asyncTask,
config = {}
) {
const progressBar = new ProgressBarBuilder()
.withTotal(total)
.withDescription(description)
.build();
const stateHistory = [];
// Track state changes
const unsubscribeState = progressBar.onStateChange((stateData) => {
stateHistory.push({
timestamp: Date.now(),
...stateData,
});
});
progressBar.start();
try {
const result = await asyncTask((increment = 1) => {
progressBar.update(increment);
});
if (!progressBar.isCompleted()) {
progressBar.complete();
}
unsubscribeState();
return { result, stateHistory };
} catch (error) {
progressBar.stop();
unsubscribeState();
console.error(
Colors.error(`\n✗ ${description} failed: ${error.message}`)
);
throw error;
}
}
}
// ===== DEMONSTRATION & EXAMPLES =====
async function demonstrateProgressBars() {
console.log(
Colors.colorize("=== CLI Progress Bar System Demo ===\n", "bright")
);
console.log(Colors.info("1. Basic Progress Bar:"));
const basicBar = new ProgressBar(100, "Processing files").start();
await simulateWork(basicBar, 100, 50);
await sleep(500);
console.log(Colors.info("\n2. Custom Styled Progress Bar:"));
const customBar = new ProgressBarBuilder()
.withTotal(50)
.withDescription("Custom Processing")
.withBarLength(30)
.withChars("▓", "▒")
.withPrecision(0)
.showSpeed(true)
.build()
.start();
await simulateWork(customBar, 50, 75);
await sleep(500);
console.log(Colors.info("\n3. Spinner for Indeterminate Progress:"));
await CLIProgressHelper.withSpinner("Connecting to server", async () => {
await sleep(2000);
});
console.log(Colors.info("\n4. Progress with Async Task:"));
await CLIProgressHelper.withProgress(
75,
"Downloading",
async (updateProgress) => {
for (let i = 0; i < 75; i++) {
await sleep(30);
updateProgress(1);
}
}
);
console.log(Colors.info("\n5. Silent Progress (CI mode):"));
const silentBar = ProgressBar.createSilent(10, "Test Progress");
for (let i = 0; i < 10; i++) {
silentBar.update(1);
}
console.log("Final progress:", silentBar.getProgress());
console.log("History length:", silentBar.renderer.getHistory().length);
console.log(Colors.info("\n6. Progress with State Tracking:"));
const { result, stateHistory } = await CLIProgressHelper.withProgressAndState(
20,
"State Tracking Demo",
async (updateProgress) => {
for (let i = 0; i < 20; i++) {
await sleep(50);
updateProgress(1);
}
return "completed successfully";
}
);
console.log("Result:", result);
console.log("State changes:", stateHistory.length);
console.log(Colors.success("\n✓ All demonstrations completed!"));
}
// Utility functions
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function simulateWork(progressBar, total, delay = 50) {
let completed = 0;
while (completed < total) {
const increment = Math.min(
Math.floor(Math.random() * 5) + 1,
total - completed
);
progressBar.update(increment);
completed += increment;
await sleep(delay + Math.random() * 50);
}
}
// ===== MAIN EXECUTION =====
async function main() {
if (import.meta.url === `file://${process.argv[1]}`) {
try {
await demonstrateProgressBars();
} catch (error) {
console.error(Colors.error(`Error: ${error.message}`));
process.exit(1);
}
}
}
main().catch(console.error);
// ===== EXPORTS =====
export {
ProgressBar,
ProgressTracker,
ConsoleProgressRenderer,
SilentProgressRenderer,
MultiProgressRenderer,
ProgressBarBuilder,
MultiProgressManager,
CLIProgressHelper,
Colors,
TerminalUtils,
Spinner,
StandardProgressCalculator,
ProcessManager,
};