UNPKG

@simonecoelhosfo/optimizely-mcp-server

Version:

Optimizely MCP Server for AI assistants with integrated CLI tools

335 lines 13.3 kB
/** * 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