@simonecoelhosfo/optimizely-mcp-server
Version:
Optimizely MCP Server for AI assistants with integrated CLI tools
335 lines • 13.3 kB
JavaScript
/**
* Flicker-Free Progress Reporter using log-update
* Recreates the original design without flickering
*/
import logUpdate from 'log-update';
import chalk from 'chalk';
import ProgressEventEmitter from '../../events/ProgressEventEmitter.js';
export class FlickerFreeProgressReporter {
projects = new Map();
totalSteps = 0;
completedSteps = 0;
spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
spinnerIndex = 0;
startTime = Date.now();
updateInterval = null;
resizeTimeout = null;
isResizing = false;
boundHandleProgressEvent = this.handleProgressEvent.bind(this);
boundHandleResize = this.handleResize.bind(this);
hasStarted = false;
static instance = null;
forceCompactMode = false;
constructor() {
// Singleton pattern to prevent multiple instances
if (FlickerFreeProgressReporter.instance) {
FlickerFreeProgressReporter.instance.dispose();
}
FlickerFreeProgressReporter.instance = this;
// Check for compact mode from environment only
this.forceCompactMode = process.env.COMPACT_PROGRESS === 'true';
// Listen to progress events
ProgressEventEmitter.on('progress', this.boundHandleProgressEvent);
// Handle terminal resize
if (process.stdout.isTTY) {
process.stdout.on('resize', this.boundHandleResize);
}
}
/**
* Handle terminal resize
*/
handleResize() {
this.isResizing = true;
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
}
this.resizeTimeout = setTimeout(() => {
this.isResizing = false;
this.updateDisplay();
}, 100);
}
/**
* Start sync operation
*/
startSync(operation, options) {
// Prevent multiple starts
if (this.hasStarted)
return;
this.hasStarted = true;
// Reset state
this.startTime = Date.now();
this.completedSteps = 0;
this.projects.clear();
// Clear any existing interval
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
// Start update loop
this.updateInterval = setInterval(() => {
this.spinnerIndex = (this.spinnerIndex + 1) % this.spinnerFrames.length;
this.updateDisplay();
}, 100);
// Initial display with header
console.log(chalk.bold.blue('🚀 Optimizely Cache Sync'));
console.log(chalk.gray(`${operation} • ${new Date().toLocaleTimeString()}`));
console.log('');
}
/**
* Set total steps
*/
setTotalSteps(projectCount, entitiesPerProject, totalSteps) {
this.totalSteps = totalSteps || (projectCount * entitiesPerProject);
}
/**
* Handle progress events
*/
handleProgressEvent(event) {
if (event.type === 'start') {
if (event.entity === 'project' && event.projectName) {
this.startProject(event.projectName);
}
else if (event.projectName) {
this.startEntity(event.projectName, event.entity);
}
}
else if (event.type === 'progress' && event.count !== undefined) {
this.updateEntity(event.projectName || '', event.entity, event.count, event.isOngoing, event.total);
}
else if (event.type === 'complete') {
this.completeEntity(event.projectName || '', event.entity, event.count);
this.completedSteps++;
}
}
/**
* Start a new project section
*/
startProject(projectName) {
const isFeature = projectName.toLowerCase().includes('feature') ||
projectName.toLowerCase().includes('flag');
this.projects.set(projectName, {
name: projectName,
entities: new Map(),
isFeature
});
}
/**
* Start tracking an entity
*/
startEntity(projectName, entityName) {
const project = this.projects.get(projectName);
if (!project)
return;
// Skip if already tracking
if (project.entities.has(entityName))
return;
project.entities.set(entityName, {
name: entityName,
current: 0,
status: 'active',
startTime: Date.now()
});
}
/**
* Update entity progress
*/
updateEntity(projectName, entityName, count, isOngoing, total) {
const project = this.projects.get(projectName);
if (!project)
return;
const entity = project.entities.get(entityName);
if (!entity || entity.status === 'complete')
return;
// Validate count is not negative
if (count < 0) {
console.error(`Invalid negative count ${count} for ${entityName} in ${projectName}`);
return;
}
entity.current = count;
if (total !== undefined && total >= 0) {
entity.total = total;
}
else if (!isOngoing) {
entity.total = count;
}
}
/**
* Complete an entity
*/
completeEntity(projectName, entityName, finalCount) {
const project = this.projects.get(projectName);
if (!project)
return;
const entity = project.entities.get(entityName);
if (!entity)
return;
entity.status = 'complete';
if (finalCount !== undefined && finalCount >= 0) {
entity.current = finalCount;
}
entity.total = entity.current;
}
/**
* Update the display
*/
updateDisplay() {
// Skip updates during resize to prevent flicker
if (this.isResizing)
return;
const lines = [];
// Check if we should use compact mode - only for very small terminals
const terminalHeight = process.stdout.rows || 24;
const terminalWidth = process.stdout.columns || 80;
// Only use compact mode if terminal is truly tiny (less than 10 lines)
const useCompactMode = this.forceCompactMode || (terminalHeight > 0 && terminalHeight < 10);
if (useCompactMode) {
// Small terminal mode - show only current project and progress bar
if (this.projects.size > 0) {
// Get the most recent/active project
const activeProject = Array.from(this.projects.values()).find(p => Array.from(p.entities.values()).some(e => e.status === 'active')) || Array.from(this.projects.values())[this.projects.size - 1];
if (activeProject) {
const icon = activeProject.isFeature ? '📦' : '📂';
lines.push(chalk.bold.cyan(`${icon} ${activeProject.name}`));
}
}
// Show progress bar with spinner
if (this.totalSteps > 0) {
const safeCompletedSteps = Math.max(0, Math.min(this.completedSteps, this.totalSteps));
const percent = Math.round((safeCompletedSteps / this.totalSteps) * 100);
const barLength = Math.min(20, Math.floor((terminalWidth - 15) / 2));
const filledLength = Math.round((barLength * safeCompletedSteps) / this.totalSteps);
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
const spinner = this.spinnerFrames[this.spinnerIndex];
// Always show spinner to indicate activity, especially during flag downloads
lines.push(chalk.cyan(`[${bar}] ${percent}% `) + chalk.cyanBright(spinner));
}
logUpdate(lines.join('\n'));
return;
}
// Full display mode for larger terminals
// Projects
let projectIndex = 0;
const projectCount = this.projects.size;
for (const project of this.projects.values()) {
projectIndex++;
const icon = project.isFeature ? '📦' : '📂';
lines.push(chalk.bold.cyan(`${icon} ${project.name}`));
const entities = Array.from(project.entities.entries());
for (let index = 0; index < entities.length; index++) {
const [name, entity] = entities[index];
const isLast = index === entities.length - 1;
const prefix = isLast ? '└─' : '│ ';
const displayName = name.replace(/_/g, ' ').padEnd(16, ' ');
const dots = '.'.repeat(Math.max(1, 20 - displayName.length));
let status = '';
if (entity.status === 'complete') {
const count = entity.total || entity.current;
const countStr = count === 0 ? 'none' : count.toString();
status = chalk.green(`${countStr.padStart(6)} ✔`);
}
else if (entity.status === 'active') {
// Special case for flag_rulesets - show progress bar
if (name === 'flag_rulesets' && entity.total) {
const percent = Math.round((entity.current / entity.total) * 100);
const barLength = 20;
const filledLength = Math.round((barLength * entity.current) / entity.total);
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
status = `[${chalk.cyan(bar)}] ${percent}% | ${entity.current}/${entity.total}`;
}
else {
// Regular entities - show spinner and count
const spinner = this.spinnerFrames[this.spinnerIndex];
const countStr = entity.current.toString();
status = chalk.yellow(countStr.padStart(6)) + ' ' + chalk.cyanBright(spinner);
}
}
else {
status = chalk.gray('pending'.padStart(9));
}
lines.push(`${prefix} ${chalk.white(displayName)} ${chalk.gray(dots)} ${status}`);
}
if (projectIndex < projectCount) {
lines.push(''); // Blank line between projects
}
}
// Progress summary at bottom
if (this.totalSteps > 0) {
lines.push('');
// Ensure completedSteps is never negative or greater than totalSteps
const safeCompletedSteps = Math.max(0, Math.min(this.completedSteps, this.totalSteps));
const percent = Math.round((safeCompletedSteps / this.totalSteps) * 100);
const barLength = 30;
const filledLength = Math.round((barLength * safeCompletedSteps) / this.totalSteps);
const bar = '█'.repeat(filledLength) + '░'.repeat(barLength - filledLength);
const elapsed = Math.round((Date.now() - this.startTime) / 1000);
lines.push(chalk.cyan(`Progress: [${bar}] ${percent}% (${safeCompletedSteps}/${this.totalSteps}) ${elapsed}s`));
}
// Use log-update for flicker-free updates
logUpdate(lines.join('\n'));
}
/**
* Complete sync operation
*/
completeSync(result) {
// Stop update loop
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
// Final render
this.updateDisplay();
// Persist the final output
logUpdate.done();
this.hasStarted = false;
// Success message
console.log('');
console.log(chalk.bold.green('✨ Sync Completed Successfully!'));
if (result.duration) {
console.log(chalk.gray(`⏱️ Completed in ${(result.duration / 1000).toFixed(1)}s`));
}
}
/**
* Report error
*/
error(error) {
// Stop update loop
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
// Persist current output
logUpdate.done();
console.log('');
console.error(chalk.red.bold('❌ Sync Failed!'));
console.error(chalk.red(error.message));
}
/**
* Clean up
*/
dispose() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
this.updateInterval = null;
}
if (this.resizeTimeout) {
clearTimeout(this.resizeTimeout);
this.resizeTimeout = null;
}
logUpdate.done();
ProgressEventEmitter.removeListener('progress', this.boundHandleProgressEvent);
// Remove resize listener
if (process.stdout.isTTY) {
process.stdout.removeListener('resize', this.boundHandleResize);
}
// Clear singleton reference
if (FlickerFreeProgressReporter.instance === this) {
FlickerFreeProgressReporter.instance = null;
}
}
/**
* Legacy updateProgress method (no-op)
*/
updateProgress(progress) {
// Do nothing - we use events only
}
}
//# sourceMappingURL=FlickerFreeProgressReporter.js.map