/** * Terminal Manager - Frontend for xterm.js terminals */ class TerminalManager { constructor() { this.terminals = new Map(); // terminalId -> { terminal, ws, fitAddon, container, mode, ready } this.activeTerminalId = null; this.xtermLoaded = false; this.terminalsContainer = null; this.terminalTabsContainer = null; this.debugMessages = []; // Bind methods this.createTerminal = this.createTerminal.bind(this); this.switchToTerminal = this.switchToTerminal.bind(this); this.closeTerminal = this.closeTerminal.bind(this); this.setMode = this.setMode.bind(this); this.clearScreen = this.clearScreen.bind(this); } /** * Log debug message to both console and visual debug panel */ debugLog(category, message, data = null) { const timestamp = new Date().toLocaleTimeString(); const logEntry = `[${timestamp}] [${category}] ${message}`; // Log to console console.log(logEntry, data || ''); // Add to debug panel this.debugMessages.push({ timestamp, category, message, data }); if (this.debugMessages.length > 50) { this.debugMessages.shift(); // Keep only last 50 messages } const debugContent = document.getElementById('terminal-debug-content'); if (debugContent) { const colorMap = { 'INIT': '#4a9eff', 'WS': '#a78bfa', 'CMD': '#51cf66', 'ERROR': '#ff6b6b', 'READY': '#ffd43b', 'PTY': '#ffa94d' }; const color = colorMap[category] || '#e0e0e0'; debugContent.innerHTML = this.debugMessages.map(msg => { const displayColor = colorMap[msg.category] || '#e0e0e0'; return `
[${msg.timestamp}] [${msg.category}] ${msg.message}${msg.data ? ` - ${JSON.stringify(msg.data)}` : ''}
`; }).join(''); // Auto-scroll to bottom debugContent.scrollTop = debugContent.scrollHeight; } } /** * Load xterm.js CSS dynamically */ async loadXTerm() { if (this.xtermLoaded) return; try { // Load xterm.js CSS const cssLink = document.createElement('link'); cssLink.rel = 'stylesheet'; cssLink.href = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css'; document.head.appendChild(cssLink); // Load xterm.js library await this.loadScript('https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js'); // Load addons await this.loadScript('https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js'); await this.loadScript('https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js'); this.xtermLoaded = true; console.log('[TerminalManager] xterm.js loaded'); } catch (error) { console.error('[TerminalManager] Failed to load xterm.js:', error); throw error; } } /** * Helper to load script dynamically */ loadScript(src) { return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = src; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } /** * Initialize terminal UI */ async initialize() { // Load xterm.js await this.loadXTerm(); // Get container elements this.terminalsContainer = document.getElementById('terminals-container'); this.terminalTabsContainer = document.getElementById('terminal-tabs'); // Check for restore state await this.checkForRestore(); // Set up keyboard shortcuts this.setupKeyboardShortcuts(); } /** * Check for saved terminal state and offer restore */ async checkForRestore() { try { const res = await fetch('/claude/api/claude/terminal-restore'); const data = await res.json(); if (data.success && data.state && data.state.terminals && data.state.terminals.length > 0) { this.showRestorePrompt(data.state); } } catch (error) { console.error('[TerminalManager] Error checking for restore:', error); } } /** * Show restore prompt toast */ showRestorePrompt(state) { const terminalCount = state.terminals.length; const toast = document.createElement('div'); toast.className = 'toast-notification toast-info restore-toast'; toast.innerHTML = `
๐Ÿ“
Previous session detected
${terminalCount} terminal${terminalCount > 1 ? 's' : ''} from last session
`; document.body.appendChild(toast); setTimeout(() => toast.classList.add('visible'), 10); // Handle restore toast.querySelector('.btn-restore-all').addEventListener('click', async () => { toast.classList.remove('visible'); await this.restoreTerminals(state.terminals); setTimeout(() => toast.remove(), 300); }); // Handle dismiss toast.querySelector('.btn-dismiss').addEventListener('click', () => { toast.classList.remove('visible'); setTimeout(() => toast.remove(), 300); }); // Auto-dismiss after 30 seconds setTimeout(() => { if (document.body.contains(toast)) { toast.classList.remove('visible'); setTimeout(() => toast.remove(), 300); } }, 30000); } /** * Restore terminals from saved state */ async restoreTerminals(savedTerminals) { for (const saved of savedTerminals) { await this.createTerminal({ workingDir: saved.workingDir, sessionId: saved.sessionId, mode: saved.mode, silent: true }); } showToast(`Restored ${savedTerminals.length} terminal${savedTerminals.length > 1 ? 's' : ''}`, 'success'); } /** * Create a new terminal */ async createTerminal(options = {}) { const { workingDir = null, sessionId = null, mode = 'mixed', silent = false, skipSessionPicker = false, terminalType = null } = options; this.debugLog('INIT', `createTerminal called with options`, { workingDir, sessionId, mode, terminalType }); // Show directory picker if no working directory provided const selection = workingDir ? { directory: workingDir, terminalType: terminalType || 'standard' } : await this.showDirectoryPicker(); if (!selection) { this.debugLog('INIT', `User cancelled directory picker`); return null; } const { directory: selectedDir, terminalType: selectedTerminalType } = selection; this.debugLog('INIT', `Directory selected: ${selectedDir}, terminalType: ${selectedTerminalType}`); // If no session provided and not skipping picker, show session picker let sessionSelection = null; if (!sessionId && !skipSessionPicker && selectedTerminalType !== 'claude-cli') { // Skip session picker if Claude Code CLI terminal is selected this.debugLog('INIT', `Showing session picker...`); sessionSelection = await this.showSessionPicker(); // If user cancelled session picker, still create terminal but without session // sessionSelection will be null or { sessionId: string, source: 'web'|'local' } or { sessionId: 'new', source: 'new' } this.debugLog('INIT', `Session picker result:`, sessionSelection); } try { // Create terminal via API this.debugLog('INIT', `Calling /claude/api/terminals to create terminal...`); const res = await fetch('/claude/api/terminals', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir: selectedDir, sessionId: sessionSelection && sessionSelection.sessionId !== 'new' ? sessionSelection.sessionId : null, mode }) }); const data = await res.json(); if (!data.success) { this.debugLog('ERROR', `API call failed:`, data); throw new Error(data.error || 'Failed to create terminal'); } const terminalId = data.terminalId; this.debugLog('INIT', `Terminal created with ID: ${terminalId}`); // Create terminal UI this.debugLog('INIT', `Creating terminal UI...`); await this.createTerminalUI(terminalId, selectedDir, mode); // Initialize xterm.js FIRST (before connecting WebSocket) // This ensures this.terminals map has the entry ready this.debugLog('INIT', `Initializing xterm.js...`); await this.initializeXTerm(terminalId); this.debugLog('INIT', `xterm.js initialized, terminal should be in map now`); // NOW connect WebSocket (terminal entry exists in map) // This waits for the 'ready' message from backend this.debugLog('INIT', `Connecting WebSocket...`); await this.connectTerminal(terminalId); this.debugLog('INIT', `WebSocket connected and ready`); // Switch to new terminal this.debugLog('INIT', `Switching to terminal ${terminalId}...`); this.switchToTerminal(terminalId); this.debugLog('INIT', `Switched to terminal ${terminalId}`); // Handle terminal type specific initialization if (selectedTerminalType === 'claude-cli') { this.debugLog('CMD', `Claude Code CLI terminal selected, launching command...`); // Launch Claude CLI with skip permissions flag // Note: Keep mode as 'mixed' since we're not attaching to a session // connectTerminal now waits for 'ready' message, so PTY is definitely ready await this.launchCommand(terminalId, 'claude --dangerously-skip-permissions\n'); if (!silent) { showToast('Claude Code CLI terminal created', 'success'); } } else if (sessionSelection && sessionSelection.sessionId) { // Auto-launch Claude CLI if session was selected if (sessionSelection.sessionId !== 'new') { await this.launchClaudeCLI(terminalId, sessionSelection.sessionId, sessionSelection.source); } else { // Launch Claude CLI without session for new session await this.launchClaudeCLI(terminalId, null, 'new'); } if (!silent) { const sessionMsg = sessionSelection && sessionSelection.sessionId && sessionSelection.sessionId !== 'new' ? ` with ${sessionSelection.source === 'local' ? 'local' : ''} session ${sessionSelection.sessionId.substring(0, 12)}` : sessionSelection && sessionSelection.sessionId === 'new' ? ' (New Session)' : ''; showToast(`Terminal created${sessionMsg}`, 'success'); } } else { if (!silent) { showToast('Terminal created', 'success'); } } return terminalId; } catch (error) { console.error('[TerminalManager] Error creating terminal:', error); showToast(error.message, 'error'); return null; } } /** * Show directory picker modal */ async showDirectoryPicker() { return new Promise(async (resolve) => { // Fetch recent directories let recentDirs = ['/home/uroma/obsidian-vault', '/home/uroma']; try { const res = await fetch('/claude/api/files/recent-dirs'); const data = await res.json(); if (data.success) { recentDirs = data.directories; } } catch (error) { console.error('Error fetching recent directories:', error); } // Create modal const modal = document.createElement('div'); modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); setTimeout(() => modal.classList.add('visible'), 10); const input = modal.querySelector('.directory-input'); const terminalTypeSelect = modal.querySelector('.terminal-type-select'); const createBtn = modal.querySelector('.btn-create'); let selectedDir = recentDirs[0]; let selectedTerminalType = 'standard'; // Handle directory selection modal.querySelectorAll('.directory-item').forEach(item => { item.addEventListener('click', () => { modal.querySelectorAll('.directory-item').forEach(i => i.classList.remove('selected')); item.classList.add('selected'); selectedDir = item.dataset.dir; input.value = selectedDir; }); }); // Handle input change input.addEventListener('input', () => { selectedDir = input.value; }); // Handle terminal type selection terminalTypeSelect.addEventListener('change', () => { selectedTerminalType = terminalTypeSelect.value; }); // Handle enter key input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { resolve({ directory: selectedDir, terminalType: selectedTerminalType }); cleanup(); } }); // Handle create createBtn.addEventListener('click', () => { resolve({ directory: selectedDir, terminalType: selectedTerminalType }); cleanup(); }); // Handle cancel/close const cancel = () => { resolve(null); cleanup(); }; modal.querySelector('.btn-cancel').addEventListener('click', cancel); modal.querySelector('.modal-close').addEventListener('click', cancel); modal.addEventListener('click', (e) => { if (e.target === modal) cancel(); }); const cleanup = () => { modal.classList.remove('visible'); setTimeout(() => modal.remove(), 300); }; input.focus(); }); } /** * Show session picker modal */ async showSessionPicker() { return new Promise(async (resolve) => { // Fetch web sessions and local sessions in parallel let webSessions = []; let localSessions = []; try { const [webRes, localRes] = await Promise.all([ fetch('/claude/api/claude/sessions'), fetch('/claude/api/claude/local-sessions') ]); const webData = await webRes.json(); const localData = await localRes.json(); // Process web sessions if (webData.success) { const allWebSessions = [ ...(webData.active || []).map(s => ({...s, type: 'web', source: 'web'})), ...(webData.historical || []).map(s => ({...s, type: 'web', source: 'web'})) ]; // Sort by last activity and take last 10 allWebSessions.sort((a, b) => { const dateA = new Date(a.lastActivity || a.createdAt || a.created_at); const dateB = new Date(b.lastActivity || b.createdAt || b.created_at); return dateB - dateA; }); webSessions = allWebSessions.slice(0, 10); } // Process local sessions if (localData.success && localData.sessions) { localSessions = localData.sessions.slice(0, 10).map(s => ({ ...s, source: 'local' })); } } catch (error) { console.error('Error fetching sessions:', error); } // Create modal const modal = document.createElement('div'); modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); setTimeout(() => modal.classList.add('visible'), 10); const openBtn = modal.querySelector('.btn-open'); let selectedSession = null; let selectedSource = null; // Handle session selection modal.querySelectorAll('.session-list-item:not(.create-new)').forEach(item => { item.addEventListener('click', () => { modal.querySelectorAll('.session-list-item').forEach(i => i.classList.remove('selected')); item.classList.add('selected'); selectedSession = item.dataset.session; selectedSource = item.dataset.source; openBtn.disabled = false; }); }); // Handle "Create New" const createNewItem = modal.querySelector('.create-new'); createNewItem.addEventListener('click', () => { modal.querySelectorAll('.session-list-item').forEach(i => i.classList.remove('selected')); createNewItem.classList.add('selected'); selectedSession = 'new'; selectedSource = 'new'; openBtn.disabled = false; }); // Auto-select "Create New Session" createNewItem.classList.add('selected'); selectedSession = 'new'; selectedSource = 'new'; openBtn.disabled = false; // Handle open openBtn.addEventListener('click', () => { resolve({ sessionId: selectedSession, source: selectedSource }); cleanup(); }); // Handle cancel/close const cancel = () => { resolve(null); // Return null to skip session attachment cleanup(); }; modal.querySelector('.btn-cancel').addEventListener('click', cancel); modal.querySelector('.modal-close').addEventListener('click', cancel); modal.addEventListener('click', (e) => { if (e.target === modal) cancel(); }); const cleanup = () => { modal.classList.remove('visible'); setTimeout(() => modal.remove(), 300); }; // Focus on first item createNewItem.focus(); }); } /** * Launch Claude CLI in terminal with optional session */ async launchClaudeCLI(terminalId, sessionId, source = 'web') { const terminal = this.terminals.get(terminalId); if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) { console.error('[TerminalManager] Terminal not ready for CLI launch'); return; } // Construct command const command = sessionId ? `claude --session ${sessionId}\n` : 'claude\n'; // Send command to terminal terminal.ws.send(JSON.stringify({ type: 'input', data: command })); // Update mode to session await this.setMode(terminalId, 'session'); // Update UI to show attached session const sessionLabel = document.getElementById(`session-${terminalId}`); if (sessionLabel) { if (sessionId) { const sourceLabel = source === 'local' ? '๐Ÿ’ป ' : '๐Ÿ”— '; sessionLabel.textContent = `${sourceLabel}${sessionId.substring(0, 12)}`; sessionLabel.title = `Attached to ${source} session ${sessionId}`; } else { sessionLabel.textContent = '๐Ÿ†• New Session'; sessionLabel.title = 'New Claude CLI session'; } } // Store session ID and source in terminal data if (sessionId) { terminal.sessionId = sessionId; terminal.sessionSource = source; } } /** * Launch a command in the terminal * Waits for terminal to be ready before sending command */ async launchCommand(terminalId, command) { this.debugLog('CMD', `launchCommand called: terminalId=${terminalId}, command="${command.trim()}"`); // Wait for terminal to be ready (max 5 seconds) this.debugLog('CMD', `Waiting for terminal ${terminalId} to be ready...`); const ready = await this.waitForTerminalReady(terminalId, 5000); if (!ready) { this.debugLog('ERROR', `Terminal ${terminalId} NOT ready (timeout after 5s)`); showToast('Terminal not ready. Please try again.', 'error'); return; } this.debugLog('CMD', `Terminal ${terminalId} is ready!`); // Small delay to ensure WebSocket is stable after switching terminals this.debugLog('CMD', `Waiting 100ms for WebSocket to stabilize...`); await new Promise(resolve => setTimeout(resolve, 100)); const terminal = this.terminals.get(terminalId); if (!terminal) { this.debugLog('ERROR', `Terminal ${terminalId} not found in map`); return; } if (!terminal.ws) { this.debugLog('ERROR', `WebSocket not set for terminal ${terminalId}`); return; } // Check WebSocket state const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; this.debugLog('CMD', `WebSocket state: ${readyStates[terminal.ws.readyState]} (${terminal.ws.readyState})`); if (terminal.ws.readyState !== WebSocket.OPEN) { this.debugLog('ERROR', `Cannot send - WebSocket not open`, { wsState: terminal.ws.readyState, stateName: readyStates[terminal.ws.readyState] }); return; } // Check if WebSocket has any buffered amount (indicating pending sends) if (terminal.ws.bufferedAmount > 0) { this.debugLog('CMD', `WebSocket has ${terminal.ws.bufferedAmount} bytes buffered, waiting...`); await new Promise(resolve => setTimeout(resolve, 200)); } // Send command to terminal const message = JSON.stringify({ type: 'input', data: command }); this.debugLog('CMD', `Sending to WebSocket: ${message}`); try { terminal.ws.send(message); this.debugLog('CMD', `Command sent to terminal ${terminalId}: ${command.trim()}`); } catch (error) { this.debugLog('ERROR', `Failed to send command`, { error: error.message, name: error.name }); showToast(`Failed to send command: ${error.message}`, 'error'); } } /** * Wait for terminal to be ready */ async waitForTerminalReady(terminalId, timeout = 5000) { const startTime = Date.now(); return new Promise((resolve) => { const checkReady = () => { const terminal = this.terminals.get(terminalId); if (terminal && terminal.ready) { console.log(`[TerminalManager] Terminal ${terminalId} is ready`); resolve(true); return; } if (Date.now() - startTime > timeout) { console.error(`[TerminalManager] Terminal ${terminalId} ready timeout`); resolve(false); return; } // Check again in 100ms setTimeout(checkReady, 100); }; checkReady(); }); } /** * Extract project name from session metadata */ getProjectName(session) { return session.metadata?.project || session.metadata?.projectName || session.workingDir?.split('/').pop() || 'Session ' + session.id.substring(0, 8); } /** * Get relative time string for session */ getRelativeTime(session) { const date = new Date(session.lastActivity || session.createdAt || session.created_at); const now = new Date(); const diffMins = Math.floor((now - date) / 60000); const diffHours = Math.floor((now - date) / 3600000); const diffDays = Math.floor((now - date) / 86400000); if (diffMins < 1) return 'Just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } /** * Get status text for session */ getStatus(session) { if (session.status === 'running') return 'Active'; return 'Done'; } /** * Get status CSS class for session */ getStatusClass(session) { if (session.status === 'running') return 'active'; return 'done'; } /** * Create terminal UI elements */ async createTerminalUI(terminalId, workingDir, mode) { // Create tab const tab = document.createElement('div'); tab.className = 'terminal-tab'; tab.dataset.terminalId = terminalId; tab.innerHTML = ` ${terminalId.substring(0, 12)} ${this.getModeIcon(mode)} `; this.terminalTabsContainer.appendChild(tab); // Handle tab click tab.addEventListener('click', (e) => { if (!e.target.classList.contains('tab-close')) { this.switchToTerminal(terminalId); } }); // Handle close button tab.querySelector('.tab-close').addEventListener('click', (e) => { e.stopPropagation(); this.closeTerminal(terminalId); }); // Create terminal container const container = document.createElement('div'); container.className = 'terminal-container'; container.dataset.terminalId = terminalId; container.innerHTML = `
${terminalId} ${escapeHtml(workingDir)} ${this.getModeLabel(mode)}
`; this.terminalsContainer.appendChild(container); // Hide placeholder const placeholder = this.terminalsContainer.querySelector('.terminal-placeholder'); if (placeholder) { placeholder.style.display = 'none'; } // Handle toolbar buttons container.querySelector('.btn-terminal-attach').addEventListener('click', () => { this.attachToSession(terminalId); }); container.querySelector('.btn-terminal-mode').addEventListener('click', () => { this.cycleMode(terminalId); }); container.querySelector('.btn-terminal-clear').addEventListener('click', () => { this.clearScreen(terminalId); }); container.querySelector('.btn-terminal-close').addEventListener('click', () => { this.closeTerminal(terminalId); }); } /** * Connect terminal WebSocket * Waits for 'ready' message from backend before resolving */ async connectTerminal(terminalId) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/claude/api/terminals/${terminalId}/ws`; this.debugLog('WS', `Connecting to WebSocket for terminal ${terminalId}`, { url: wsUrl }); return new Promise((resolve, reject) => { try { const ws = new WebSocket(wsUrl); ws.onopen = () => { this.debugLog('WS', `WebSocket OPENED for terminal ${terminalId}`); // Store WebSocket in terminal entry // NOTE: This assumes initializeXTerm() has already been called // and this.terminals has the entry const terminal = this.terminals.get(terminalId); if (terminal) { terminal.ws = ws; terminal.ready = false; // Will be set to true when 'ready' message received this.debugLog('WS', `WebSocket stored in terminal map, waiting for 'ready' message`); } else { this.debugLog('ERROR', `CRITICAL: Terminal ${terminalId} not found in map!`, { terminalsInMap: Array.from(this.terminals.keys()) }); reject(new Error(`Terminal ${terminalId} not initialized`)); ws.close(); return; } }; ws.onmessage = (event) => { try { const message = JSON.parse(event.data); this.debugLog('WS', `Message received: type="${message.type}"`, message); this.handleTerminalMessage(terminalId, message); // If this is the ready message, resolve the promise if (message.type === 'ready') { this.debugLog('READY', `โœ… Ready message received for ${terminalId}, PTY initialized`, message); const terminal = this.terminals.get(terminalId); if (terminal) { terminal.ready = true; } resolve(); } } catch (error) { this.debugLog('ERROR', `Failed to parse WebSocket message`, { error: error.message, data: event.data }); } }; ws.onerror = (error) => { this.debugLog('ERROR', `WebSocket error for terminal ${terminalId}`, error); reject(error); }; ws.onclose = (event) => { this.debugLog('WS', `WebSocket CLOSED for terminal ${terminalId}`, { code: event.code, reason: event.reason, wasClean: event.wasClean }); }; } catch (error) { this.debugLog('ERROR', `Exception connecting WebSocket`, error); reject(error); } }); } /** * Handle terminal message from WebSocket */ handleTerminalMessage(terminalId, message) { this.debugLog('WS', `Handling message: type="${message.type}"`, message); const terminal = this.terminals.get(terminalId); if (!terminal) { this.debugLog('ERROR', `Cannot handle message - terminal ${terminalId} not found in map`, { terminalsInMap: Array.from(this.terminals.keys()) }); return; } switch (message.type) { case 'ready': this.debugLog('READY', `PTY initialized for ${terminalId}`); break; case 'data': // Write to xterm.js if (terminal.terminal) { this.debugLog('PTY', `Writing data to xterm.js (${message.data.length} chars)`); terminal.terminal.write(message.data); } else { this.debugLog('ERROR', `No xterm.js instance for terminal ${terminalId}`); } break; case 'exit': this.debugLog('PTY', `Terminal exited: ${message.exitCode || signal}`); showToast(`Terminal exited: ${message.exitCode || 'terminated'}`, 'info'); break; case 'modeChanged': this.debugLog('INIT', `Mode changed to ${message.mode}`); this.updateModeDisplay(terminalId, message.mode); break; default: this.debugLog('WS', `Unknown message type: ${message.type}`); } } /** * Initialize xterm.js instance for terminal */ async initializeXTerm(terminalId) { this.debugLog('INIT', `initializeXTerm called for ${terminalId}`); const container = document.getElementById(`xterm-${terminalId}`); if (!container) { this.debugLog('ERROR', `Terminal container not found: ${terminalId}`); throw new Error(`Terminal container not found: ${terminalId}`); } // Create Terminal instance const Terminal = window.Terminal; const FitAddon = window.FitAddon; const WebLinksAddon = window.WebLinksAddon; const terminal = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: 'Menlo, Monaco, "Courier New", monospace', theme: { background: '#1a1a1a', foreground: '#e0e0e0', cursor: '#4a9eff', selection: 'rgba(74, 158, 255, 0.3)', black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510', blue: '#2472c8', magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5', brightBlack: '#666666', brightRed: '#f14c4c', brightGreen: '#23d18b', brightYellow: '#f5f543', brightBlue: '#3b8eea', brightMagenta: '#d670d6', brightCyan: '#29b8db', brightWhite: '#ffffff' } }); // Load addons const fitAddon = new FitAddon.FitAddon(); const webLinksAddon = new WebLinksAddon.WebLinksAddon(); terminal.loadAddon(fitAddon); terminal.loadAddon(webLinksAddon); // Open in container terminal.open(container); // Fit to container fitAddon.fit(); // Focus terminal on click container.addEventListener('click', () => { terminal.focus(); }); // Auto-focus terminal when created setTimeout(() => terminal.focus(), 100); // Handle user input terminal.onData((data) => { this.sendTerminalInput(terminalId, data); }); // Handle resize terminal.onResize(({ cols, rows }) => { this.sendTerminalResize(terminalId, cols, rows); }); // Store terminal instance in map // This MUST happen before connectTerminal() is called this.terminals.set(terminalId, { terminal, fitAddon, ws: null, // Will be set by connectTerminal() container, mode: 'mixed', ready: false // Will be set to true when 'ready' message received }); this.debugLog('INIT', `xterm.js instance stored in map for ${terminalId}, terminals now has`, { terminalIds: Array.from(this.terminals.keys()) }); return terminal; } /** * Send input to terminal */ sendTerminalInput(terminalId, data) { const terminal = this.terminals.get(terminalId); if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) { return; } terminal.ws.send(JSON.stringify({ type: 'input', data })); } /** * Send terminal resize */ sendTerminalResize(terminalId, cols, rows) { const terminal = this.terminals.get(terminalId); if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) { return; } terminal.ws.send(JSON.stringify({ type: 'resize', cols, rows })); } /** * Switch to specific terminal */ async switchToTerminal(terminalId) { this.debugLog('INIT', `switchToTerminal called for ${terminalId}, checking if terminal in map...`); // Hide all containers and remove active class document.querySelectorAll('.terminal-container').forEach(c => { c.classList.remove('active'); c.style.display = 'none'; }); // Deactivate all tabs document.querySelectorAll('.terminal-tab').forEach(t => { t.classList.remove('active'); }); // Show selected container const container = document.querySelector(`.terminal-container[data-terminal-id="${terminalId}"]`); if (container) { container.classList.add('active'); container.style.display = 'flex'; // Initialize xterm.js if not already done if (!this.terminals.has(terminalId)) { this.debugLog('INIT', `Terminal ${terminalId} NOT in map, calling initializeXTerm...`); await this.initializeXTerm(terminalId); } else { this.debugLog('INIT', `Terminal ${terminalId} already in map, skipping initialization`); } // Fit terminal const terminal = this.terminals.get(terminalId); if (terminal && terminal.fitAddon) { this.debugLog('INIT', `Calling fitAddon.fit() for ${terminalId} in 100ms...`); setTimeout(() => { this.debugLog('INIT', `Executing fitAddon.fit() for ${terminalId}`); try { terminal.fitAddon.fit(); this.debugLog('INIT', `fitAddon.fit() completed for ${terminalId}`); } catch (error) { this.debugLog('ERROR', `fitAddon.fit() failed for ${terminalId}`, { error: error.message }); } }, 100); } else { this.debugLog('INIT', `No fitAddon for terminal ${terminalId} (terminal=${!!terminal}, fitAddon=${!!terminal?.fitAddon})`); } } // Activate tab const tab = document.querySelector(`.terminal-tab[data-terminal-id="${terminalId}"]`); if (tab) { tab.classList.add('active'); } this.debugLog('INIT', `switchToTerminal completed for ${terminalId}`); this.activeTerminalId = terminalId; } /** * Close terminal */ async closeTerminal(terminalId) { try { // Close via API await fetch(`/claude/api/terminals/${terminalId}`, { method: 'DELETE' }); // Remove tab const tab = document.querySelector(`.terminal-tab[data-terminal-id="${terminalId}"]`); if (tab) tab.remove(); // Remove container const container = document.querySelector(`.terminal-container[data-terminal-id="${terminalId}"]`); if (container) container.remove(); // Remove from map this.terminals.delete(terminalId); // If this was active terminal, switch to another if (this.activeTerminalId === terminalId) { const remainingTabs = document.querySelectorAll('.terminal-tab'); if (remainingTabs.length > 0) { this.switchToTerminal(remainingTabs[0].dataset.terminalId); } else { this.activeTerminalId = null; // Show placeholder when no terminals remain const placeholder = this.terminalsContainer.querySelector('.terminal-placeholder'); if (placeholder) { placeholder.style.display = 'block'; } } } showToast('Terminal closed', 'info'); } catch (error) { console.error('[TerminalManager] Error closing terminal:', error); showToast('Failed to close terminal', 'error'); } } /** * Set terminal mode */ async setMode(terminalId, mode) { try { const res = await fetch(`/claude/api/terminals/${terminalId}/mode`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode }) }); const data = await res.json(); if (data.success) { const terminal = this.terminals.get(terminalId); if (terminal) { terminal.mode = mode; } this.updateModeDisplay(terminalId, mode); showToast(`Mode changed to ${this.getModeLabel(mode)}`, 'success'); } } catch (error) { console.error('[TerminalManager] Error setting mode:', error); showToast('Failed to change mode', 'error'); } } /** * Cycle through modes */ cycleMode(terminalId) { const terminal = this.terminals.get(terminalId); if (!terminal) return; const modes = ['shell', 'session', 'mixed']; const currentIndex = modes.indexOf(terminal.mode); const nextMode = modes[(currentIndex + 1) % modes.length]; this.setMode(terminalId, nextMode); } /** * Update mode display */ updateModeDisplay(terminalId, mode) { // Update toolbar const modeLabel = document.querySelector(`.terminal-container[data-terminal-id="${terminalId}"] .terminal-mode`); if (modeLabel) { modeLabel.textContent = this.getModeLabel(mode); modeLabel.dataset.mode = mode; } // Update tab const tabMode = document.querySelector(`.terminal-tab[data-terminal-id="${terminalId}"] .tab-mode`); if (tabMode) { tabMode.textContent = this.getModeIcon(mode); } } /** * Clear terminal screen */ clearScreen(terminalId) { const terminal = this.terminals.get(terminalId); if (terminal && terminal.terminal) { terminal.terminal.clear(); } } /** * Get mode icon */ getModeIcon(mode) { const icons = { session: '๐Ÿ”ต', shell: '๐ŸŸข', mixed: '๐ŸŸก' }; return icons[mode] || 'โšช'; } /** * Get mode label */ getModeLabel(mode) { const labels = { session: 'Session', shell: 'Shell', mixed: 'Mixed' }; return labels[mode] || 'Unknown'; } /** * Attach terminal to current Claude Code session */ async attachToSession(terminalId) { // Check if there's an active session const currentSessionId = window.chatSessionId; if (!currentSessionId) { showToast('No active Claude Code session. Start a chat first!', 'warning'); return; } try { const res = await fetch(`/claude/api/terminals/${terminalId}/attach`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId: currentSessionId }) }); const data = await res.json(); if (!data.success) { throw new Error(data.error || 'Failed to attach'); } // Update terminal mode to session await this.setMode(terminalId, 'session'); // Auto-launch Claude CLI with the session await this.launchClaudeCLI(terminalId, currentSessionId); showToast(`Attached to session ${currentSessionId.substring(0, 12)}`, 'success'); } catch (error) { console.error('[TerminalManager] Error attaching to session:', error); showToast(error.message || 'Failed to attach to session', 'error'); } } /** * Setup keyboard shortcuts */ setupKeyboardShortcuts() { document.addEventListener('keydown', (e) => { // Ctrl+Shift+T - New terminal if (e.ctrlKey && e.shiftKey && e.key === 'T') { e.preventDefault(); this.createTerminal(); } // Ctrl+Shift+W - Close terminal if (e.ctrlKey && e.shiftKey && e.key === 'W') { e.preventDefault(); if (this.activeTerminalId) { this.closeTerminal(this.activeTerminalId); } } // Ctrl+Tab - Next terminal if (e.ctrlKey && e.key === 'Tab') { e.preventDefault(); const tabs = Array.from(document.querySelectorAll('.terminal-tab')); if (tabs.length > 0) { const currentIndex = tabs.findIndex(t => t.dataset.terminalId === this.activeTerminalId); const nextIndex = (currentIndex + 1) % tabs.length; this.switchToTerminal(tabs[nextIndex].dataset.terminalId); } } // Ctrl+Shift+M - Toggle mode if (e.ctrlKey && e.shiftKey && e.key === 'M') { e.preventDefault(); if (this.activeTerminalId) { this.cycleMode(this.activeTerminalId); } } }); } /** * Save terminal state for restoration */ async saveState() { const terminals = []; for (const [id, terminal] of this.terminals.entries()) { const container = document.querySelector(`.terminal-container[data-terminal-id="${id}"]`); if (container) { const path = container.querySelector('.terminal-path')?.textContent; const mode = container.querySelector('.terminal-mode')?.dataset.mode; terminals.push({ id, workingDir: path, mode }); } } const state = { timestamp: new Date().toISOString(), terminals }; // Save to server try { await fetch('/claude/api/claude/terminal-state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(state) }); } catch (error) { console.error('[TerminalManager] Error saving state:', error); } } } // Helper function function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }