UNPKG

jay-code

Version:

Streamlined AI CLI orchestration engine with mathematical rigor and enterprise-grade reliability

684 lines (590 loc) 16.8 kB
/** * Terminal Emulator for Claude Code Console * Provides terminal-like behavior and output formatting */ export class TerminalEmulator { constructor(outputElement, inputElement) { this.outputElement = outputElement; this.inputElement = inputElement; this.history = []; this.historyIndex = -1; this.maxHistorySize = 1000; this.maxOutputLines = 1000; this.currentPrompt = 'jay-code>'; this.isLocked = false; // Command suggestions this.commands = [ 'help', 'clear', 'status', 'connect', 'disconnect', 'jay-code', 'swarm', 'init', 'config', 'memory', 'tools', 'agents', 'benchmark', 'sparc', ]; // ANSI color codes mapping this.ansiColors = { 30: '#000000', // Black 31: '#ff5555', // Red 32: '#50fa7b', // Green 33: '#f1fa8c', // Yellow 34: '#bd93f9', // Blue 35: '#ff79c6', // Magenta 36: '#8be9fd', // Cyan 37: '#f8f8f2', // White 90: '#6272a4', // Bright Black (Gray) 91: '#ff6e6e', // Bright Red 92: '#69ff94', // Bright Green 93: '#ffffa5', // Bright Yellow 94: '#d6acff', // Bright Blue 95: '#ff92df', // Bright Magenta 96: '#a4ffff', // Bright Cyan 97: '#ffffff', // Bright White }; this.setupInputHandlers(); this.setupScrollBehavior(); } /** * Write output to terminal */ write(content, type = 'output', timestamp = true) { const entry = this.createOutputEntry(content, type, timestamp); this.outputElement.appendChild(entry); this.limitOutputLines(); this.scrollToBottom(); return entry; } /** * Write line to terminal */ writeLine(content, type = 'output', timestamp = true) { return this.write(content + '\n', type, timestamp); } /** * Write command to terminal */ writeCommand(command) { return this.write(`${this.currentPrompt} ${command}`, 'command', true); } /** * Write error message */ writeError(message) { return this.writeLine(`Error: ${message}`, 'error'); } /** * Write success message */ writeSuccess(message) { return this.writeLine(message, 'success'); } /** * Write warning message */ writeWarning(message) { return this.writeLine(`Warning: ${message}`, 'warning'); } /** * Write info message */ writeInfo(message) { return this.writeLine(message, 'info'); } /** * Write raw HTML content */ writeHTML(html, type = 'output') { const entry = document.createElement('div'); entry.className = 'output-entry'; entry.innerHTML = html; if (type) { entry.classList.add(`output-${type}`); } this.outputElement.appendChild(entry); this.limitOutputLines(); this.scrollToBottom(); return entry; } /** * Clear terminal output */ clear() { this.outputElement.innerHTML = ''; this.showWelcomeMessage(); } /** * Show welcome message */ showWelcomeMessage() { // Check if welcome message already exists (from static HTML) const existingWelcome = this.outputElement.querySelector('.welcome-message'); if (existingWelcome) { // Welcome message already exists, don't add another one return; } const welcome = document.createElement('div'); welcome.className = 'welcome-message'; welcome.innerHTML = ` <div class="ascii-art">╔═══════════════════════════════════════════════════════════╗ ║ ║ ║ 🌊 Claude Flow v2.0.0 ║ ║ ║ ║ Welcome to the web-based swarm orchestration ║ ║ Type 'help' for available commands ║ ║ Use Ctrl+L to clear console ║ ║ ║ ╚═══════════════════════════════════════════════════════════╝</div> `; this.outputElement.appendChild(welcome); } /** * Set prompt text */ setPrompt(prompt) { this.currentPrompt = prompt; const promptElement = document.getElementById('promptText'); if (promptElement) { promptElement.textContent = prompt; } } /** * Lock/unlock input */ setLocked(locked) { this.isLocked = locked; this.inputElement.disabled = locked; if (locked) { this.inputElement.placeholder = 'Processing...'; } else { this.inputElement.placeholder = 'Enter command...'; this.inputElement.focus(); } } /** * Focus input */ focus() { if (!this.isLocked) { this.inputElement.focus(); } } /** * Get current input value */ getInput() { return this.inputElement.value; } /** * Set input value */ setInput(value) { this.inputElement.value = value; } /** * Clear input */ clearInput() { this.inputElement.value = ''; } /** * Add command to history */ addToHistory(command) { if (command.trim() && this.history[this.history.length - 1] !== command) { this.history.push(command); if (this.history.length > this.maxHistorySize) { this.history.shift(); } } this.historyIndex = -1; } /** * Navigate command history */ navigateHistory(direction) { if (this.history.length === 0) return; if (direction === 'up') { if (this.historyIndex === -1) { this.historyIndex = this.history.length - 1; } else if (this.historyIndex > 0) { this.historyIndex--; } } else if (direction === 'down') { if (this.historyIndex < this.history.length - 1) { this.historyIndex++; } else { this.historyIndex = -1; } } if (this.historyIndex === -1) { this.setInput(''); } else { this.setInput(this.history[this.historyIndex]); } } /** * Create output entry element */ createOutputEntry(content, type, timestamp) { const entry = document.createElement('div'); entry.className = 'output-entry'; const line = document.createElement('div'); line.className = 'output-line'; // Add timestamp if enabled if (timestamp && this.shouldShowTimestamp()) { const timeElement = document.createElement('span'); timeElement.className = 'output-timestamp'; timeElement.textContent = this.formatTimestamp(new Date()); line.appendChild(timeElement); } // Add content const contentElement = document.createElement('span'); contentElement.className = `output-content ${type}`; // Process ANSI codes if present if (typeof content === 'string' && content.includes('\x1b[')) { contentElement.innerHTML = this.processAnsiCodes(content); } else { contentElement.textContent = content; } line.appendChild(contentElement); entry.appendChild(line); return entry; } /** * Process ANSI escape codes */ processAnsiCodes(text) { // Simple ANSI processing - convert color codes to HTML return ( text .replace(/\x1b\[(\d+)m/g, (match, code) => { if (code === '0' || code === '00') { return '</span>'; // Reset } const color = this.ansiColors[code]; if (color) { return `<span style="color: ${color}">`; } return ''; }) .replace(/\x1b\[[\d;]*m/g, '') + // Remove other ANSI codes '</span>' ); // Ensure we close any open spans } /** * Format timestamp */ formatTimestamp(date) { return date.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit', }); } /** * Check if timestamps should be shown */ shouldShowTimestamp() { const showTimestamps = localStorage.getItem('console_show_timestamps'); return showTimestamps !== 'false'; } /** * Limit output lines */ limitOutputLines() { const entries = this.outputElement.querySelectorAll('.output-entry'); if (entries.length > this.maxOutputLines) { const excessCount = entries.length - this.maxOutputLines; for (let i = 0; i < excessCount; i++) { if (entries[i] && !entries[i].classList.contains('welcome-message')) { entries[i].remove(); } } } } /** * Scroll to bottom */ scrollToBottom(smooth = false) { if (this.shouldAutoScroll()) { if (smooth) { this.outputElement.scrollTo({ top: this.outputElement.scrollHeight, behavior: 'smooth', }); } else { this.outputElement.scrollTop = this.outputElement.scrollHeight; } } } /** * Check if auto-scroll is enabled */ shouldAutoScroll() { const autoScroll = localStorage.getItem('console_auto_scroll'); return autoScroll !== 'false'; } /** * Setup input event handlers */ setupInputHandlers() { this.inputElement.addEventListener('keydown', (event) => { if (this.isLocked) { event.preventDefault(); return; } switch (event.key) { case 'Enter': event.preventDefault(); this.handleEnter(); break; case 'ArrowUp': event.preventDefault(); this.navigateHistory('up'); break; case 'ArrowDown': event.preventDefault(); this.navigateHistory('down'); break; case 'Tab': event.preventDefault(); this.handleTab(); break; case 'l': if (event.ctrlKey) { event.preventDefault(); this.clear(); } break; case 'c': if (event.ctrlKey) { event.preventDefault(); this.handleInterrupt(); } break; } }); this.inputElement.addEventListener('input', () => { if (!this.isLocked) { this.handleInput(); } }); } /** * Handle Enter key */ handleEnter() { const command = this.getInput().trim(); if (command) { this.addToHistory(command); this.writeCommand(command); this.clearInput(); // Emit command event this.emit('command', command); } } /** * Handle Tab key (autocomplete) */ handleTab() { const input = this.getInput(); const matches = this.commands.filter((cmd) => cmd.startsWith(input)); if (matches.length === 1) { this.setInput(matches[0] + ' '); } else if (matches.length > 1) { this.writeLine(`Available commands: ${matches.join(', ')}`, 'info'); } } /** * Handle input changes */ handleInput() { // Could be used for live suggestions in the future this.emit('input_change', this.getInput()); } /** * Handle Ctrl+C interrupt */ handleInterrupt() { this.writeLine('^C', 'warning'); this.clearInput(); this.emit('interrupt'); } /** * Setup scroll behavior */ setupScrollBehavior() { let isUserScrolling = false; let scrollTimeout; let lastScrollTop = 0; this.outputElement.addEventListener('scroll', () => { const currentScrollTop = this.outputElement.scrollTop; const maxScrollTop = this.outputElement.scrollHeight - this.outputElement.clientHeight; // Check if user scrolled up (away from bottom) if (currentScrollTop < lastScrollTop && currentScrollTop < maxScrollTop - 10) { isUserScrolling = true; // Show scroll indicator if not already visible this.showScrollIndicator(); clearTimeout(scrollTimeout); // Don't auto-resume scrolling for 3 seconds after user scrolls up scrollTimeout = setTimeout(() => { // Only resume auto-scroll if user is back near the bottom const newScrollTop = this.outputElement.scrollTop; const newMaxScrollTop = this.outputElement.scrollHeight - this.outputElement.clientHeight; if (newScrollTop >= newMaxScrollTop - 50) { isUserScrolling = false; this.hideScrollIndicator(); } }, 3000); } // If user scrolled to bottom, resume auto-scrolling immediately else if (currentScrollTop >= maxScrollTop - 10) { isUserScrolling = false; this.hideScrollIndicator(); clearTimeout(scrollTimeout); } lastScrollTop = currentScrollTop; }); // Override shouldAutoScroll to check user scrolling const originalShouldAutoScroll = this.shouldAutoScroll; this.shouldAutoScroll = () => { return originalShouldAutoScroll.call(this) && !isUserScrolling; }; // Store reference for manual scroll control this.isUserScrolling = () => isUserScrolling; this.resumeAutoScroll = () => { isUserScrolling = false; this.hideScrollIndicator(); this.scrollToBottom(true); // Smooth scroll to bottom }; } /** * Show scroll indicator */ showScrollIndicator() { let indicator = document.getElementById('scrollIndicator'); if (!indicator) { indicator = document.createElement('div'); indicator.id = 'scrollIndicator'; indicator.className = 'scroll-indicator'; indicator.innerHTML = ` <span class="scroll-text">Auto-scroll paused</span> <button class="scroll-resume-btn" onclick="window.claudeConsole.terminal.resumeAutoScroll()"> ↓ Resume </button> `; // Position it relative to the console container const consoleContainer = this.outputElement.closest('.console-container'); if (consoleContainer) { consoleContainer.appendChild(indicator); } else { document.body.appendChild(indicator); } } indicator.style.display = 'flex'; } /** * Hide scroll indicator */ hideScrollIndicator() { const indicator = document.getElementById('scrollIndicator'); if (indicator) { indicator.style.display = 'none'; } } /** * Stream text output with typing effect */ async streamText(text, delay = 10) { const entry = this.createOutputEntry('', 'output', true); this.outputElement.appendChild(entry); const contentElement = entry.querySelector('.output-content'); for (let i = 0; i < text.length; i++) { contentElement.textContent += text[i]; this.scrollToBottom(); if (delay > 0) { await new Promise((resolve) => setTimeout(resolve, delay)); } } return entry; } /** * Add event listener */ on(event, callback) { if (!this.eventListeners) { this.eventListeners = new Map(); } if (!this.eventListeners.has(event)) { this.eventListeners.set(event, []); } this.eventListeners.get(event).push(callback); } /** * Emit event */ emit(event, data) { if (!this.eventListeners || !this.eventListeners.has(event)) { return; } this.eventListeners.get(event).forEach((callback) => { try { callback(data); } catch (error) { console.error('Error in terminal event listener:', error); } }); } /** * Set maximum output lines */ setMaxLines(maxLines) { this.maxOutputLines = Math.max(100, Math.min(10000, maxLines)); this.limitOutputLines(); } /** * Get terminal statistics */ getStats() { const entries = this.outputElement.querySelectorAll('.output-entry'); return { totalLines: entries.length, historySize: this.history.length, isLocked: this.isLocked, currentPrompt: this.currentPrompt, }; } /** * Export terminal history */ exportHistory() { const entries = Array.from(this.outputElement.querySelectorAll('.output-entry')); return entries.map((entry) => { const timestamp = entry.querySelector('.output-timestamp')?.textContent || ''; const content = entry.querySelector('.output-content')?.textContent || ''; const type = entry .querySelector('.output-content') ?.className.split(' ') .find((c) => c.startsWith('output-')) || ''; return { timestamp, content, type }; }); } /** * Import terminal history */ importHistory(history) { this.clear(); history.forEach(({ timestamp, content, type }) => { this.write(content, type.replace('output-', ''), false); }); } }