@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
367 lines (366 loc) • 11.7 kB
JavaScript
/**
* Modern CLI UI utilities for enhanced visual feedback
* Provides loading indicators, progress bars, and modern CLI aesthetics
*/
import process from 'node:process';
import colors from '../constants/colors.js';
/**
* Simple spinner implementation for loading states
*/
export class SimpleSpinner {
constructor(text = 'Loading...', interval = 80) {
this.text = text;
this.interval = interval;
this.frames = colors.spinnerFrames;
this.currentFrame = 0;
this.timer = null;
this.isSpinning = false;
}
start() {
if (this.isSpinning) {
return this;
}
this.isSpinning = true;
this.timer = setInterval(() => {
const frame = this.frames[this.currentFrame];
process.stdout.write(`\r${colors.highlight(frame)} ${this.text}`);
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
}, this.interval);
return this;
}
stop(finalText = null) {
if (!this.isSpinning) {
return this;
}
clearInterval(this.timer);
this.timer = null;
this.isSpinning = false;
if (finalText) {
process.stdout.write(`\r${finalText}\n`);
}
else {
process.stdout.write(`\r${' '.repeat(this.text.length + 2)}\r`);
}
return this;
}
succeed(text = null) {
return this.stop(colors.statusSymbol('success', text || this.text));
}
fail(text = null) {
return this.stop(colors.statusSymbol('error', text || this.text));
}
warn(text = null) {
return this.stop(colors.statusSymbol('warning', text || this.text));
}
info(text = null) {
return this.stop(colors.statusSymbol('info', text || this.text));
}
}
/**
* Progress bar for long-running operations
*/
export class ProgressBar {
constructor(total, width = 40, options = {}) {
this.total = total;
this.current = 0;
this.width = width;
this.startTime = Date.now();
const { format = '{bar} {percentage}% | {current}/{total} | ETA: {eta}s', complete = '█', incomplete = '░', renderThrottle = 100, } = options;
this.format = format;
this.complete = complete;
this.incomplete = incomplete;
this.renderThrottle = renderThrottle;
this.lastRender = 0;
}
update(current, label = '') {
this.current = Math.min(current, this.total);
const now = Date.now();
if (now - this.lastRender < this.renderThrottle && this.current < this.total) {
return;
}
this.lastRender = now;
const percentage = Math.floor((this.current / this.total) * 100);
const filledWidth = Math.floor((this.current / this.total) * this.width);
const emptyWidth = this.width - filledWidth;
const bar = colors.success(this.complete.repeat(filledWidth)) +
colors.dim(this.incomplete.repeat(emptyWidth));
// Calculate ETA
const elapsed = (now - this.startTime) / 1000;
const rate = this.current / elapsed;
const eta = rate > 0 ? Math.ceil((this.total - this.current) / rate) : 0;
let output = this.format
.replace('{bar}', bar)
.replace('{percentage}', percentage.toString().padStart(3))
.replace('{current}', this.current.toString())
.replace('{total}', this.total.toString())
.replace('{eta}', eta.toString());
if (label) {
output += ` | ${label}`;
}
process.stdout.write(`\r${output}`);
if (this.current >= this.total) {
process.stdout.write('\n');
}
}
increment(label = '') {
this.update(this.current + 1, label);
}
finish(message = '') {
this.update(this.total);
if (message) {
console.log(colors.statusSymbol('success', message));
}
}
}
/**
* Multi-line progress display for multiple concurrent operations
*/
export class MultiProgress {
constructor() {
this.bars = new Map();
this.isActive = false;
}
create(id, total, label = '', options = {}) {
const bar = new ProgressBar(total, options.width || 30, {
...options,
format: `${label.padEnd(20)} {bar} {percentage}% | {current}/{total}`,
});
this.bars.set(id, { bar, label, lastOutput: '' });
return bar;
}
update(id, current, label = '') {
const entry = this.bars.get(id);
if (!entry) {
return;
}
entry.bar.update(current, label);
}
remove(id, finalMessage = '') {
const entry = this.bars.get(id);
if (!entry) {
return;
}
this.bars.delete(id);
if (finalMessage) {
console.log(colors.statusSymbol('success', `${entry.label}: ${finalMessage}`));
}
}
clear() {
this.bars.clear();
}
}
import { format } from 'node:util';
/**
* Enhanced console with better formatting
*/
export class EnhancedConsole {
static log(type = 'info', ...args) {
const message = format(...args);
console.log(colors.statusSymbol(type, message));
}
static success(...args) {
EnhancedConsole.log('success', ...args);
}
static error(...args) {
EnhancedConsole.log('error', ...args);
}
static warn(...args) {
EnhancedConsole.log('warning', ...args);
}
static info(...args) {
EnhancedConsole.log('info', ...args);
}
static processing(...args) {
EnhancedConsole.log('processing', ...args);
}
static ai(...args) {
EnhancedConsole.log('ai', ...args);
}
static metrics(data, title = 'Metrics') {
if (typeof data === 'string') {
EnhancedConsole.log('metrics', data);
}
else if (data && Object.keys(data).length > 0) {
EnhancedConsole.section(title);
console.log(colors.formatMetrics(data));
}
}
static section(title, content = '') {
console.log(`\n${colors.sectionHeader(title)}`);
console.log(colors.separator('─', 50));
if (content) {
console.log(content);
}
}
static box(title, content, options = {}) {
console.log(colors.boxed(content, { title, ...options }));
}
static table(data, options = {}) {
if (data && data.length > 0) {
console.log(colors.table(data, options));
}
}
static fileList(files, title = 'Files') {
if (files && files.length > 0) {
EnhancedConsole.section(title);
console.log(colors.formatFileList(files));
}
}
static divider(char = '─', length = 60) {
console.log(colors.separator(char, length));
}
static space(lines = 1) {
console.log('\n'.repeat(lines - 1));
}
}
/**
* Task list display for showing progress on multiple items
*/
export class TaskList {
constructor(tasks = []) {
this.tasks = tasks.map((task) => ({
text: task,
status: 'pending',
startTime: null,
endTime: null,
}));
this.currentIndex = -1;
}
add(task) {
this.tasks.push({
text: task,
status: 'pending',
startTime: null,
endTime: null,
});
}
start(index) {
if (index < 0 || index >= this.tasks.length) {
return;
}
this.currentIndex = index;
this.tasks[index].status = 'running';
this.tasks[index].startTime = Date.now();
this.render();
}
complete(index, message = '') {
if (index < 0 || index >= this.tasks.length) {
return;
}
this.tasks[index].status = 'completed';
this.tasks[index].endTime = Date.now();
if (message) {
this.tasks[index].text += ` (${message})`;
}
this.render();
}
fail(index, message = '') {
if (index < 0 || index >= this.tasks.length) {
return;
}
this.tasks[index].status = 'failed';
this.tasks[index].endTime = Date.now();
if (message) {
this.tasks[index].text += ` (${colors.error(message)})`;
}
this.render();
}
render() {
// Clear previous output
if (this.lastRenderHeight) {
process.stdout.write(`\u001b[${this.lastRenderHeight}A`);
process.stdout.write('\u001b[0J');
}
const output = this.tasks
.map((task, _index) => {
let symbol;
let color;
switch (task.status) {
case 'completed':
symbol = colors.symbols.success;
color = colors.success;
break;
case 'failed':
symbol = colors.symbols.error;
color = colors.error;
break;
case 'running':
symbol = colors.symbols.refresh;
color = colors.highlight;
break;
default:
symbol = colors.symbols.bullet;
color = colors.dim;
}
return `${color(symbol)} ${task.text}`;
})
.join('\n');
console.log(output);
this.lastRenderHeight = this.tasks.length;
}
summary() {
const completed = this.tasks.filter((t) => t.status === 'completed').length;
const failed = this.tasks.filter((t) => t.status === 'failed').length;
const total = this.tasks.length;
console.log(`\n${colors.separator('─', 50)}`);
console.log(colors.statusSymbol('info', `Tasks completed: ${colors.success(completed)}/${total}`));
if (failed > 0) {
console.log(colors.statusSymbol('error', `Tasks failed: ${colors.error(failed)}`));
}
}
}
/**
* Utility functions for common CLI patterns
*/
export const cliUtils = {
// Wait for user input
async waitForKey(message = 'Press any key to continue...') {
console.log(colors.dim(message));
process.stdin.setRawMode(true);
process.stdin.resume();
return new Promise((resolve) => {
process.stdin.once('data', () => {
process.stdin.setRawMode(false);
process.stdin.pause();
resolve();
});
});
},
// Clear screen
clear() {
process.stdout.write('\u001b[2J\u001b[0;0H');
},
// Move cursor up
cursorUp(lines = 1) {
process.stdout.write(`\u001b[${lines}A`);
},
// Clear current line
clearLine() {
process.stdout.write('\u001b[0K');
},
// Format duration
formatDuration(ms) {
if (ms <= 0) {
return '0s';
}
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
},
// Format bytes
formatBytes(bytes) {
const sizes = ['B', 'KB', 'MB', 'GB'];
if (bytes <= 0 || !Number.isFinite(bytes)) {
return '0B';
}
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${Math.round((bytes / 1024 ** i) * 100) / 100}${sizes[i]}`;
},
};