/** * Terminal Manager - Frontend for xterm.js terminals */ class TerminalManager { constructor() { this.terminals = new Map(); // terminalId -> { terminal, ws, fitAddon, container, mode } this.activeTerminalId = null; this.xtermLoaded = false; this.terminalsContainer = null; this.terminalTabsContainer = null; // 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); } /** * 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 } = options; // Show directory picker if no working directory provided const selectedDir = workingDir || await this.showDirectoryPicker(); if (!selectedDir) { return null; } // If no session provided and not skipping picker, show session picker let sessionSelection = null; if (!sessionId && !skipSessionPicker) { 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' } } try { // Create terminal via API 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) { throw new Error(data.error || 'Failed to create terminal'); } const terminalId = data.terminalId; // Create terminal UI await this.createTerminalUI(terminalId, selectedDir, mode); // Connect WebSocket await this.connectTerminal(terminalId); // Switch to new terminal this.switchToTerminal(terminalId); // Auto-launch Claude CLI if session was selected if (sessionSelection && sessionSelection.sessionId) { 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'); } 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 createBtn = modal.querySelector('.btn-create'); let selectedDir = recentDirs[0]; // 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 enter key input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { resolve(selectedDir); cleanup(); } }); // Handle create createBtn.addEventListener('click', () => { resolve(selectedDir); 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; } } /** * 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 */ async connectTerminal(terminalId) { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${protocol}//${window.location.host}/claude/api/terminals/${terminalId}/ws`; try { const ws = new WebSocket(wsUrl); ws.onopen = () => { console.log(`[TerminalManager] Connected to terminal ${terminalId}`); }; ws.onmessage = (event) => { const message = JSON.parse(event.data); this.handleTerminalMessage(terminalId, message); }; ws.onerror = (error) => { console.error(`[TerminalManager] WebSocket error for terminal ${terminalId}:`, error); }; ws.onclose = () => { console.log(`[TerminalManager] WebSocket closed for terminal ${terminalId}`); }; // Store WebSocket const terminal = this.terminals.get(terminalId); if (terminal) { terminal.ws = ws; } } catch (error) { console.error('[TerminalManager] Error connecting WebSocket:', error); throw error; } } /** * Handle terminal message from WebSocket */ handleTerminalMessage(terminalId, message) { const terminal = this.terminals.get(terminalId); if (!terminal) return; switch (message.type) { case 'ready': console.log(`[TerminalManager] Terminal ${terminalId} ready`); break; case 'data': // Write to xterm.js if (terminal.terminal) { terminal.terminal.write(message.data); } break; case 'exit': console.log(`[TerminalManager] Terminal ${terminalId} exited: ${message.exitCode}`); showToast(`Terminal exited: ${message.exitCode || 'terminated'}`, 'info'); break; case 'modeChanged': // Update mode display this.updateModeDisplay(terminalId, message.mode); break; } } /** * Initialize xterm.js instance for terminal */ async initializeXTerm(terminalId) { const container = document.getElementById(`xterm-${terminalId}`); if (!container) { 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 this.terminals.set(terminalId, { terminal, fitAddon, ws: null, container, mode: 'mixed' }); 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) { // 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)) { await this.initializeXTerm(terminalId); } // Fit terminal const terminal = this.terminals.get(terminalId); if (terminal && terminal.fitAddon) { setTimeout(() => terminal.fitAddon.fit(), 100); } } // Activate tab const tab = document.querySelector(`.terminal-tab[data-terminal-id="${terminalId}"]`); if (tab) { tab.classList.add('active'); } 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; }