mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
- Added `/issue:manage` command for interactive issue management via CLI. - Implemented features for listing, viewing, editing, deleting, and bulk operations on issues. - Integrated GitHub issue fetching and text description parsing for issue creation. - Enhanced user experience with menu-driven interface and structured output. - Created helper functions for parsing user input and managing issue data. - Added error handling and related command references for better usability. feat(issue-creation): Introduce structured issue creation from GitHub URL or text description - Added `/issue:new` command to create structured issues from GitHub issues or text descriptions. - Implemented parsing logic for extracting key elements from issue descriptions. - Integrated user confirmation for issue creation with options to edit title and priority. - Ensured proper writing of issues to `.workflow/issues/issues.jsonl` with metadata. - Included examples and error handling for various input scenarios.
462 lines
14 KiB
JavaScript
462 lines
14 KiB
JavaScript
/**
|
||
* 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 `
|
||
<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;
|
||
|
||
// Render output lines
|
||
contentContainer.innerHTML = exec.output.map(line =>
|
||
`<div class="cli-stream-line ${line.type}">${escapeHtml(line.content)}</div>`
|
||
).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'
|
||
? _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') {
|
||
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 (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'
|
||
};
|
||
return fallbacks[key] || key;
|
||
}
|
||
|
||
// Initialize when DOM is ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', initCliStreamViewer);
|
||
} else {
|
||
initCliStreamViewer();
|
||
}
|