/** * 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; let searchFilter = ''; // Search filter for output content const MAX_OUTPUT_LINES = 5000; // Prevent memory issues // ===== State Synchronization ===== /** * Sync active executions from server * Called on initialization to recover state when view is opened mid-execution */ async function syncActiveExecutions() { // Only sync in server mode if (!window.SERVER_MODE) return; try { const response = await fetch('/api/cli/active'); if (!response.ok) return; const { executions } = await response.json(); if (!executions || executions.length === 0) return; executions.forEach(exec => { // Skip if already tracked (avoid overwriting live data) if (cliStreamExecutions[exec.id]) return; // Rebuild execution state cliStreamExecutions[exec.id] = { tool: exec.tool || 'cli', mode: exec.mode || 'analysis', output: [], status: exec.status || 'running', startTime: exec.startTime || Date.now(), endTime: null }; // Add system start message cliStreamExecutions[exec.id].output.push({ type: 'system', content: `[${new Date(exec.startTime).toLocaleTimeString()}] CLI execution started: ${exec.tool} (${exec.mode} mode)`, timestamp: exec.startTime }); // Fill historical output (limit to last MAX_OUTPUT_LINES) if (exec.output) { const lines = exec.output.split('\n'); const startIndex = Math.max(0, lines.length - MAX_OUTPUT_LINES + 1); lines.slice(startIndex).forEach(line => { if (line.trim()) { cliStreamExecutions[exec.id].output.push({ type: 'stdout', content: line, timestamp: Date.now() }); } }); } }); // Update UI if we recovered any executions if (executions.length > 0) { // Set active tab to first running execution const runningExec = executions.find(e => e.status === 'running'); if (runningExec && !activeStreamTab) { activeStreamTab = runningExec.id; } renderStreamTabs(); updateStreamBadge(); // If viewer is open, render content if (isCliStreamViewerOpen) { renderStreamContent(activeStreamTab); } } console.log(`[CLI Stream] Synced ${executions.length} active execution(s)`); } catch (e) { console.error('[CLI Stream] Sync failed:', e); } } // ===== Initialization ===== function initCliStreamViewer() { // Initialize keyboard shortcuts document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && isCliStreamViewerOpen) { if (searchFilter) { clearSearch(); } else { toggleCliStreamViewer(); } } // Ctrl+F to focus search when viewer is open if ((e.ctrlKey || e.metaKey) && e.key === 'f' && isCliStreamViewerOpen) { e.preventDefault(); const searchInput = document.getElementById('cliStreamSearchInput'); if (searchInput) { searchInput.focus(); searchInput.select(); } } }); // Initialize scroll detection for auto-scroll const content = document.getElementById('cliStreamContent'); if (content) { content.addEventListener('scroll', handleStreamContentScroll); } // Sync active executions from server (recover state for mid-execution joins) syncActiveExecutions(); } // ===== 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, unit } = payload; const exec = cliStreamExecutions[executionId]; if (!exec) return; // Use structured unit if available, otherwise fall back to data const unitContent = unit?.content; const unitType = unit?.type || chunkType; // For tool_call type, format the content specially let content; if (unitType === 'tool_call' && typeof unitContent === 'object' && unitContent !== null) { // Format tool_call for display if (unitContent.action === 'invoke') { const params = unitContent.parameters ? JSON.stringify(unitContent.parameters) : ''; content = `[Tool] ${unitContent.toolName}(${params})`; } else if (unitContent.action === 'result') { const status = unitContent.status || 'unknown'; const output = unitContent.output ? `: ${unitContent.output.substring(0, 200)}${unitContent.output.length > 200 ? '...' : ''}` : ''; content = `[Tool Result] ${status}${output}`; } else { content = JSON.stringify(unitContent); } } else { // Use data (already serialized) for backward compatibility 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: unitType || '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(); } // ===== Message Type Parsing ===== const MESSAGE_TYPE_PATTERNS = { system: /^\[系统\]/, thinking: /^\[思考\]/, response: /^\[响应\]/, result: /^\[结果\]/, error: /^\[错误\]/, warning: /^\[警告\]/, info: /^\[信息\]/ }; const MESSAGE_TYPE_ICONS = { system: 'settings', thinking: 'brain', response: 'message-circle', result: 'check-circle', error: 'alert-circle', warning: 'alert-triangle', info: 'info' }; const MESSAGE_TYPE_LABELS = { system: '系统', thinking: '思考', response: '响应', result: '结果', error: '错误', warning: '警告', info: '信息' }; /** * Parse message content to extract type and clean content * @param {string} content - Raw message content * @returns {{ type: string, label: string, content: string, hasPrefix: boolean }} */ function parseMessageType(content) { for (const [type, pattern] of Object.entries(MESSAGE_TYPE_PATTERNS)) { if (pattern.test(content)) { return { type, label: MESSAGE_TYPE_LABELS[type], content: content.replace(pattern, '').trim(), hasPrefix: true }; } } return { type: 'default', label: '', content: content, hasPrefix: false }; } /** * Render a formatted message line with type badge * @param {Object} line - Line object with type and content * @param {string} searchFilter - Current search filter * @returns {string} - HTML string */ function renderFormattedLine(line, searchFilter) { const parsed = parseMessageType(line.content); let content = escapeHtml(parsed.content); // Apply search highlighting if (searchFilter && searchFilter.trim()) { const searchRegex = new RegExp(`(${escapeRegex(searchFilter)})`, 'gi'); content = content.replace(searchRegex, '$1'); } // Format code blocks content = formatCodeBlocks(content); // Format inline code content = content.replace(/`([^`]+)`/g, '$1'); // Type badge icons for backend chunkType (CliOutputUnit.type) // Maps to different CLI tools' output types: // - Gemini: init→metadata, message→stdout, result→metadata, tool_use/tool_result→tool_call // - Codex: reasoning→thought, agent_message→stdout, turn.completed→metadata // - Claude: system→metadata, assistant→stdout, result→metadata // - OpenCode: step_start→progress, text→stdout, step_finish→metadata const CHUNK_TYPE_ICONS = { thought: 'brain', code: 'code', file_diff: 'git-compare', progress: 'loader', system: 'settings', stderr: 'alert-circle', metadata: 'info', stdout: 'message-circle', tool_call: 'wrench' }; // Type badge labels for backend chunkType const CHUNK_TYPE_LABELS = { thought: 'Thinking', code: 'Code', file_diff: 'Diff', progress: 'Progress', system: 'System', stderr: 'Error', metadata: 'Info', stdout: 'Response', tool_call: 'Tool' }; // Build type badge - prioritize content prefix, then fall back to chunkType let typeBadge = ''; let lineClass = ''; if (parsed.hasPrefix) { // Content has Chinese prefix like [系统], [思考], etc. typeBadge = ` ${parsed.label} `; lineClass = `cli-stream-line formatted ${parsed.type}`; } else if (line.type && line.type !== 'stdout' && CHUNK_TYPE_LABELS[line.type]) { // No content prefix, but backend sent a meaningful chunkType typeBadge = ` ${CHUNK_TYPE_LABELS[line.type]} `; lineClass = `cli-stream-line formatted ${line.type}`; } else { // Plain stdout, no badge lineClass = `cli-stream-line ${line.type || 'stdout'}`; } return `
${typeBadge}${content}
`; } /** * Format code blocks in content */ function formatCodeBlocks(content) { // Handle multi-line code blocks (already escaped) // Just apply styling class for now return content; } // ===== 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 = `
${_streamT('cliStream.noStreams')}
${_streamT('cliStream.noStreamsHint')}
`; if (typeof lucide !== 'undefined') lucide.createIcons(); return; } // Check if should auto-scroll const wasAtBottom = contentContainer.scrollHeight - contentContainer.scrollTop <= contentContainer.clientHeight + 50; // Filter output lines based on search let filteredOutput = exec.output; if (searchFilter.trim()) { const searchLower = searchFilter.toLowerCase(); filteredOutput = exec.output.filter(line => line.content.toLowerCase().includes(searchLower) ); } // Render output lines with formatted styling contentContainer.innerHTML = filteredOutput.map(line => renderFormattedLine(line, searchFilter) ).join(''); // Initialize Lucide icons for message badges if (typeof lucide !== 'undefined') { lucide.createIcons({ attrs: { class: 'cli-msg-icon' } }); } // Show filter result count if filtering if (searchFilter.trim() && filteredOutput.length !== exec.output.length) { const filterInfo = document.createElement('div'); filterInfo.className = 'cli-stream-filter-info'; filterInfo.textContent = `${filteredOutput.length} / ${exec.output.length} lines`; contentContainer.insertBefore(filterInfo, contentContainer.firstChild); } // 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' ? _streamT('cliStream.running') : exec.status === 'completed' ? _streamT('cliStream.completed') : _streamT('cliStream.error'); statusContainer.innerHTML = `
${statusLabel}
${duration}
${exec.output.length} ${_streamT('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; } function escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } // ===== Search Functions ===== function handleSearchInput(event) { searchFilter = event.target.value; renderStreamContent(activeStreamTab); } function clearSearch() { searchFilter = ''; const searchInput = document.getElementById('cliStreamSearchInput'); if (searchInput) { searchInput.value = ''; } renderStreamContent(activeStreamTab); } // Translation helper with fallback (uses global t from i18n.js) function _streamT(key) { // First try global t() from i18n.js if (typeof t === 'function' && t !== _streamT) { try { return t(key); } catch (e) { // Fall through to fallbacks } } // 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', 'cliStream.searchPlaceholder': 'Search output...', 'cliStream.filterResults': 'results' }; return fallbacks[key] || key; } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initCliStreamViewer); } else { initCliStreamViewer(); } // ===== Global Exposure ===== window.toggleCliStreamViewer = toggleCliStreamViewer; window.handleCliStreamStarted = handleCliStreamStarted; window.handleCliStreamOutput = handleCliStreamOutput; window.handleCliStreamCompleted = handleCliStreamCompleted; window.handleCliStreamError = handleCliStreamError; window.switchStreamTab = switchStreamTab; window.closeStream = closeStream; window.clearCompletedStreams = clearCompletedStreams; window.toggleAutoScroll = toggleAutoScroll; window.handleSearchInput = handleSearchInput; window.clearSearch = clearSearch;