UNPKG

@calmhive/calmhive-cli

Version:

🐝 Intelligent Claude CLI wrapper with rule injection, AFk background processing, voice interface, and adaptive retry

944 lines (826 loc) 26.7 kB
/** * Logs Viewer * * Real-time log viewing for sessions */ const blessed = require('blessed'); const fs = require('fs').promises; const path = require('path'); const os = require('os'); const SessionDatabase = require('../../session-database'); const TUILogParser = require('../tui-log-parser'); class LogsView { constructor(parent, manager) { this.parent = parent; this.manager = manager; this.sessionDb = new SessionDatabase(); this.logParser = new TUILogParser(); this.sessionId = null; this.visible = false; this.tailProcess = null; this.logContent = ''; this.parsedLogData = null; this.viewMode = 'summary'; // 'summary' or 'raw' this.pathCache = new Map(); // Cache successful log paths this.lastModifiedTime = null; // Track file modification time for optimization this.createComponents(); } /** * Create view components */ createComponents() { // Main container this.container = blessed.box({ parent: this.parent, width: '100%', height: '100%', hidden: true }); // Title this.title = blessed.text({ parent: this.container, top: 0, left: 0, width: '100%', height: 1, content: '{center}Session Logs{/center}', tags: true, style: { fg: 'yellow', bold: true } }); // Session info bar this.infoBar = blessed.text({ parent: this.container, top: 1, left: 0, width: '100%', height: 1, content: '', tags: true, style: { fg: 'cyan' } }); // View mode toggle bar this.modeBar = blessed.text({ parent: this.container, top: 2, left: 0, width: '100%', height: 1, content: '{center}[S]ummary | [R]aw | [T]oggle{/center}', tags: true, style: { fg: 'gray' } }); // Log viewer with safer tag handling this.logBox = blessed.log({ parent: this.container, top: 4, left: 0, width: '100%', height: '100%-4', border: { type: 'line' }, style: { border: { fg: 'green' } }, scrollable: true, alwaysScroll: true, mouse: true, keys: true, vi: true, scrollbar: { ch: ' ', track: { bg: 'gray' }, style: { inverse: true } }, tags: true, parseTags: true // Enable for rich formatting }); // Key bindings for view mode toggle this.setupKeyBindings(); } /** * Setup key bindings for view mode toggling */ setupKeyBindings() { this.logBox.key(['s'], () => { this.viewMode = 'summary'; this.refreshDisplay(); }); this.logBox.key(['r'], () => { this.viewMode = 'raw'; this.refreshDisplay(); }); this.logBox.key(['t'], () => { this.viewMode = this.viewMode === 'summary' ? 'raw' : 'summary'; this.refreshDisplay(); }); } /** * Show the view with session logs */ async show(data) { try { // Validate components exist if (!this.container) { throw new Error('LogsView container not initialized'); } if (!this.logBox) { throw new Error('LogsView logBox not initialized'); } this.visible = true; this.container.show(); if (data && data.sessionId) { this.sessionId = data.sessionId; await this.loadSession(); this.startTailing(); } // Focus with error handling if (this.logBox && typeof this.logBox.focus === 'function') { this.logBox.focus(); } else { console.error('[LogsView] Warning: logBox.focus not available'); } } catch (error) { console.error('[LogsView] Error in show():', error); // Try to show error in UI if possible if (this.manager && this.manager.showMessage) { this.manager.showMessage(`Error loading logs: ${error.message}`, 'error'); } throw error; // Re-throw so TUI manager can handle it } } /** * Hide the view */ hide() { this.visible = false; this.container.hide(); this.stopTailing(); // Reset modification time tracking when hiding this.lastModifiedTime = null; } /** * Load session information */ async loadSession() { try { const session = await this.sessionDb.getSession(this.sessionId); if (session) { this.infoBar.setContent( `Session: ${session.id} | Task: ${this.truncate(session.task, 50)} | Status: ${session.status}` ); // Load logs from file await this.loadLogsFromFile(session); } } catch (error) { this.manager.showMessage(`Error loading session: ${error.message}`, 'error'); } } /** * Load logs from log file with smart path resolution */ async loadLogsFromFile(session) { try { const logData = await this.findSessionLogs(session.id); if (logData.found) { this.logContent = logData.content; this.currentLogPath = logData.path; this.logSource = logData.source; if (this.logContent.trim().length > 0) { // Parse logs for beautiful summaries this.parsedLogData = this.logParser.parseLogContent(this.logContent); this.displayLogs(); // Show log source info this.title.setContent(`{center}Session Logs - ${this.logSource}{/center}`); } else { this.logBox.pushLine('{gray-fg}Waiting for logs...{/}'); } } else { this.logBox.pushLine('{gray-fg}No logs found - checked multiple locations{/}'); this.logBox.pushLine(`{gray-fg}Session: ${session.id}{/}`); this.logBox.pushLine(`{gray-fg}Status: ${session.status}{/}`); } } catch (error) { this.logBox.pushLine(`{red-fg}Error loading logs: ${error.message}{/}`); } } /** * Smart log path resolution - checks multiple locations */ async findSessionLogs(sessionId) { const homeDir = os.homedir(); // Check if we have a cached path for this session if (this.pathCache.has(sessionId)) { const cachedPath = this.pathCache.get(sessionId); try { await fs.access(cachedPath.path); const stats = await fs.stat(cachedPath.path); if (stats.size > 0) { const content = await fs.readFile(cachedPath.path, 'utf8'); return { ...cachedPath, content, size: stats.size, mtime: stats.mtime, fromCache: true }; } } catch (error) { // Cached path no longer valid, remove from cache this.pathCache.delete(sessionId); } } // Define all possible log locations in priority order const logCandidates = [ // 1. V3 main logs with AFk prefix (highest priority - current system) { path: path.join(homeDir, '.claude/calmhive/v3/logs', `afk-${sessionId}.log`), source: 'V3 Main Log (AFk)', type: 'v3_main_afk' }, // 2. V3 main logs without prefix (fallback for non-AFk sessions) { path: path.join(homeDir, '.claude/calmhive/v3/logs', `${sessionId}.log`), source: 'V3 Main Log', type: 'v3_main' }, // 3. AFk worker logs (legacy but may have valuable info) { path: path.join(homeDir, '.claude/afk_registry', sessionId, 'worker.log'), source: 'AFk Worker Log', type: 'afk_worker' }, // 4. AFk context monitor (debugging info) { path: path.join(homeDir, '.claude/afk_registry', sessionId, 'context-monitor.log'), source: 'AFk Context Log', type: 'afk_context' }, // 5. General logs directory { path: path.join(homeDir, '.claude/logs', `${sessionId}.log`), source: 'General Log', type: 'general' }, // 6. Calmhive logs directory { path: path.join(homeDir, '.claude/calmhive/logs', `${sessionId}.log`), source: 'Calmhive Log', type: 'calmhive' } ]; // Try each location in order for (const candidate of logCandidates) { try { // Check if file exists and is readable await fs.access(candidate.path); const stats = await fs.stat(candidate.path); if (stats.size > 0) { const content = await fs.readFile(candidate.path, 'utf8'); const result = { found: true, path: candidate.path, source: candidate.source, type: candidate.type, content, size: stats.size, mtime: stats.mtime }; // Cache the successful path for future lookups this.pathCache.set(sessionId, { path: candidate.path, source: candidate.source, type: candidate.type }); return result; } } catch (error) { // File doesn't exist or isn't readable, continue to next candidate continue; } } // If no logs found, try to aggregate multiple sources const aggregatedLogs = await this.aggregateMultipleLogs(sessionId, logCandidates); if (aggregatedLogs.found) { return aggregatedLogs; } return { found: false, checkedPaths: logCandidates.map(c => c.path), sessionId }; } /** * Aggregate logs from multiple sources for unified view */ async aggregateMultipleLogs(sessionId, candidates) { const foundLogs = []; for (const candidate of candidates) { try { await fs.access(candidate.path); const stats = await fs.stat(candidate.path); const content = await fs.readFile(candidate.path, 'utf8'); if (content.trim().length > 0) { foundLogs.push({ ...candidate, content, size: stats.size, mtime: stats.mtime }); } } catch (error) { // Skip inaccessible files } } if (foundLogs.length === 0) { return { found: false }; } // Sort by modification time to show chronological order foundLogs.sort((a, b) => a.mtime - b.mtime); // Create aggregated content with source markers let aggregatedContent = ''; aggregatedContent += `=== Session ${sessionId} - Aggregated Logs ===\n`; aggregatedContent += `Found ${foundLogs.length} log sources\n\n`; for (const log of foundLogs) { aggregatedContent += `--- ${log.source} (${log.path}) ---\n`; aggregatedContent += `Modified: ${log.mtime.toLocaleString()}\n`; aggregatedContent += `Size: ${Math.round(log.size / 1024)}KB\n\n`; aggregatedContent += log.content; aggregatedContent += '\n\n'; } return { found: true, path: foundLogs[0].path, // Primary path for tailing source: `Aggregated (${foundLogs.length} sources)`, type: 'aggregated', content: aggregatedContent, sources: foundLogs }; } /** * Start tailing logs with smart path resolution */ async startTailing() { // Use smart log path resolution for tailing this.tailInterval = setInterval(async () => { if (!this.visible) {return;} try { const session = await this.sessionDb.getSession(this.sessionId); if (!session) {return;} // Use smart path resolution to find current log location let currentContent = ''; let logPath = this.currentLogPath; let shouldReadFile = false; // If we have a current log path, check if it's been modified if (logPath) { try { const stats = await fs.stat(logPath); // Only read file if it has been modified since last check if (!this.lastModifiedTime || stats.mtime > this.lastModifiedTime) { shouldReadFile = true; this.lastModifiedTime = stats.mtime; } if (shouldReadFile) { currentContent = await fs.readFile(logPath, 'utf8'); } } catch (error) { // Primary path failed, try smart resolution const logData = await this.findSessionLogs(session.id); if (logData.found) { logPath = logData.path; currentContent = logData.content; this.currentLogPath = logPath; this.logSource = logData.source; this.lastModifiedTime = logData.mtime; shouldReadFile = true; } } } else { // No current path, use smart resolution const logData = await this.findSessionLogs(session.id); if (logData.found) { logPath = logData.path; currentContent = logData.content; this.currentLogPath = logPath; this.logSource = logData.source; this.lastModifiedTime = logData.mtime; shouldReadFile = true; } } // Only update display if we read new content if (shouldReadFile && (currentContent.length !== this.logContent.length || (currentContent.trim().length > 0 && this.logBox.getContent().includes('Waiting for logs')))) { this.logContent = currentContent; // Re-parse logs when content changes if (this.logContent.trim().length > 0) { this.parsedLogData = this.logParser.parseLogContent(this.logContent); } this.displayLogs(); // Update title with current log source if (this.logSource) { this.title.setContent(`{center}Session Logs - ${this.logSource}{/center}`); } } } catch (error) { // Log errors during tailing for debugging if (process.env.DEBUG) { console.error('Tailing error:', error.message); } } }, 1000); // Check every second } /** * Stop tailing logs */ stopTailing() { if (this.tailInterval) { clearInterval(this.tailInterval); this.tailInterval = null; } } /** * Display all logs (summary or raw based on view mode) */ displayLogs() { try { // Ensure logBox exists and is ready if (!this.logBox || typeof this.logBox.setContent !== 'function') { console.error('[LogsView] LogBox not ready for content display'); return; } // Clear content safely with error handling try { this.logBox.setContent(''); } catch (clearError) { console.error('[LogsView] Error clearing content:', clearError); return; } if (!this.logContent || typeof this.logContent !== 'string') { this.logBox.pushLine('No log content available'); this.safeRender(); return; } // Update mode bar const modeText = this.viewMode === 'summary' ? '{bold}{green-fg}[S]ummary{/green-fg}{/bold} | [R]aw | [T]oggle' : '[S]ummary | {bold}{green-fg}[R]aw{/green-fg}{/bold} | [T]oggle'; this.modeBar.setContent(`{center}${modeText}{/center}`); if (this.viewMode === 'summary' && this.parsedLogData) { this.displaySummaryView(); } else { this.displayRawView(); } // Defer scrolling and rendering to avoid blessed widget issues this.safeRender(); } catch (error) { console.error('[LogsView] Error in displayLogs:', error); // Try to show error in log viewer try { if (this.logBox && typeof this.logBox.pushLine === 'function') { this.logBox.pushLine(`Error loading logs: ${error.message}`); this.safeRender(); } } catch (fallbackError) { console.error('[LogsView] Fallback error display failed:', fallbackError); } } } /** * Display beautiful summary view */ displaySummaryView() { if (!this.parsedLogData) { this.logBox.pushLine('{yellow-fg}Parsing logs...{/yellow-fg}'); return; } const formattedSummary = this.logParser.formatForTUI(this.parsedLogData); const lines = formattedSummary.split('\n'); lines.forEach(line => { if (line.trim()) { this.logBox.pushLine(line); } }); // Add detailed iteration views if available if (this.parsedLogData.iterations && this.parsedLogData.iterations.length > 0) { this.logBox.pushLine(''); this.logBox.pushLine('{bold}📋 Iteration Details:{/bold}'); this.logBox.pushLine(''); this.parsedLogData.iterations.forEach((iteration, index) => { this.displayIterationDetails(iteration, index); }); } } /** * Display detailed iteration view */ displayIterationDetails(iteration, index) { const { summary } = iteration; // Collapsible iteration header this.logBox.pushLine(`{bold}${summary.statusEmoji} Iteration ${iteration.number}{/bold} ${summary.progressBar}`); // Status and timing info if (summary.keyEvents.length > 0) { this.logBox.pushLine(` {gray-fg}Events: ${summary.keyEvents.join(' • ')}{/gray-fg}`); } // Error details (if any) if (iteration.errors.length > 0) { this.logBox.pushLine(' {red-fg}Errors:{/red-fg}'); iteration.errors.forEach(error => { this.logBox.pushLine(` {red-fg}• ${error}{/red-fg}`); }); } // Warning details (if any) if (iteration.warnings.length > 0) { this.logBox.pushLine(' {yellow-fg}Warnings:{/yellow-fg}'); iteration.warnings.forEach(warning => { this.logBox.pushLine(` {yellow-fg}• ${warning}{/yellow-fg}`); }); } // Sample log lines (first 3, last 3) if (iteration.logs.length > 6) { this.logBox.pushLine(' {gray-fg}First 3 lines:{/gray-fg}'); iteration.logs.slice(0, 3).forEach(line => { this.logBox.pushLine(` {gray-fg}${this.truncate(line, 100)}{/gray-fg}`); }); this.logBox.pushLine(` {gray-fg}... (${iteration.logs.length - 6} lines hidden) ...{/gray-fg}`); this.logBox.pushLine(' {gray-fg}Last 3 lines:{/gray-fg}'); iteration.logs.slice(-3).forEach(line => { this.logBox.pushLine(` {gray-fg}${this.truncate(line, 100)}{/gray-fg}`); }); } else { iteration.logs.forEach(line => { this.logBox.pushLine(` {gray-fg}${this.truncate(line, 100)}{/gray-fg}`); }); } this.logBox.pushLine(''); } /** * Display raw log view (original behavior) */ displayRawView() { const lines = this.logContent.split('\n'); let hasContent = false; let processedLines = []; // Process all lines first to avoid timing issues lines.forEach(line => { if (line && line.trim().length > 0) { try { const formattedLine = this.formatLogLine(line); if (formattedLine !== null && formattedLine !== undefined) { processedLines.push(formattedLine); hasContent = true; } } catch (lineError) { // If formatting fails, escape and push raw line console.error('[LogsView] Error formatting line:', lineError); const safeLine = this.escapeBlessedTags(String(line)); processedLines.push(safeLine); hasContent = true; } } }); // Add all lines at once to avoid blessed.js timing issues if (hasContent) { processedLines.forEach(line => { try { this.logBox.pushLine(line); } catch (pushError) { console.error('[LogsView] Error pushing line:', pushError); // Try with escaped version try { this.logBox.pushLine(this.escapeBlessedTags(String(line))); } catch (escapeError) { console.error('[LogsView] Error with escaped line:', escapeError); } } }); } else { this.logBox.pushLine('Log file is empty...'); } } /** * Refresh display when view mode changes */ refreshDisplay() { this.displayLogs(); this.manager.render(); } /** * Append new log content */ appendLogs(newContent) { const lines = newContent.split('\n'); lines.forEach(line => { if (line.trim()) { this.logBox.pushLine(this.formatLogLine(line)); } }); // Auto-scroll to bottom if already at bottom if (this.logBox.getScrollPerc() > 90) { this.logBox.setScrollPerc(100); } this.manager.render(); } /** * Format a log line with colors */ formatLogLine(line) { // Ensure line is a string if (line === null || line === undefined) { return ''; } line = String(line); // For raw view, escape blessed tags for safety if (this.viewMode === 'raw') { line = this.escapeBlessedTags(line); } // Add timestamp if not present if (!line.match(/^\[\d{4}-\d{2}-\d{2}/)) { const timestamp = new Date().toLocaleTimeString(); line = `[${timestamp}] ${line}`; } // Use blessed tags for rich formatting in summary mode if (this.viewMode === 'summary') { if (line.includes('ERROR') || line.includes('FAILED')) { return `{red-fg}${line}{/red-fg}`; } else if (line.includes('WARNING') || line.includes('WARN')) { return `{yellow-fg}${line}{/yellow-fg}`; } else if (line.includes('SUCCESS') || line.includes('COMPLETE')) { return `{green-fg}${line}{/green-fg}`; } else if (line.includes('INFO')) { return `{cyan-fg}${line}{/cyan-fg}`; } else if (line.includes('DEBUG')) { return `{gray-fg}${line}{/gray-fg}`; } } else { // Use ANSI escape codes for raw mode if (line.includes('ERROR') || line.includes('FAILED')) { return `\u001b[31m${line}\u001b[0m`; // Red } else if (line.includes('WARNING') || line.includes('WARN')) { return `\u001b[33m${line}\u001b[0m`; // Yellow } else if (line.includes('SUCCESS') || line.includes('COMPLETE')) { return `\u001b[32m${line}\u001b[0m`; // Green } else if (line.includes('INFO')) { return `\u001b[36m${line}\u001b[0m`; // Cyan } else if (line.includes('DEBUG')) { return `\u001b[90m${line}\u001b[0m`; // Gray } } return line; } /** * Safely escape blessed tags to prevent parser crashes */ escapeBlessedTags(text) { if (typeof text !== 'string') { return String(text || ''); } // Only escape when in raw mode or when explicitly needed if (this.viewMode === 'raw') { return text .replace(/\{/g, '\\{') .replace(/\}/g, '\\}'); } return text; } /** * Safe rendering with error handling */ safeRender() { // Use double setTimeout to ensure blessed.js is ready setTimeout(() => { try { if (this.logBox && typeof this.logBox.setScrollPerc === 'function') { this.logBox.setScrollPerc(100); } } catch (scrollError) { console.error('[LogsView] Error during scroll:', scrollError); } setTimeout(() => { try { if (this.manager && typeof this.manager.render === 'function') { this.manager.render(); } } catch (renderError) { console.error('[LogsView] Error during render:', renderError); } }, 10); }, 10); } /** * Truncate string */ truncate(str, length) { if (!str) {return '';} if (str.length <= length) {return str;} return str.substring(0, length - 3) + '...'; } /** * Navigation methods */ navigateUp() { this.logBox.scroll(-1); } navigateDown() { this.logBox.scroll(1); } pageUp() { this.logBox.scroll(-10); } pageDown() { this.logBox.scroll(10); } /** * Enhanced vim-style navigation methods */ goToTop() { this.logBox.setScrollPerc(0); } goToBottom() { this.logBox.setScrollPerc(100); } goToMiddle() { // Go to middle of content (what M should do) this.logBox.setScrollPerc(50); } goToViewTop() { // Already at view top if no scrolling implemented differently // For log viewer, this is same as current position } goToViewMiddle() { this.logBox.setScrollPerc(50); } goToViewBottom() { this.logBox.setScrollPerc(100); } halfPageUp() { const halfPage = Math.floor((this.logBox.height - 2) / 2); this.logBox.scroll(-halfPage); } halfPageDown() { const halfPage = Math.floor((this.logBox.height - 2) / 2); this.logBox.scroll(halfPage); } /** * Search functionality for logs */ search(searchTerm, direction = 'forward') { if (!searchTerm) {return;} this.currentSearchTerm = searchTerm.toLowerCase(); this.searchDirection = direction; this.searchResults = []; const content = this.logContent || ''; const lines = content.split('\n'); // Find all matching line numbers lines.forEach((line, index) => { if (line.toLowerCase().includes(this.currentSearchTerm)) { this.searchResults.push(index); } }); if (this.searchResults.length === 0) { this.manager.showMessage(`No results for: ${searchTerm}`, 'warning'); return; } // Jump to first result this.currentSearchIndex = 0; this.goToSearchResult(); this.manager.showMessage(`Found ${this.searchResults.length} results for: ${searchTerm}`, 'info'); } nextSearchResult() { if (!this.searchResults || this.searchResults.length === 0) {return;} this.currentSearchIndex = (this.currentSearchIndex + 1) % this.searchResults.length; this.goToSearchResult(); } previousSearchResult() { if (!this.searchResults || this.searchResults.length === 0) {return;} this.currentSearchIndex = this.currentSearchIndex - 1; if (this.currentSearchIndex < 0) { this.currentSearchIndex = this.searchResults.length - 1; } this.goToSearchResult(); } goToSearchResult() { if (!this.searchResults || this.searchResults.length === 0) {return;} const lineNumber = this.searchResults[this.currentSearchIndex]; const totalLines = (this.logContent || '').split('\n').length; const scrollPerc = totalLines > 0 ? (lineNumber / totalLines) * 100 : 0; this.logBox.setScrollPerc(Math.min(100, Math.max(0, scrollPerc))); // Show search status const current = this.currentSearchIndex + 1; const total = this.searchResults.length; this.manager.showMessage(`Result ${current}/${total}: ${this.currentSearchTerm} (line ${lineNumber + 1})`, 'info'); } } module.exports = LogsView;