469acd7e3f
- 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
562 lines
20 KiB
JavaScript
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;
|