feat(cli): Add CLI Manager with status and history components

- Implemented CLI History Component to display execution history with filtering and search capabilities.
- Created CLI Status Component to show availability of CLI tools and allow setting a default tool.
- Enhanced notifications to handle CLI execution events.
- Integrated CLI Manager view to combine status and history panels for better user experience.
- Developed CLI Executor Tool for unified execution of external CLI tools (Gemini, Qwen, Codex) with streaming output.
- Added functionality to save and retrieve CLI execution history.
- Updated dashboard HTML to include navigation for CLI tools management.
This commit is contained in:
catlog22
2025-12-11 11:05:57 +08:00
parent a667b7548c
commit b81d1039c5
14 changed files with 2014 additions and 19 deletions

View File

@@ -0,0 +1,200 @@
// CLI History Component
// Displays execution history with filtering and search
// ========== CLI History State ==========
let cliExecutionHistory = [];
let cliHistoryFilter = null; // Filter by tool
let cliHistoryLimit = 50;
// ========== Data Loading ==========
async function loadCliHistory(options = {}) {
try {
const { limit = cliHistoryLimit, tool = cliHistoryFilter, status = null } = options;
let url = `/api/cli/history?path=${encodeURIComponent(projectPath)}&limit=${limit}`;
if (tool) url += `&tool=${tool}`;
if (status) url += `&status=${status}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to load CLI history');
const data = await response.json();
cliExecutionHistory = data.executions || [];
return data;
} catch (err) {
console.error('Failed to load CLI history:', err);
return { executions: [], total: 0, count: 0 };
}
}
async function loadExecutionDetail(executionId) {
try {
const url = `/api/cli/execution?path=${encodeURIComponent(projectPath)}&id=${encodeURIComponent(executionId)}`;
const response = await fetch(url);
if (!response.ok) throw new Error('Execution not found');
return await response.json();
} catch (err) {
console.error('Failed to load execution detail:', err);
return null;
}
}
// ========== Rendering ==========
function renderCliHistory() {
const container = document.getElementById('cli-history-panel');
if (!container) return;
if (cliExecutionHistory.length === 0) {
container.innerHTML = `
<div class="cli-history-header">
<h3>Execution History</h3>
<div class="cli-history-controls">
${renderToolFilter()}
<button class="btn-icon" onclick="refreshCliHistory()" title="Refresh">
<i data-lucide="refresh-cw"></i>
</button>
</div>
</div>
<div class="empty-state">
<i data-lucide="terminal"></i>
<p>No executions yet</p>
</div>
`;
if (window.lucide) lucide.createIcons();
return;
}
const historyHtml = cliExecutionHistory.map(exec => {
const statusIcon = exec.status === 'success' ? 'check-circle' :
exec.status === 'timeout' ? 'clock' : 'x-circle';
const statusClass = exec.status === 'success' ? 'text-success' :
exec.status === 'timeout' ? 'text-warning' : 'text-destructive';
const duration = formatDuration(exec.duration_ms);
const timeAgo = getTimeAgo(new Date(exec.timestamp));
return `
<div class="cli-history-item" onclick="showExecutionDetail('${exec.id}')">
<div class="cli-history-item-header">
<span class="cli-tool-tag cli-tool-${exec.tool}">${exec.tool}</span>
<span class="cli-history-time">${timeAgo}</span>
<i data-lucide="${statusIcon}" class="${statusClass}"></i>
</div>
<div class="cli-history-prompt">${escapeHtml(exec.prompt_preview)}</div>
<div class="cli-history-meta">
<span class="text-muted-foreground">${duration}</span>
</div>
</div>
`;
}).join('');
container.innerHTML = `
<div class="cli-history-header">
<h3>Execution History</h3>
<div class="cli-history-controls">
${renderToolFilter()}
<button class="btn-icon" onclick="refreshCliHistory()" title="Refresh">
<i data-lucide="refresh-cw"></i>
</button>
</div>
</div>
<div class="cli-history-list">
${historyHtml}
</div>
`;
if (window.lucide) lucide.createIcons();
}
function renderToolFilter() {
const tools = ['all', 'gemini', 'qwen', 'codex'];
return `
<select class="cli-tool-filter" onchange="filterCliHistory(this.value)">
${tools.map(tool => `
<option value="${tool === 'all' ? '' : tool}" ${cliHistoryFilter === (tool === 'all' ? null : tool) ? 'selected' : ''}>
${tool === 'all' ? 'All Tools' : tool.charAt(0).toUpperCase() + tool.slice(1)}
</option>
`).join('')}
</select>
`;
}
// ========== Execution Detail Modal ==========
async function showExecutionDetail(executionId) {
const detail = await loadExecutionDetail(executionId);
if (!detail) {
showRefreshToast('Execution not found', 'error');
return;
}
const modalContent = `
<div class="cli-detail-header">
<div class="cli-detail-info">
<span class="cli-tool-tag cli-tool-${detail.tool}">${detail.tool}</span>
<span class="cli-detail-status status-${detail.status}">${detail.status}</span>
<span class="text-muted-foreground">${formatDuration(detail.duration_ms)}</span>
</div>
<div class="cli-detail-meta">
<span class="text-muted-foreground">Model: ${detail.model || 'default'}</span>
<span class="text-muted-foreground">Mode: ${detail.mode}</span>
<span class="text-muted-foreground">${new Date(detail.timestamp).toLocaleString()}</span>
</div>
</div>
<div class="cli-detail-section">
<h4>Prompt</h4>
<pre class="cli-detail-prompt">${escapeHtml(detail.prompt)}</pre>
</div>
${detail.output.stdout ? `
<div class="cli-detail-section">
<h4>Output</h4>
<pre class="cli-detail-output">${escapeHtml(detail.output.stdout)}</pre>
</div>
` : ''}
${detail.output.stderr ? `
<div class="cli-detail-section">
<h4>Errors</h4>
<pre class="cli-detail-error">${escapeHtml(detail.output.stderr)}</pre>
</div>
` : ''}
${detail.output.truncated ? `
<p class="text-warning">Output was truncated due to size.</p>
` : ''}
`;
showModal('Execution Detail', modalContent);
}
// ========== Actions ==========
async function filterCliHistory(tool) {
cliHistoryFilter = tool || null;
await loadCliHistory();
renderCliHistory();
}
async function refreshCliHistory() {
await loadCliHistory();
renderCliHistory();
showRefreshToast('History refreshed', 'success');
}
// ========== Helpers ==========
function formatDuration(ms) {
if (ms >= 60000) {
const mins = Math.floor(ms / 60000);
const secs = Math.round((ms % 60000) / 1000);
return `${mins}m ${secs}s`;
} else if (ms >= 1000) {
return `${(ms / 1000).toFixed(1)}s`;
}
return `${ms}ms`;
}
function getTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
if (seconds < 604800) return `${Math.floor(seconds / 86400)}d ago`;
return date.toLocaleDateString();
}

View File

@@ -0,0 +1,101 @@
// CLI Status Component
// Displays CLI tool availability status and allows setting default tool
// ========== CLI State ==========
let cliToolStatus = { gemini: {}, qwen: {}, codex: {} };
let defaultCliTool = 'gemini';
// ========== Initialization ==========
function initCliStatus() {
// Load CLI status on init
loadCliToolStatus();
}
// ========== Data Loading ==========
async function loadCliToolStatus() {
try {
const response = await fetch('/api/cli/status');
if (!response.ok) throw new Error('Failed to load CLI status');
const data = await response.json();
cliToolStatus = data;
// Update badge
updateCliBadge();
return data;
} catch (err) {
console.error('Failed to load CLI status:', err);
return null;
}
}
// ========== Badge Update ==========
function updateCliBadge() {
const badge = document.getElementById('badgeCliTools');
if (badge) {
const available = Object.values(cliToolStatus).filter(t => t.available).length;
const total = Object.keys(cliToolStatus).length;
badge.textContent = `${available}/${total}`;
badge.classList.toggle('text-success', available === total);
badge.classList.toggle('text-warning', available > 0 && available < total);
badge.classList.toggle('text-destructive', available === 0);
}
}
// ========== Rendering ==========
function renderCliStatus() {
const container = document.getElementById('cli-status-panel');
if (!container) return;
const tools = ['gemini', 'qwen', 'codex'];
const toolsHtml = tools.map(tool => {
const status = cliToolStatus[tool] || {};
const isAvailable = status.available;
const isDefault = defaultCliTool === tool;
return `
<div class="cli-tool-card ${isAvailable ? 'available' : 'unavailable'}">
<div class="cli-tool-header">
<span class="cli-tool-status ${isAvailable ? 'status-available' : 'status-unavailable'}"></span>
<span class="cli-tool-name">${tool.charAt(0).toUpperCase() + tool.slice(1)}</span>
${isDefault ? '<span class="cli-tool-badge">Default</span>' : ''}
</div>
<div class="cli-tool-info">
${isAvailable
? `<span class="text-success">Ready</span>`
: `<span class="text-muted-foreground">Not Installed</span>`
}
</div>
${isAvailable && !isDefault
? `<button class="btn-sm btn-outline" onclick="setDefaultCliTool('${tool}')">Set Default</button>`
: ''
}
</div>
`;
}).join('');
container.innerHTML = `
<div class="cli-status-header">
<h3>CLI Tools</h3>
<button class="btn-icon" onclick="loadCliToolStatus()" title="Refresh">
<i data-lucide="refresh-cw"></i>
</button>
</div>
<div class="cli-tools-grid">
${toolsHtml}
</div>
`;
// Initialize Lucide icons
if (window.lucide) {
lucide.createIcons();
}
}
// ========== Actions ==========
function setDefaultCliTool(tool) {
defaultCliTool = tool;
renderCliStatus();
showRefreshToast(`Default CLI tool set to ${tool}`, 'success');
}

View File

@@ -83,6 +83,31 @@ function handleNotification(data) {
handleToolExecutionNotification(payload);
break;
// CLI Tool Execution Events
case 'CLI_EXECUTION_STARTED':
if (typeof handleCliExecutionStarted === 'function') {
handleCliExecutionStarted(payload);
}
break;
case 'CLI_OUTPUT':
if (typeof handleCliOutput === 'function') {
handleCliOutput(payload);
}
break;
case 'CLI_EXECUTION_COMPLETED':
if (typeof handleCliExecutionCompleted === 'function') {
handleCliExecutionCompleted(payload);
}
break;
case 'CLI_EXECUTION_ERROR':
if (typeof handleCliExecutionError === 'function') {
handleCliExecutionError(payload);
}
break;
default:
console.log('[WS] Unknown notification type:', type);
}

View File

@@ -15,6 +15,8 @@ document.addEventListener('DOMContentLoaded', async () => {
try { initCarousel(); } catch (e) { console.error('Carousel init failed:', e); }
try { initMcpManager(); } catch (e) { console.error('MCP Manager init failed:', e); }
try { initHookManager(); } catch (e) { console.error('Hook Manager init failed:', e); }
try { initCliManager(); } catch (e) { console.error('CLI Manager init failed:', e); }
try { initCliStatus(); } catch (e) { console.error('CLI Status init failed:', e); }
try { initGlobalNotifications(); } catch (e) { console.error('Global notifications init failed:', e); }
try { initVersionCheck(); } catch (e) { console.error('Version check init failed:', e); }

View File

@@ -0,0 +1,282 @@
// CLI Manager View
// Main view combining CLI status and history panels
// ========== CLI Manager State ==========
let currentCliExecution = null;
let cliExecutionOutput = '';
// ========== Initialization ==========
function initCliManager() {
// Initialize CLI navigation
document.querySelectorAll('.nav-item[data-view="cli-manager"]').forEach(item => {
item.addEventListener('click', () => {
setActiveNavItem(item);
currentView = 'cli-manager';
currentFilter = null;
currentLiteType = null;
currentSessionDetailKey = null;
updateContentTitle();
renderCliManager();
});
});
}
// ========== Rendering ==========
async function renderCliManager() {
const mainContent = document.querySelector('.main-content');
if (!mainContent) return;
// Load data
await Promise.all([
loadCliToolStatus(),
loadCliHistory()
]);
mainContent.innerHTML = `
<div class="cli-manager-container">
<div class="cli-manager-grid">
<!-- Status Panel -->
<div class="cli-panel">
<div id="cli-status-panel"></div>
</div>
<!-- Quick Execute Panel -->
<div class="cli-panel">
<div id="cli-execute-panel"></div>
</div>
</div>
<!-- History Panel -->
<div class="cli-panel cli-panel-full">
<div id="cli-history-panel"></div>
</div>
<!-- Live Output Panel (shown during execution) -->
<div class="cli-panel cli-panel-full ${currentCliExecution ? '' : 'hidden'}" id="cli-output-panel">
<div class="cli-output-header">
<h3>Execution Output</h3>
<div class="cli-output-status">
<span id="cli-output-status-indicator" class="status-indicator running"></span>
<span id="cli-output-status-text">Running...</span>
</div>
</div>
<pre class="cli-output-content" id="cli-output-content"></pre>
</div>
</div>
`;
// Render sub-panels
renderCliStatus();
renderCliExecutePanel();
renderCliHistory();
// Initialize Lucide icons
if (window.lucide) {
lucide.createIcons();
}
}
function renderCliExecutePanel() {
const container = document.getElementById('cli-execute-panel');
if (!container) return;
const tools = ['gemini', 'qwen', 'codex'];
const modes = ['analysis', 'write', 'auto'];
container.innerHTML = `
<div class="cli-execute-header">
<h3>Quick Execute</h3>
</div>
<div class="cli-execute-form">
<div class="cli-execute-row">
<div class="cli-form-group">
<label for="cli-exec-tool">Tool</label>
<select id="cli-exec-tool" class="cli-select">
${tools.map(tool => `
<option value="${tool}" ${tool === defaultCliTool ? 'selected' : ''}>
${tool.charAt(0).toUpperCase() + tool.slice(1)}
</option>
`).join('')}
</select>
</div>
<div class="cli-form-group">
<label for="cli-exec-mode">Mode</label>
<select id="cli-exec-mode" class="cli-select">
${modes.map(mode => `
<option value="${mode}" ${mode === 'analysis' ? 'selected' : ''}>
${mode.charAt(0).toUpperCase() + mode.slice(1)}
</option>
`).join('')}
</select>
</div>
</div>
<div class="cli-form-group">
<label for="cli-exec-prompt">Prompt</label>
<textarea id="cli-exec-prompt" class="cli-textarea" placeholder="Enter your prompt..."></textarea>
</div>
<div class="cli-execute-actions">
<button class="btn btn-primary" onclick="executeCliFromDashboard()" ${currentCliExecution ? 'disabled' : ''}>
<i data-lucide="play"></i>
Execute
</button>
</div>
</div>
`;
if (window.lucide) lucide.createIcons();
}
// ========== Execution ==========
async function executeCliFromDashboard() {
const tool = document.getElementById('cli-exec-tool').value;
const mode = document.getElementById('cli-exec-mode').value;
const prompt = document.getElementById('cli-exec-prompt').value.trim();
if (!prompt) {
showRefreshToast('Please enter a prompt', 'error');
return;
}
// Show output panel
currentCliExecution = { tool, mode, prompt, startTime: Date.now() };
cliExecutionOutput = '';
const outputPanel = document.getElementById('cli-output-panel');
const outputContent = document.getElementById('cli-output-content');
const statusIndicator = document.getElementById('cli-output-status-indicator');
const statusText = document.getElementById('cli-output-status-text');
if (outputPanel) outputPanel.classList.remove('hidden');
if (outputContent) outputContent.textContent = '';
if (statusIndicator) {
statusIndicator.className = 'status-indicator running';
}
if (statusText) statusText.textContent = 'Running...';
// Disable execute button
const execBtn = document.querySelector('.cli-execute-actions .btn-primary');
if (execBtn) execBtn.disabled = true;
try {
const response = await fetch('/api/cli/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool,
mode,
prompt,
dir: projectPath
})
});
const result = await response.json();
// Update status
if (statusIndicator) {
statusIndicator.className = `status-indicator ${result.success ? 'success' : 'error'}`;
}
if (statusText) {
const duration = formatDuration(result.execution?.duration_ms || (Date.now() - currentCliExecution.startTime));
statusText.textContent = result.success
? `Completed in ${duration}`
: `Failed: ${result.error || 'Unknown error'}`;
}
// Refresh history
await loadCliHistory();
renderCliHistory();
if (result.success) {
showRefreshToast('Execution completed', 'success');
} else {
showRefreshToast(result.error || 'Execution failed', 'error');
}
} catch (error) {
if (statusIndicator) {
statusIndicator.className = 'status-indicator error';
}
if (statusText) {
statusText.textContent = `Error: ${error.message}`;
}
showRefreshToast(`Execution error: ${error.message}`, 'error');
}
currentCliExecution = null;
// Re-enable execute button
if (execBtn) execBtn.disabled = false;
}
// ========== WebSocket Event Handlers ==========
function handleCliExecutionStarted(payload) {
const { executionId, tool, mode, timestamp } = payload;
currentCliExecution = { executionId, tool, mode, startTime: new Date(timestamp).getTime() };
cliExecutionOutput = '';
// Show output panel if in CLI manager view
if (currentView === 'cli-manager') {
const outputPanel = document.getElementById('cli-output-panel');
const outputContent = document.getElementById('cli-output-content');
const statusIndicator = document.getElementById('cli-output-status-indicator');
const statusText = document.getElementById('cli-output-status-text');
if (outputPanel) outputPanel.classList.remove('hidden');
if (outputContent) outputContent.textContent = '';
if (statusIndicator) statusIndicator.className = 'status-indicator running';
if (statusText) statusText.textContent = `Running ${tool} (${mode})...`;
}
}
function handleCliOutput(payload) {
const { data } = payload;
cliExecutionOutput += data;
// Update output panel if visible
const outputContent = document.getElementById('cli-output-content');
if (outputContent) {
outputContent.textContent = cliExecutionOutput;
// Auto-scroll to bottom
outputContent.scrollTop = outputContent.scrollHeight;
}
}
function handleCliExecutionCompleted(payload) {
const { executionId, success, status, duration_ms } = payload;
// Update status
const statusIndicator = document.getElementById('cli-output-status-indicator');
const statusText = document.getElementById('cli-output-status-text');
if (statusIndicator) {
statusIndicator.className = `status-indicator ${success ? 'success' : 'error'}`;
}
if (statusText) {
statusText.textContent = success
? `Completed in ${formatDuration(duration_ms)}`
: `Failed: ${status}`;
}
currentCliExecution = null;
// Refresh history
if (currentView === 'cli-manager') {
loadCliHistory().then(() => renderCliHistory());
}
}
function handleCliExecutionError(payload) {
const { executionId, error } = payload;
const statusIndicator = document.getElementById('cli-output-status-indicator');
const statusText = document.getElementById('cli-output-status-text');
if (statusIndicator) {
statusIndicator.className = 'status-indicator error';
}
if (statusText) {
statusText.textContent = `Error: ${error}`;
}
currentCliExecution = null;
}