// static/js/terminal.js v10 - PTY-backed terminal with xterm.js var term = null, ws = null, fitAddon = null, ctrlOn = false, termInitialized = false; var darkTermTheme = { background: '#0a0a0a', foreground: '#f8fafc', cursor: '#ffc107', cursorAccent: '#0a0a0a', selectionBackground: 'rgba(255, 193, 7, 0.3)', selectionForeground: '#ffffff', black: '#1e293b', red: '#ef4444', green: '#22c55e', yellow: '#ffc107', blue: '#3b82f6', magenta: '#a855f7', cyan: '#06b6d4', white: '#f8fafc', brightBlack: '#64748b', brightRed: '#f87171', brightGreen: '#4ade80', brightYellow: '#facc15', brightBlue: '#60a5fa', brightMagenta: '#c084fc', brightCyan: '#22d3ee', brightWhite: '#ffffff', }; var lightTermTheme = { background: '#ffffff', foreground: '#0f172a', cursor: '#d97706', cursorAccent: '#ffffff', selectionBackground: 'rgba(217, 119, 6, 0.3)', selectionForeground: '#ffffff', black: '#0f172a', red: '#dc2626', green: '#16a34a', yellow: '#d97706', blue: '#2563eb', magenta: '#9333ea', cyan: '#0891b2', white: '#f8fafc', brightBlack: '#64748b', brightRed: '#ef4444', brightGreen: '#22c55e', brightYellow: '#f59e0b', brightBlue: '#3b82f6', brightMagenta: '#a855f7', brightCyan: '#06b6d4', brightWhite: '#ffffff', }; function fitTerminal() { if (!term || !fitAddon) return; var c = document.getElementById('xterm-container'); if (!c || c.offsetWidth === 0 || c.offsetHeight === 0) return; try { fitAddon.fit(); } catch(e) {} } function updateConnStatus(s) { var dot = document.getElementById('connection-dot'), txt = document.getElementById('connection-text'); if (!dot || !txt) return; var c = { connected: 'var(--success)', disconnected: 'var(--error)', connecting: 'var(--accent)' }; var l = { connected: 'Online', disconnected: 'Offline', connecting: 'Connecting...' }; dot.parentElement.style.background = s === 'connected' ? 'var(--success-dim)' : s === 'connecting' ? 'var(--accent-dim)' : 'var(--error-dim)'; dot.parentElement.style.color = c[s] || c.disconnected; txt.textContent = l[s] || l.disconnected; } function cleanupTerm() { if (ws) { try { ws.close(); } catch(e) {} ws = null; } if (term) { try { term.dispose(); } catch(e) {} term = null; } fitAddon = null; termInitialized = false; } window.initTerminal = function() { if (!currentToken) return; var container = document.getElementById('xterm-container'); if (!container) return; // Already connected and working — just refocus if (term && ws && ws.readyState === WebSocket.OPEN && termInitialized) { setTimeout(function() { fitTerminal(); term.focus(); }, 50); return; } // WS dead but terminal exists — full cleanup and reconnect cleanupTerm(); if (typeof Terminal === 'undefined') { container.innerHTML = '
xterm.js failed to load. Check your connection.
'; return; } var isLight = document.documentElement.classList.contains('light'); term = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', 'Consolas', monospace", theme: isLight ? lightTermTheme : darkTermTheme, scrollback: 10000, allowProposedApi: true, drawBoldTextInBrightColors: true, minimumContrastRatio: 1, }); fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); try { var wla = new WebLinksAddon.WebLinksAddon(); term.loadAddon(wla); } catch(e) {} term.open(container); termInitialized = true; // Force fit + focus after render setTimeout(function() { fitTerminal(); term.focus(); }, 100); // Wire up input → WebSocket BEFORE connecting // This ensures keystrokes are captured even during connection term.onData(function(data) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({type: 'input', data: data})); } }); term.onResize(function(size) { if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({type: 'resize', cols: size.cols, rows: size.rows})); } }); // Connect WebSocket var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; updateConnStatus('connecting'); ws = new WebSocket(proto + '//' + location.host + '/ws/' + currentToken); ws.onopen = function() { updateConnStatus('connected'); try { ws.send(JSON.stringify({type: 'resize', cols: term.cols, rows: term.rows})); } catch(e) {} // CRITICAL: Focus terminal after WS connects setTimeout(function() { if (term) { term.focus(); } }, 50); }; ws.onmessage = function(e) { var msg; try { msg = JSON.parse(e.data); } catch(err) { return; } if (msg.type === 'output') { term.write(msg.data); } else if (msg.type === 'exit') { term.write('\r\n\x1b[33m[Process exited]\x1b[0m\r\n'); } else if (msg.type === 'error') { term.write('\r\n\x1b[31m' + (msg.msg || 'Error') + '\x1b[0m\r\n'); } }; ws.onclose = function() { updateConnStatus('disconnected'); if (term) term.write('\r\n\x1b[31m[Connection lost]\x1b[0m\r\n'); }; ws.onerror = function() { updateConnStatus('disconnected'); if (term) term.write('\r\n\x1b[31m[WebSocket error]\x1b[0m\r\n'); }; // Resize on window change var resizeTimeout; window.addEventListener('resize', function() { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(fitTerminal, 150); }); // Click on terminal to focus container.addEventListener('click', function() { if (term) term.focus(); }); // Refocus when terminal view becomes active var obs = new MutationObserver(function() { var tv = document.getElementById('terminal-view'); if (tv && tv.classList.contains('active')) { setTimeout(function() { fitTerminal(); if (term) term.focus(); }, 150); } }); var tv = document.getElementById('terminal-view'); if (tv) obs.observe(tv, { attributes: true, attributeFilter: ['class'] }); }; window.sendSpecial = function(key) { if (!ws || ws.readyState !== WebSocket.OPEN || !term) return; var seq = ''; switch(key) { case 'ctrl': ctrlOn = !ctrlOn; document.getElementById('ctrl-btn').classList.toggle('ctrl-active', ctrlOn); return; case 'esc': seq = '\x1b'; break; case 'tab': seq = '\t'; break; case 'up': seq = '\x1b[A'; break; case 'down': seq = '\x1b[B'; break; case 'left': seq = '\x1b[D'; break; case 'right': seq = '\x1b[C'; break; case 'enter': seq = '\r'; break; } if (seq) ws.send(JSON.stringify({type: 'input', data: seq})); if (term) term.focus(); }; window.termClear = function() { if (term) term.clear(); }; window.restartTerminal = function() { cleanupTerm(); initTerminal(); }; window.updateTermTheme = function() { if (!term) return; term.options.theme = document.documentElement.classList.contains('light') ? lightTermTheme : darkTermTheme; }; // CTRL key combos on mobile document.addEventListener('keydown', function(e) { if (ctrlOn && e.key.length === 1 && !e.ctrlKey && !e.metaKey) { var code = e.key.toUpperCase().charCodeAt(0) - 64; if (ws && ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({type: 'input', data: String.fromCharCode(code)})); } e.preventDefault(); ctrlOn = false; var btn = document.getElementById('ctrl-btn'); if (btn) btn.classList.remove('ctrl-active'); } });