/**
* 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 `