Files
Claude-Code-Workflow/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js
catlog22 9c9b1ad01c Add TypeScript LSP setup guide and enhance debugging tests
- Created a comprehensive guide for setting up TypeScript LSP in Claude Code, detailing installation methods, configuration, and troubleshooting.
- Added multiple debugging test scripts to validate LSP communication with pyright, including direct communication tests, configuration checks, and document symbol retrieval.
- Implemented error handling and logging for better visibility during LSP interactions.
2026-01-20 14:53:18 +08:00

842 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CLI Stream Viewer Component
* Real-time streaming output viewer for CLI executions
*/
// ===== Lifecycle Management =====
let cliStreamViewerDestroy = null;
let streamKeyboardHandler = null;
let streamScrollHandler = null; // Track scroll listener
let streamStatusTimers = []; // Track status update timers
// ===== 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;
let needsUiUpdate = false;
executions.forEach(exec => {
const existing = cliStreamExecutions[exec.id];
// Parse historical output from server
const historicalLines = [];
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()) {
historicalLines.push({
type: 'stdout',
content: line,
timestamp: exec.startTime || Date.now()
});
}
});
}
if (existing) {
// Already tracked by WebSocket events - merge historical output
// Only prepend historical lines that are not already in the output
// (WebSocket events only add NEW output, so historical output should come before)
const existingContentSet = new Set(existing.output.map(o => o.content));
const missingLines = historicalLines.filter(h => !existingContentSet.has(h.content));
if (missingLines.length > 0) {
// Find the system start message index (skip it when prepending)
const systemMsgIndex = existing.output.findIndex(o => o.type === 'system');
const insertIndex = systemMsgIndex >= 0 ? systemMsgIndex + 1 : 0;
// Prepend missing historical lines after system message
existing.output.splice(insertIndex, 0, ...missingLines);
// Trim if too long
if (existing.output.length > MAX_OUTPUT_LINES) {
existing.output = existing.output.slice(-MAX_OUTPUT_LINES);
}
needsUiUpdate = true;
console.log(`[CLI Stream] Merged ${missingLines.length} historical lines for ${exec.id}`);
}
return;
}
needsUiUpdate = true;
// New execution - rebuild full 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
});
// Add historical output
cliStreamExecutions[exec.id].output.push(...historicalLines);
});
// Update UI if we recovered or merged any executions
if (needsUiUpdate) {
// 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
streamKeyboardHandler = 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();
}
}
};
document.addEventListener('keydown', streamKeyboardHandler);
// Initialize scroll detection for auto-scroll
const content = document.getElementById('cliStreamContent');
if (content) {
streamScrollHandler = handleStreamContentScroll;
content.addEventListener('scroll', streamScrollHandler);
}
// 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, '<mark class="cli-stream-highlight">$1</mark>');
}
// Format code blocks
content = formatCodeBlocks(content);
// Format inline code
content = content.replace(/`([^`]+)`/g, '<code class="cli-inline-code">$1</code>');
// 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 = `<span class="cli-msg-badge cli-msg-${parsed.type}">
<i data-lucide="${MESSAGE_TYPE_ICONS[parsed.type] || 'circle'}"></i>
<span>${parsed.label}</span>
</span>`;
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 = `<span class="cli-msg-badge cli-msg-${line.type}">
<i data-lucide="${CHUNK_TYPE_ICONS[line.type] || 'circle'}"></i>
<span>${CHUNK_TYPE_LABELS[line.type]}</span>
</span>`;
lineClass = `cli-stream-line formatted ${line.type}`;
} else {
// Plain stdout, no badge
lineClass = `cli-stream-line ${line.type || 'stdout'}`;
}
return `<div class="${lineClass}">${typeBadge}<span class="cli-msg-content">${content}</span></div>`;
}
/**
* 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 `
<div class="cli-stream-tab ${isActive ? 'active' : ''}"
onclick="switchStreamTab('${id}')"
data-execution-id="${id}">
<span class="cli-stream-tab-status ${exec.status}"></span>
<span class="cli-stream-tab-tool">${escapeHtml(exec.tool)}</span>
<span class="cli-stream-tab-mode">${exec.mode}</span>
<button class="cli-stream-tab-close ${canClose ? '' : 'disabled'}"
onclick="event.stopPropagation(); closeStream('${id}')"
title="${canClose ? _streamT('cliStream.close') : _streamT('cliStream.cannotCloseRunning')}"
${canClose ? '' : 'disabled'}>×</button>
</div>
`;
}).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 = `
<div class="cli-stream-empty">
<i data-lucide="terminal"></i>
<div class="cli-stream-empty-title" data-i18n="cliStream.noStreams">${_streamT('cliStream.noStreams')}</div>
<div class="cli-stream-empty-hint" data-i18n="cliStream.noStreamsHint">${_streamT('cliStream.noStreamsHint')}</div>
</div>
`;
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 = `
<div class="cli-stream-status-info">
<div class="cli-stream-status-item">
<span class="cli-stream-tab-status ${exec.status}"></span>
<span>${statusLabel}</span>
</div>
<div class="cli-stream-status-item">
<i data-lucide="clock"></i>
<span>${duration}</span>
</div>
<div class="cli-stream-status-item">
<i data-lucide="file-text"></i>
<span>${exec.output.length} ${_streamT('cliStream.lines') || 'lines'}</span>
</div>
</div>
<div class="cli-stream-status-actions">
<button class="cli-stream-toggle-btn ${autoScrollEnabled ? 'active' : ''}"
onclick="toggleAutoScroll()"
title="${_streamT('cliStream.autoScroll')}">
<i data-lucide="arrow-down-to-line"></i>
<span data-i18n="cliStream.autoScroll">${_streamT('cliStream.autoScroll')}</span>
</button>
</div>
`;
if (typeof lucide !== 'undefined') lucide.createIcons();
// Update duration periodically for running executions
if (exec.status === 'running') {
const timerId = setTimeout(() => {
if (activeStreamTab === executionId && cliStreamExecutions[executionId]?.status === 'running') {
renderStreamStatus(executionId);
}
}, 1000);
streamStatusTimers.push(timerId);
}
}
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();
}
// ===== Lifecycle Functions =====
function destroyCliStreamViewer() {
// Remove keyboard event listener if exists
if (streamKeyboardHandler) {
document.removeEventListener('keydown', streamKeyboardHandler);
streamKeyboardHandler = null;
}
// Remove scroll event listener if exists
if (streamScrollHandler) {
const content = document.getElementById('cliStreamContent');
if (content) {
content.removeEventListener('scroll', streamScrollHandler);
}
streamScrollHandler = null;
}
// Clear all pending status update timers
streamStatusTimers.forEach(timerId => clearTimeout(timerId));
streamStatusTimers = [];
}
// Export lifecycle functions
window.destroyCliStreamViewer = destroyCliStreamViewer;
// ===== 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;