/** * CLI Stream Viewer Component * Real-time streaming output viewer for CLI executions */ // ===== State Management ===== let cliStreamExecutions = {}; // { executionId: { tool, mode, output, status, startTime, endTime } } let activeStreamTab = null; let autoScrollEnabled = true; let isCliStreamViewerOpen = false; const MAX_OUTPUT_LINES = 5000; // Prevent memory issues // ===== Initialization ===== function initCliStreamViewer() { // Initialize keyboard shortcuts document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && isCliStreamViewerOpen) { toggleCliStreamViewer(); } }); // Initialize scroll detection for auto-scroll const content = document.getElementById('cliStreamContent'); if (content) { content.addEventListener('scroll', handleStreamContentScroll); } } // ===== Panel Control ===== function toggleCliStreamViewer() { const viewer = document.getElementById('cliStreamViewer'); const overlay = document.getElementById('cliStreamOverlay'); if (!viewer || !overlay) return; isCliStreamViewerOpen = !isCliStreamViewerOpen; if (isCliStreamViewerOpen) { viewer.classList.add('open'); overlay.classList.add('open'); // If no active tab but have executions, select the first one if (!activeStreamTab && Object.keys(cliStreamExecutions).length > 0) { const firstId = Object.keys(cliStreamExecutions)[0]; switchStreamTab(firstId); } else { renderStreamContent(activeStreamTab); } // Re-init lucide icons if (typeof lucide !== 'undefined') { lucide.createIcons(); } } else { viewer.classList.remove('open'); overlay.classList.remove('open'); } } // ===== WebSocket Event Handlers ===== function handleCliStreamStarted(payload) { const { executionId, tool, mode, timestamp } = payload; // Create new execution record cliStreamExecutions[executionId] = { tool: tool || 'cli', mode: mode || 'analysis', output: [], status: 'running', startTime: timestamp ? new Date(timestamp).getTime() : Date.now(), endTime: null }; // Add system message cliStreamExecutions[executionId].output.push({ type: 'system', content: `[${new Date().toLocaleTimeString()}] CLI execution started: ${tool} (${mode} mode)`, timestamp: Date.now() }); // If this is the first execution or panel is open, select it if (!activeStreamTab || isCliStreamViewerOpen) { activeStreamTab = executionId; } renderStreamTabs(); renderStreamContent(activeStreamTab); updateStreamBadge(); // Auto-open panel if configured (optional) // if (!isCliStreamViewerOpen) toggleCliStreamViewer(); } function handleCliStreamOutput(payload) { const { executionId, chunkType, data } = payload; const exec = cliStreamExecutions[executionId]; if (!exec) return; // Parse and add output lines const content = typeof data === 'string' ? data : JSON.stringify(data); const lines = content.split('\n'); lines.forEach(line => { if (line.trim() || lines.length === 1) { // Keep empty lines if it's the only content exec.output.push({ type: chunkType || 'stdout', content: line, timestamp: Date.now() }); } }); // Trim if too long if (exec.output.length > MAX_OUTPUT_LINES) { exec.output = exec.output.slice(-MAX_OUTPUT_LINES); } // Update UI if this is the active tab if (activeStreamTab === executionId && isCliStreamViewerOpen) { requestAnimationFrame(() => { renderStreamContent(executionId); }); } // Update badge to show activity updateStreamBadge(); } function handleCliStreamCompleted(payload) { const { executionId, success, duration, timestamp } = payload; const exec = cliStreamExecutions[executionId]; if (!exec) return; exec.status = success ? 'completed' : 'error'; exec.endTime = timestamp ? new Date(timestamp).getTime() : Date.now(); // Add completion message const durationText = duration ? ` (${formatDuration(duration)})` : ''; const statusText = success ? 'completed successfully' : 'failed'; exec.output.push({ type: 'system', content: `[${new Date().toLocaleTimeString()}] CLI execution ${statusText}${durationText}`, timestamp: Date.now() }); renderStreamTabs(); if (activeStreamTab === executionId) { renderStreamContent(executionId); } updateStreamBadge(); } function handleCliStreamError(payload) { const { executionId, error, timestamp } = payload; const exec = cliStreamExecutions[executionId]; if (!exec) return; exec.status = 'error'; exec.endTime = timestamp ? new Date(timestamp).getTime() : Date.now(); // Add error message exec.output.push({ type: 'stderr', content: `[ERROR] ${error || 'Unknown error occurred'}`, timestamp: Date.now() }); renderStreamTabs(); if (activeStreamTab === executionId) { renderStreamContent(executionId); } updateStreamBadge(); } // ===== UI Rendering ===== function renderStreamTabs() { const tabsContainer = document.getElementById('cliStreamTabs'); if (!tabsContainer) return; const execIds = Object.keys(cliStreamExecutions); if (execIds.length === 0) { tabsContainer.innerHTML = ''; return; } // Sort: running first, then by start time (newest first) execIds.sort((a, b) => { const execA = cliStreamExecutions[a]; const execB = cliStreamExecutions[b]; if (execA.status === 'running' && execB.status !== 'running') return -1; if (execA.status !== 'running' && execB.status === 'running') return 1; return execB.startTime - execA.startTime; }); tabsContainer.innerHTML = execIds.map(id => { const exec = cliStreamExecutions[id]; const isActive = id === activeStreamTab; const canClose = exec.status !== 'running'; return `
${escapeHtml(exec.tool)} ${exec.mode}
`; }).join(''); // Update count badge const countBadge = document.getElementById('cliStreamCountBadge'); if (countBadge) { const runningCount = execIds.filter(id => cliStreamExecutions[id].status === 'running').length; countBadge.textContent = execIds.length; countBadge.classList.toggle('has-running', runningCount > 0); } } function renderStreamContent(executionId) { const contentContainer = document.getElementById('cliStreamContent'); if (!contentContainer) return; const exec = executionId ? cliStreamExecutions[executionId] : null; if (!exec) { // Show empty state contentContainer.innerHTML = `
${t('cliStream.noStreams')}
${t('cliStream.noStreamsHint')}
`; if (typeof lucide !== 'undefined') lucide.createIcons(); return; } // Check if should auto-scroll const wasAtBottom = contentContainer.scrollHeight - contentContainer.scrollTop <= contentContainer.clientHeight + 50; // Render output lines contentContainer.innerHTML = exec.output.map(line => `
${escapeHtml(line.content)}
` ).join(''); // Auto-scroll if enabled and was at bottom if (autoScrollEnabled && wasAtBottom) { contentContainer.scrollTop = contentContainer.scrollHeight; } // Update status bar renderStreamStatus(executionId); } function renderStreamStatus(executionId) { const statusContainer = document.getElementById('cliStreamStatus'); if (!statusContainer) return; const exec = executionId ? cliStreamExecutions[executionId] : null; if (!exec) { statusContainer.innerHTML = ''; return; } const duration = exec.endTime ? formatDuration(exec.endTime - exec.startTime) : formatDuration(Date.now() - exec.startTime); const statusLabel = exec.status === 'running' ? t('cliStream.running') : exec.status === 'completed' ? t('cliStream.completed') : t('cliStream.error'); statusContainer.innerHTML = `
${statusLabel}
${duration}
${exec.output.length} ${t('cliStream.lines') || 'lines'}
`; if (typeof lucide !== 'undefined') lucide.createIcons(); // Update duration periodically for running executions if (exec.status === 'running') { setTimeout(() => { if (activeStreamTab === executionId && cliStreamExecutions[executionId]?.status === 'running') { renderStreamStatus(executionId); } }, 1000); } } function switchStreamTab(executionId) { if (!cliStreamExecutions[executionId]) return; activeStreamTab = executionId; renderStreamTabs(); renderStreamContent(executionId); } function updateStreamBadge() { const badge = document.getElementById('cliStreamBadge'); if (!badge) return; const runningCount = Object.values(cliStreamExecutions).filter(e => e.status === 'running').length; if (runningCount > 0) { badge.textContent = runningCount; badge.classList.add('has-running'); } else { badge.textContent = ''; badge.classList.remove('has-running'); } } // ===== User Actions ===== function closeStream(executionId) { const exec = cliStreamExecutions[executionId]; if (!exec || exec.status === 'running') return; delete cliStreamExecutions[executionId]; // Switch to another tab if this was active if (activeStreamTab === executionId) { const remaining = Object.keys(cliStreamExecutions); activeStreamTab = remaining.length > 0 ? remaining[0] : null; } renderStreamTabs(); renderStreamContent(activeStreamTab); updateStreamBadge(); } function clearCompletedStreams() { const toRemove = Object.keys(cliStreamExecutions).filter( id => cliStreamExecutions[id].status !== 'running' ); toRemove.forEach(id => delete cliStreamExecutions[id]); // Update active tab if needed if (activeStreamTab && !cliStreamExecutions[activeStreamTab]) { const remaining = Object.keys(cliStreamExecutions); activeStreamTab = remaining.length > 0 ? remaining[0] : null; } renderStreamTabs(); renderStreamContent(activeStreamTab); updateStreamBadge(); } function toggleAutoScroll() { autoScrollEnabled = !autoScrollEnabled; if (autoScrollEnabled && activeStreamTab) { const content = document.getElementById('cliStreamContent'); if (content) { content.scrollTop = content.scrollHeight; } } renderStreamStatus(activeStreamTab); } function handleStreamContentScroll() { const content = document.getElementById('cliStreamContent'); if (!content) return; // If user scrolls up, disable auto-scroll const isAtBottom = content.scrollHeight - content.scrollTop <= content.clientHeight + 50; if (!isAtBottom && autoScrollEnabled) { autoScrollEnabled = false; renderStreamStatus(activeStreamTab); } } // ===== Helper Functions ===== function formatDuration(ms) { if (ms < 1000) return `${ms}ms`; const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; const hours = Math.floor(minutes / 60); const remainingMinutes = minutes % 60; return `${hours}h ${remainingMinutes}m`; } function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Translation helper with fallback function t(key) { if (typeof window.t === 'function') { return window.t(key); } // Fallback values const fallbacks = { 'cliStream.noStreams': 'No active CLI executions', 'cliStream.noStreamsHint': 'Start a CLI command to see streaming output', 'cliStream.running': 'Running', 'cliStream.completed': 'Completed', 'cliStream.error': 'Error', 'cliStream.autoScroll': 'Auto-scroll', 'cliStream.close': 'Close', 'cliStream.cannotCloseRunning': 'Cannot close running execution', 'cliStream.lines': 'lines' }; return fallbacks[key] || key; } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initCliStreamViewer); } else { initCliStreamViewer(); }