// 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');
}
});