mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
- 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.
842 lines
26 KiB
JavaScript
842 lines
26 KiB
JavaScript
/**
|
||
* 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;
|