Files
muxplex/muxplex/frontend/terminal.js
T
Ken 469acd7e3f fix: align terminal.js token response field with API contract and remove stale ttyd refs
- terminal.js:55: read data.port instead of data.muxtermPort to match
  the /api/terminal-token response shape ({token, port})
- test_terminal.mjs:37: update mock to return {port: 9999} matching API
- app.js: replace two stale ttyd references in comments with muxterm
2026-05-28 07:38:04 +00:00

562 lines
20 KiB
JavaScript

// terminal.js — muxterm rewrite
// Single WebSocket to muxterm, JSON control protocol, no terminal cache
// ——— Module-level state ————————————————————————————————————————————————
let _term = null;
let _fitAddon = null;
let _ws = null;
let _reconnectTimer = null;
let _overlayTimer = null;
let _currentSession = null;
let _vpHandler = null;
let _reconnectAttempts = 0;
let _searchAddon = null;
let _resizeObserver = null;
let _ctrlActive = false;
let _altActive = false;
let _muxtermPort = null;
let _muxtermToken = null;
// WASM initialization for ghostty-web
let _ghosttyReady = (typeof GhosttyWeb !== 'undefined' && GhosttyWeb.init)
? GhosttyWeb.init({ wasmUrl: '/vendor/ghostty-vt.wasm' })
: Promise.resolve();
// ——— Encoding helpers ——————————————————————————————————————————————————
const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
const _decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
// ——— Clipboard helpers ————————————————————————————————————————————————
function _copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch(function() {});
} else {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch(e) {}
document.body.removeChild(ta);
}
}
// ——— WebSocket connection ————————————————————————————————————————————
/**
* Fetch a terminal token from the API, then open a WebSocket to muxterm.
*/
function connectWebSocket() {
var reconnectOverlay = document.getElementById('reconnect-overlay');
fetch('/api/terminal-token', { method: 'GET' })
.then(function(res) { return res.json(); })
.then(function(data) {
_muxtermPort = data.port;
_muxtermToken = data.token;
_openMuxtermSocket();
})
.catch(function(err) {
console.warn('muxplex: failed to fetch terminal token:', err);
// Schedule retry
_reconnectAttempts++;
var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000);
delay += Math.random() * 500;
_reconnectTimer = setTimeout(connectWebSocket, delay);
});
}
/**
* Open a WebSocket directly to muxterm on ws://host:muxtermPort/ws?token=...
* Sets binaryType='arraybuffer'. Handles open, message, close events.
*/
function _openMuxtermSocket() {
var reconnectOverlay = document.getElementById('reconnect-overlay');
var url = 'ws://' + location.hostname + ':' + _muxtermPort + '/ws?token=' + encodeURIComponent(_muxtermToken);
var ws = new WebSocket(url);
_ws = ws;
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', function() {
if (ws !== _ws) return; // stale guard
// Reset reconnect on successful open
_reconnectAttempts = 0;
if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; }
if (reconnectOverlay) reconnectOverlay.classList.add('hidden');
// Send attach message if we have a current session
if (_currentSession) {
ws.send(JSON.stringify({ attach: _currentSession }));
}
if (_term) _term.focus();
});
ws.addEventListener('message', function(e) {
if (ws !== _ws) return; // stale guard
if (!_term) return;
if (e.data instanceof ArrayBuffer) {
// Binary frame: terminal output — write directly to terminal
var payload = new Uint8Array(e.data);
_term.write(_decoder ? _decoder.decode(payload) : payload);
} else if (typeof e.data === 'string') {
// Text frame: JSON control message
try {
var msg = JSON.parse(e.data);
if (msg.attached) {
// Session attached confirmation
} else if (msg.error) {
console.warn('muxterm error:', msg.error);
} else if (msg.exited) {
// Session exited
}
} catch (ex) {
// Not valid JSON — ignore
}
}
});
ws.addEventListener('close', function() {
if (ws !== _ws) return; // stale guard
if (!_currentSession) return; // intentional close
// Grace period before showing overlay
if (_overlayTimer) clearTimeout(_overlayTimer);
_overlayTimer = setTimeout(function() {
if (reconnectOverlay && _currentSession) reconnectOverlay.classList.remove('hidden');
_overlayTimer = null;
}, 1500);
_reconnectAttempts++;
// Exponential backoff: 1s, 2s, 4s, 8s, cap at 15s + jitter
var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000);
delay += Math.random() * 500;
_reconnectTimer = setTimeout(connectWebSocket, delay);
});
ws.addEventListener('error', function() {
if (ws !== _ws) return;
console.warn('muxplex: WebSocket error');
});
}
// ——— Visual viewport (mobile keyboard) ————————————————————————————————
function initVisualViewport() {
if (!window.visualViewport) return;
if (_vpHandler) window.visualViewport.removeEventListener('resize', _vpHandler);
_vpHandler = function() {
if (!_term || !_fitAddon) return;
var container = document.getElementById('terminal-container');
if (!container) return;
var headerHeight = 44;
var toolbar = document.getElementById('mobile-toolbar');
var toolbarHeight = (toolbar && !toolbar.classList.contains('hidden')) ? toolbar.offsetHeight : 0;
var vvh = window.visualViewport.height;
var termHeight = Math.max(100, vvh - headerHeight - toolbarHeight);
container.style.height = termHeight + 'px';
window.scrollTo(0, 0);
try { _fitAddon.fit(); } catch (_) {}
};
window.visualViewport.addEventListener('resize', _vpHandler);
}
// ——— Terminal creation ————————————————————————————————————————————————
/**
* Create (or recreate) the ghostty-web Terminal with addons.
* @param {number} [fontSize=14]
*/
function createTerminal(fontSize) {
if (_term) {
_term.dispose();
_term = null;
_fitAddon = null;
}
var storedFontSize = (typeof fontSize === 'number' && fontSize > 0) ? fontSize : 14;
var mobile = window.innerWidth < 600;
var effectiveFontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize;
var TerminalClass = (typeof GhosttyWeb !== 'undefined' && GhosttyWeb.Terminal) || window.Terminal;
_term = new TerminalClass({
cursorBlink: true,
fontSize: effectiveFontSize,
fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace",
theme: { background: '#000000', foreground: '#c9d1d9', cursor: '#58a6ff' },
scrollback: mobile ? 500 : 5000,
allowProposedApi: true,
linkHandler: {
activate: function(event, uri) { window.open(uri, '_blank'); },
},
});
// FitAddon
var FitAddonClass = (typeof GhosttyWeb !== 'undefined' && GhosttyWeb.FitAddon) || (window.FitAddon && window.FitAddon.FitAddon);
_fitAddon = new FitAddonClass();
_term.loadAddon(_fitAddon);
// WebLinksAddon
try {
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
if (WebLinksAddon) {
_term.loadAddon(new WebLinksAddon(function(event, uri) {
window.open(uri, '_blank');
}));
}
} catch (e) {
console.warn('WebLinksAddon not compatible:', e);
}
// SearchAddon
try {
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
if (SearchAddon) {
_searchAddon = new SearchAddon();
_term.loadAddon(_searchAddon);
}
} catch (e) {
console.warn('SearchAddon not compatible:', e);
_searchAddon = null;
}
// ImageAddon
try {
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
if (ImageAddon) {
_term.loadAddon(new ImageAddon());
}
} catch (e) {
console.warn('ImageAddon not compatible:', e);
}
}
// ——— Search helpers ——————————————————————————————————————————————————
function _openSearch() {
var bar = document.getElementById('terminal-search-bar');
var input = document.getElementById('terminal-search-input');
if (bar) {
bar.classList.remove('hidden');
if (input) { input.focus(); input.select(); }
}
}
function _closeSearch() {
var bar = document.getElementById('terminal-search-bar');
if (bar) bar.classList.add('hidden');
if (_searchAddon) _searchAddon.clearDecorations();
if (_term) _term.focus();
}
function _searchNext() {
var input = document.getElementById('terminal-search-input');
if (input && input.value && _searchAddon) _searchAddon.findNext(input.value);
}
function _searchPrev() {
var input = document.getElementById('terminal-search-input');
if (input && input.value && _searchAddon) _searchAddon.findPrevious(input.value);
}
// ——— Open terminal ——————————————————————————————————————————————————
/**
* Open a terminal session inside #terminal-container.
* Creates terminal if needed, then connects or sends attach.
* @param {string} sessionName
* @param {string} [remoteId] (ignored in muxterm — kept for API compat)
* @param {number} [fontSize=14]
*/
function openTerminal(sessionName, remoteId, fontSize) {
_currentSession = null;
_reconnectAttempts = 0;
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
if (_ws) { _ws.close(); _ws = null; }
_currentSession = sessionName;
var container = document.getElementById('terminal-container');
if (!container) {
console.warn('[openTerminal] #terminal-container not found');
return;
}
// Ensure ghostty-web WASM is ready before creating terminal
_ghosttyReady.then(function() {
// Create terminal if not already present
if (!_term) {
createTerminal(fontSize);
_term.open(container);
// ResizeObserver for auto-refit
if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
if (typeof ResizeObserver !== 'undefined') {
var _roTimer = null;
_resizeObserver = new ResizeObserver(function() {
clearTimeout(_roTimer);
_roTimer = setTimeout(function() {
if (_fitAddon) try { _fitAddon.fit(); } catch (_) {}
}, 50);
});
_resizeObserver.observe(container);
}
// onData → send raw binary
_term.onData(function(data) {
if (_ws && _ws.readyState === WebSocket.OPEN) {
var outData = data;
// Mobile toolbar modifier keys (one-shot)
if (_ctrlActive && data.length === 1) {
var code = data.toUpperCase().charCodeAt(0);
if (code >= 65 && code <= 90) outData = String.fromCharCode(code - 64);
_ctrlActive = false;
var cb = document.querySelector('[data-modifier="ctrl"]');
if (cb) cb.classList.remove('mobile-toolbar__key--active');
} else if (_altActive && data.length === 1) {
outData = '\x1b' + data;
_altActive = false;
var ab = document.querySelector('[data-modifier="alt"]');
if (ab) ab.classList.remove('mobile-toolbar__key--active');
}
// Send raw binary — no prefix byte
var bytes = _encoder ? _encoder.encode(outData) : new Uint8Array(Array.from(outData).map(function(c) { return c.charCodeAt(0); }));
_ws.send(bytes);
}
});
// onResize → send JSON text frame
_term.onResize(function(size) {
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(JSON.stringify({ resize: { cols: size.cols, rows: size.rows } }));
}
});
// Clipboard: Ctrl+Shift+C to copy, Ctrl+F to search
_term.attachCustomKeyEventHandler(function(e) {
if (e.type !== 'keydown') return true;
if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) {
var sel = _term.getSelection();
if (sel) _copyToClipboard(sel);
return false;
}
if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) {
_openSearch();
return false;
}
return true;
});
// Auto-copy selection
_term.onSelectionChange(function() {
var sel = _term.getSelection();
if (sel) _copyToClipboard(sel);
});
// OSC 52 clipboard bridge (tmux → browser)
_term.parser.registerOscHandler(52, function(data) {
var parts = data.split(';');
if (parts.length >= 2) {
try {
var text = atob(parts[1]);
_copyToClipboard(text);
} catch (e) {}
}
return true;
});
// Fit after layout
if (_fitAddon) {
var fitAddonRef = _fitAddon;
var raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : function(fn) { fn(); };
raf(function() {
try { fitAddonRef.fit(); } catch (_) {}
setTimeout(function() {
if (_fitAddon) try { _fitAddon.fit(); } catch (_) {}
}, 500);
});
}
// Search bar wiring
var searchInput = document.getElementById('terminal-search-input');
var searchClose = document.getElementById('terminal-search-close');
var searchNextBtn = document.getElementById('terminal-search-next');
var searchPrevBtn = document.getElementById('terminal-search-prev');
if (searchInput) {
var newInput = searchInput.cloneNode(true);
searchInput.parentNode.replaceChild(newInput, searchInput);
searchInput = newInput;
searchInput.addEventListener('input', function() {
if (_searchAddon && searchInput.value) _searchAddon.findNext(searchInput.value);
else if (_searchAddon) _searchAddon.clearDecorations();
});
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) _searchPrev(); else _searchNext(); }
if (e.key === 'Escape') { e.preventDefault(); _closeSearch(); }
});
}
if (searchClose) { var nc = searchClose.cloneNode(true); searchClose.parentNode.replaceChild(nc, searchClose); nc.addEventListener('click', _closeSearch); }
if (searchNextBtn) { var nn = searchNextBtn.cloneNode(true); searchNextBtn.parentNode.replaceChild(nn, searchNextBtn); nn.addEventListener('click', _searchNext); }
if (searchPrevBtn) { var np = searchPrevBtn.cloneNode(true); searchPrevBtn.parentNode.replaceChild(np, searchPrevBtn); np.addEventListener('click', _searchPrev); }
// Context menu suppression
container.addEventListener('contextmenu', function(e) {
if (e.shiftKey || e.ctrlKey || e.metaKey) return;
e.preventDefault();
});
initVisualViewport();
_initAndroidIMEFix(container);
_initMobileToolbar();
}
// If WS is already open, send attach; otherwise connect
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(JSON.stringify({ attach: sessionName }));
} else {
connectWebSocket();
}
}); // close _ghosttyReady.then(function() { ... })
}
// ——— Close terminal ————————————————————————————————————————————————
/**
* Close the terminal session: send detach, close WS, dispose terminal.
*/
function closeTerminal() {
// Send detach message
if (_ws && _ws.readyState === WebSocket.OPEN) {
_ws.send(JSON.stringify({ detach: true }));
}
if (_vpHandler) {
if (window.visualViewport) window.visualViewport.removeEventListener('resize', _vpHandler);
_vpHandler = null;
}
if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; }
_currentSession = null; // set before close so close handler won't reconnect
if (_ws) { _ws.close(); _ws = null; }
if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
_ctrlActive = false;
_altActive = false;
var mobileToolbar = document.getElementById('mobile-toolbar');
if (mobileToolbar) mobileToolbar.classList.add('hidden');
if (_term) {
_term.dispose();
_term = null;
_fitAddon = null;
_searchAddon = null;
}
_closeSearch();
_reconnectAttempts = 0;
}
// ——— Font size ————————————————————————————————————————————————————
function setTerminalFontSize(size) {
if (!_term) return;
_term.options.fontSize = size;
if (_fitAddon) {
try { _fitAddon.fit(); } catch (_) {}
}
}
// ——— Android IME fix ————————————————————————————————————————————————
function _initAndroidIMEFix(container) {
if (!/Android/i.test(navigator.userAgent)) return;
setTimeout(function() {
// ghostty-web may use a different textarea class than xterm.js (.xterm-helper-textarea)
var ta = container.querySelector('textarea');
if (!ta) return;
ta.addEventListener('beforeinput', function(e) {
if (e.inputType === 'insertReplacementText') {
e.preventDefault();
e.stopImmediatePropagation();
var data = e.data || '';
if (data && _ws && _ws.readyState === WebSocket.OPEN) {
var bytes = _encoder ? _encoder.encode('\x08' + data) : new Uint8Array(Array.from('\x08' + data).map(function(c) { return c.charCodeAt(0); }));
_ws.send(bytes);
}
ta.value = '';
}
}, true);
}, 100);
}
// ——— Mobile toolbar ————————————————————————————————————————————————
function _initMobileToolbar() {
var isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
if (!isTouchDevice) return;
var toolbar = document.getElementById('mobile-toolbar');
if (!toolbar) return;
toolbar.classList.remove('hidden');
_ctrlActive = false;
_altActive = false;
var ctrlBtn = toolbar.querySelector('[data-modifier="ctrl"]');
var altBtn = toolbar.querySelector('[data-modifier="alt"]');
toolbar.addEventListener('pointerdown', function(e) {
var btn = e.target.closest('.mobile-toolbar__key');
if (!btn) return;
var modifier = btn.dataset.modifier;
if (modifier === 'ctrl') {
e.preventDefault();
_ctrlActive = !_ctrlActive;
btn.classList.toggle('mobile-toolbar__key--active', _ctrlActive);
if (_altActive && altBtn) { _altActive = false; altBtn.classList.remove('mobile-toolbar__key--active'); }
if (_ctrlActive && _term) _term.focus();
return;
}
if (modifier === 'alt') {
e.preventDefault();
_altActive = !_altActive;
btn.classList.toggle('mobile-toolbar__key--active', _altActive);
if (_ctrlActive && ctrlBtn) { _ctrlActive = false; ctrlBtn.classList.remove('mobile-toolbar__key--active'); }
if (_altActive && _term) _term.focus();
return;
}
e.preventDefault();
var key = btn.dataset.key;
var input = btn.dataset.input;
var seq = '';
if (key) {
switch (key) {
case 'Escape': seq = '\x1b'; break;
case 'Tab': seq = '\t'; break;
case 'ArrowUp': seq = '\x1b[A'; break;
case 'ArrowDown': seq = '\x1b[B'; break;
case 'ArrowRight': seq = '\x1b[C'; break;
case 'ArrowLeft': seq = '\x1b[D'; break;
}
} else if (input) {
seq = input;
}
if (seq && _ws && _ws.readyState === WebSocket.OPEN) {
var bytes = _encoder ? _encoder.encode(seq) : new Uint8Array(Array.from(seq).map(function(c) { return c.charCodeAt(0); }));
_ws.send(bytes);
}
});
}
// ——— Window exports ——————————————————————————————————————————————————
window._openTerminal = openTerminal;
window._switchTerminal = openTerminal; // = openTerminal (no cache)
window._closeTerminal = closeTerminal;
window._closeAllTerminals = closeTerminal; // = closeTerminal (no cache)
window._destroyCachedTerminal = function() {}; // no-op (no cache)
window._isTerminalCached = function() { return false; }; // always false (no cache)
window._openSearch = _openSearch;
window._closeSearch = _closeSearch;
window._setTerminalFontSize = setTerminalFontSize;