e032d540b0
Two independent optimizations that eliminate the ~700ms-1.2s lag when switching sessions: ### Backend: ttyd process pool (ttyd.py + main.py) Instead of killing and respawning a single ttyd process on every session switch, maintain a pool of ttyd processes - one per connected session, each on its own port (7682-7701). Revisiting a session reuses the existing process instantly. - New pool API: get_or_spawn(), pool_port(), kill_session(), kill_all(), kill_orphans() - Backward-compat aliases preserved: kill_ttyd(), spawn_ttyd(), kill_orphan_ttyd(), TTYD_PORT - /connect endpoint now returns in ~0ms for already-connected sessions vs 700ms+ before - WS proxy reads ?session= query param to route to correct ttyd port - Per-port PID files for orphan recovery across restarts ### Frontend: terminal instance cache (terminal.js + app.js) Keep up to 5 xterm.js Terminal instances alive in hidden container divs. Switching to an already-visited session is a CSS display swap + fit() call - no xterm.js teardown/recreation, no WebSocket reconnection, no addon reloading. - New switchTerminal() entry point with LRU cache (max 5) - Each cached entry has its own container div, term, WebSocket, addons - Cache-aware /connect skip: if terminal is cached with live WS, bypass backend entirely - Animation gate fixed: 0ms for session-to-session switches (was always 260ms) - Cache cleanup on session delete ### Test changes (minimal) - test_ttyd.py: Added pool cleanup in fixture for isolation - test_api.py: Updated 4 tests to mock new pool API - test_ws_proxy.py: Updated auto-spawn test for pool API All 1,306 tests pass. Generated with Amplifier Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
1075 lines
41 KiB
JavaScript
1075 lines
41 KiB
JavaScript
// Phase 2b implementation — terminal.js
|
|
// xterm.js Terminal + FitAddon initialization (task-12)
|
|
|
|
// ——— Module-level state ———————————————————————————————————————————
|
|
let _term = null;
|
|
let _fitAddon = null;
|
|
let _ws = null;
|
|
let _reconnectTimer = null;
|
|
let _overlayTimer = null; // grace period before showing reconnect overlay
|
|
let _currentSession = null;
|
|
let _vpHandler = null;
|
|
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn
|
|
let _searchAddon = null;
|
|
let _resizeObserver = null;
|
|
let _ctrlActive = false; // mobile toolbar: Ctrl modifier toggle (one-shot)
|
|
let _altActive = false; // mobile toolbar: Alt modifier toggle (one-shot)
|
|
|
|
// ——— Terminal instance cache ——————————————————————————————————————
|
|
// Maps sessionKey → {container, term, fitAddon, ws, searchAddon, resizeObserver,
|
|
// reconnectTimer, overlayTimer, reconnectAttempts, currentSession, remoteId}
|
|
var _terminalCache = new Map();
|
|
var _MAX_CACHED = 5; // LRU eviction when exceeded
|
|
var _activeSessionKey = null; // currently visible session key
|
|
var _cacheOrder = []; // LRU tracking (most recent at end)
|
|
|
|
// Session key format: remoteId ? (remoteId + ':' + name) : name
|
|
function _buildSessionKey(name, remoteId) {
|
|
return remoteId ? (remoteId + ':' + name) : name;
|
|
}
|
|
|
|
// ——— Module-level encoding helpers ————————————————————————————————
|
|
// Hoisted here so the clipboard key handler (in openTerminal) can also use them.
|
|
const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
|
|
// TextDecoder: used to decode UTF-8 bytes received from ttyd before writing to xterm.js.
|
|
// xterm.js write(Uint8Array) treats each byte as Latin-1, not UTF-8 — multi-byte characters
|
|
// like ─ (U+2500, bytes E2 94 80) render as â (Latin-1 0xE2) without decoding first.
|
|
// Matches ttyd's official client pattern: textDecoder.decode(payload) → _term.write(string).
|
|
const _decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
|
|
|
|
function _encodePayload(typeChar, str) {
|
|
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
|
|
var strBytes = _encoder ? _encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); }));
|
|
var payload = new Uint8Array(1 + strBytes.length);
|
|
payload[0] = typeChar;
|
|
payload.set(strBytes, 1);
|
|
return payload;
|
|
}
|
|
|
|
// ——— Clipboard helpers ———————————————————————————————————————————
|
|
// Ctrl+Shift+C: copy terminal selection to system clipboard
|
|
// Ctrl+Shift+V: handled natively by xterm.js (browser paste event → xterm → WebSocket)
|
|
|
|
function _copyToClipboard(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text).catch(function() {});
|
|
} else {
|
|
// Fallback for non-HTTPS contexts (HTTP over LAN)
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ——— Cache helpers ———————————————————————————————————————————————
|
|
|
|
/**
|
|
* Sync module-level variables to point at the active cached entry's values.
|
|
* This keeps backward compat for search, font size changes, etc.
|
|
*/
|
|
function _syncModuleState(entry) {
|
|
if (entry) {
|
|
_term = entry.term;
|
|
_fitAddon = entry.fitAddon;
|
|
_ws = entry.ws;
|
|
_searchAddon = entry.searchAddon;
|
|
_resizeObserver = entry.resizeObserver;
|
|
_reconnectTimer = entry.reconnectTimer;
|
|
_overlayTimer = entry.overlayTimer;
|
|
_reconnectAttempts = entry.reconnectAttempts;
|
|
_currentSession = entry.currentSession;
|
|
} else {
|
|
_term = null;
|
|
_fitAddon = null;
|
|
_ws = null;
|
|
_searchAddon = null;
|
|
_resizeObserver = null;
|
|
_reconnectTimer = null;
|
|
_overlayTimer = null;
|
|
_reconnectAttempts = 0;
|
|
_currentSession = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the cached entry from current module-level variables.
|
|
* Call this after any module-level state changes to keep the entry in sync.
|
|
*/
|
|
function _syncEntryFromModule(key) {
|
|
var entry = _terminalCache.get(key);
|
|
if (!entry) return;
|
|
entry.term = _term;
|
|
entry.fitAddon = _fitAddon;
|
|
entry.ws = _ws;
|
|
entry.searchAddon = _searchAddon;
|
|
entry.resizeObserver = _resizeObserver;
|
|
entry.reconnectTimer = entry.reconnectTimer; // managed per-entry
|
|
entry.overlayTimer = entry.overlayTimer; // managed per-entry
|
|
entry.reconnectAttempts = _reconnectAttempts;
|
|
entry.currentSession = _currentSession;
|
|
}
|
|
|
|
/**
|
|
* Move a key to the end of _cacheOrder (most recently used).
|
|
*/
|
|
function _touchCacheOrder(key) {
|
|
var idx = _cacheOrder.indexOf(key);
|
|
if (idx !== -1) _cacheOrder.splice(idx, 1);
|
|
_cacheOrder.push(key);
|
|
}
|
|
|
|
// ——— Forward declarations ——————————————————————————————————————————
|
|
|
|
function connectWebSocket(name, remoteId) {
|
|
// Always connect to the same origin — remote sessions route through the
|
|
// federation proxy (ws://host/federation/{remoteId}/terminal/ws) so that
|
|
// no cross-origin WebSocket connections are made from the browser.
|
|
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
var url;
|
|
if (remoteId) {
|
|
// Remote session via federation proxy — same origin, different path
|
|
url = proto + '//' + location.host + '/federation/' + remoteId + '/terminal/ws';
|
|
} else {
|
|
// Local session: add ?session= query param so backend proxy can route to correct ttyd port
|
|
url = proto + '//' + location.host + '/terminal/ws?session=' + encodeURIComponent(name);
|
|
}
|
|
const reconnectOverlay = document.getElementById('reconnect-overlay');
|
|
// Use module-level _encodePayload (hoisted above connectWebSocket)
|
|
var encodePayload = _encodePayload;
|
|
|
|
// Register terminal event handlers once on this _term instance.
|
|
// These handlers read the module-level _ws at call time (not a captured reference),
|
|
// so they always target the live socket. createTerminal() disposes _term before
|
|
// the next session, removing these handlers automatically.
|
|
if (_term) {
|
|
_term.onData(function(data) {
|
|
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
var outData = data;
|
|
// Mobile toolbar modifier keys (one-shot: auto-deactivate after use)
|
|
if (_ctrlActive && data.length === 1) {
|
|
var code = data.toUpperCase().charCodeAt(0);
|
|
if (code >= 65 && code <= 90) { // A-Z → Ctrl+letter
|
|
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; // Alt = ESC prefix
|
|
_altActive = false;
|
|
var ab = document.querySelector('[data-modifier="alt"]');
|
|
if (ab) ab.classList.remove('mobile-toolbar__key--active');
|
|
}
|
|
// ttyd protocol: input is type 0x30 ('0') + UTF-8 keystroke bytes
|
|
_ws.send(encodePayload(0x30, outData));
|
|
}
|
|
});
|
|
_term.onResize(function(size) {
|
|
if (_ws && _ws.readyState === WebSocket.OPEN) {
|
|
// ttyd protocol: resize is type 0x31 ('1') + UTF-8 JSON
|
|
_ws.send(encodePayload(0x31, JSON.stringify({ columns: size.cols, rows: size.rows })));
|
|
}
|
|
});
|
|
}
|
|
|
|
// _connectWebSocket — creates the WebSocket instance and registers all event handlers.
|
|
// Called directly for normal reconnects (ttyd still alive), or after a brief delay
|
|
// following the /connect POST (ttyd was dead and needed respawning).
|
|
//
|
|
// Local const `ws` captures this specific instance so each handler can check
|
|
// `if (ws !== _ws) return;` (stale guard). Without it, rapid reconnects or
|
|
// session switches cause old handlers to fire on the new _ws while it is still
|
|
// CONNECTING → send error → close → reconnect → infinite loop (Bug 2).
|
|
function _connectWebSocket() {
|
|
// 'tty' subprotocol is REQUIRED — without it ttyd never starts the PTY.
|
|
// Confirmed via raw Python WebSocket tests: ttyd accepts the TCP upgrade but
|
|
// sits completely silent (no child process spawned) when subprotocol is omitted.
|
|
const ws = new WebSocket(url, ['tty']);
|
|
_ws = ws;
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.addEventListener('open', function() {
|
|
if (ws !== _ws) return; // stale connection — superseded by a newer one, ignore
|
|
// NOTE: do NOT reset _reconnectAttempts here. The server-side proxy accepts
|
|
// the WS before confirming ttyd is alive (auto-spawning if needed), but the
|
|
// browser 'open' event fires as soon as the proxy accepts — not when ttyd
|
|
// is actually ready. Resetting here caused the 0→1→0→1 bounce. Instead,
|
|
// reset on first data message (proves ttyd is alive and relaying).
|
|
// Cancel any pending overlay show (grace period may not have elapsed yet)
|
|
if (_overlayTimer) { clearTimeout(_overlayTimer); _overlayTimer = null; }
|
|
if (reconnectOverlay) reconnectOverlay.classList.add('hidden');
|
|
// Step 1: TEXT frame auth handshake — ttyd checks AuthToken before starting PTY
|
|
ws.send(JSON.stringify({ AuthToken: '' }));
|
|
// Step 2: BINARY frame with initial terminal dimensions — [0x31] + JSON({columns, rows})
|
|
if (_term) {
|
|
ws.send(encodePayload(0x31, JSON.stringify({ columns: _term.cols, rows: _term.rows })));
|
|
}
|
|
// Auto-focus the terminal so user can type immediately without clicking
|
|
if (_term) _term.focus();
|
|
});
|
|
|
|
ws.addEventListener('message', function(e) {
|
|
if (ws !== _ws) return; // stale connection — superseded by a newer one, ignore
|
|
if (!_term) return;
|
|
// First data message proves ttyd is alive and relaying — safe to reset counter.
|
|
// We deliberately do NOT reset in the 'open' handler: the server-side proxy
|
|
// accepts the browser WS before ttyd is fully confirmed alive, so 'open'
|
|
// firing alone doesn't mean data will flow. Resetting here prevents the
|
|
// 0→1→0→1 bounce that kept the reconnect loop from escalating to /connect.
|
|
if (_reconnectAttempts > 0) _reconnectAttempts = 0;
|
|
if (e.data instanceof ArrayBuffer) {
|
|
var msg = new Uint8Array(e.data);
|
|
if (msg.length < 1) return;
|
|
var msgType = msg[0];
|
|
var payload = msg.slice(1);
|
|
if (msgType === 0x30) { // '0' = terminal output — write to xterm.js
|
|
// decode: Uint8Array → UTF-8 string. write(Uint8Array) treats bytes as Latin-1.
|
|
_term.write(_decoder ? _decoder.decode(payload) : payload);
|
|
}
|
|
// 0x31 ('1') = window title, 0x32 ('2') = preferences — ignore for now
|
|
} else if (typeof e.data === 'string') {
|
|
_term.write(e.data); // fallback for text frames
|
|
}
|
|
});
|
|
|
|
ws.addEventListener('close', function() {
|
|
if (ws !== _ws) return; // stale connection — don't reconnect for old sockets
|
|
if (!_currentSession) return; // intentional close — don't reconnect
|
|
// Grace period: delay showing the overlay so brief reconnects (e.g. service
|
|
// restart) are invisible to the user. If onopen fires within the grace window,
|
|
// it cancels _overlayTimer and the user never sees "Reconnecting...".
|
|
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. Add jitter to avoid thundering herd.
|
|
var delay = Math.min(1000 * Math.pow(2, _reconnectAttempts - 1), 15000);
|
|
delay += Math.random() * 500; // jitter
|
|
_reconnectTimer = setTimeout(connect, delay);
|
|
});
|
|
|
|
ws.addEventListener('error', function() {
|
|
if (ws !== _ws) return; // stale connection — ignore
|
|
console.warn('tmux-web: WebSocket error on', url);
|
|
});
|
|
}
|
|
|
|
function connect() {
|
|
// After 2 failed WS attempts, ttyd is likely dead (e.g. after service restart).
|
|
// AWAIT the /connect POST before opening the WebSocket — ttyd must be alive first.
|
|
// fetch() includes cookies automatically for same-origin requests so auth is transparent.
|
|
//
|
|
// Critical: this path uses .then() so _connectWebSocket() runs only AFTER the POST
|
|
// response (plus an 800ms settle delay for ttyd to bind its port). The early return
|
|
// prevents falling through to the direct _connectWebSocket() call below.
|
|
if (_reconnectAttempts >= 2 && _currentSession) {
|
|
var connectPath;
|
|
if (remoteId) {
|
|
// Remote session: route through federation proxy
|
|
connectPath = '/api/federation/' + encodeURIComponent(remoteId) + '/connect/' + encodeURIComponent(_currentSession);
|
|
} else {
|
|
// Local session
|
|
connectPath = '/api/sessions/' + encodeURIComponent(_currentSession) + '/connect';
|
|
}
|
|
fetch(connectPath, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
.catch(function() { return null; })
|
|
.then(function() {
|
|
// Brief delay for ttyd to bind its port after /connect spawns it
|
|
setTimeout(_connectWebSocket, 800);
|
|
});
|
|
return; // Don't fall through — .then() handles the WebSocket creation
|
|
}
|
|
|
|
_connectWebSocket();
|
|
}
|
|
|
|
connect();
|
|
}
|
|
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;
|
|
|
|
// Resize container to fill visual viewport above keyboard.
|
|
// Account for header + mobile toolbar (if visible).
|
|
var headerHeight = 44; // matches --header-height CSS custom property
|
|
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';
|
|
|
|
// Ensure terminal viewport stays pinned — prevent the page from
|
|
// shifting upward when the soft keyboard opens on mobile.
|
|
window.scrollTo(0, 0);
|
|
|
|
// Refit xterm.js to new container size
|
|
try { _fitAddon.fit(); } catch (_) {}
|
|
};
|
|
|
|
window.visualViewport.addEventListener('resize', _vpHandler);
|
|
}
|
|
|
|
// ——— Terminal creation ————————————————————————————————————————————
|
|
|
|
/**
|
|
* Create (or recreate) the xterm.js Terminal and FitAddon instances.
|
|
* Disposes any existing terminal first.
|
|
* Stores the results in module-level _term and _fitAddon.
|
|
* @param {number} [fontSize=14] - font size in pixels, from server display settings
|
|
*/
|
|
function createTerminal(fontSize) {
|
|
// Dispose any existing instance
|
|
if (_term) {
|
|
_term.dispose();
|
|
_term = null;
|
|
_fitAddon = null;
|
|
}
|
|
|
|
// Use the fontSize passed from app.js (getDisplaySettings().fontSize), defaulting to 14.
|
|
var storedFontSize = (typeof fontSize === 'number' && fontSize > 0) ? fontSize : 14;
|
|
|
|
const mobile = window.innerWidth < 600; // matches MOBILE_THRESHOLD in app.js
|
|
const effectiveFontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize;
|
|
|
|
_term = new window.Terminal({
|
|
cursorBlink: true,
|
|
fontSize: effectiveFontSize,
|
|
fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace",
|
|
theme: {
|
|
background: '#000000',
|
|
foreground: '#c9d1d9',
|
|
cursor: '#58a6ff',
|
|
},
|
|
scrollback: mobile ? 500 : 5000,
|
|
allowProposedApi: true,
|
|
// Override OSC 8 hyperlink handler — the default shows a confirm() dialog.
|
|
// This opens links directly, matching native terminal behavior (Ghostty, iTerm2).
|
|
linkHandler: {
|
|
activate: function(event, uri) {
|
|
window.open(uri, '_blank');
|
|
},
|
|
},
|
|
});
|
|
|
|
_fitAddon = new window.FitAddon.FitAddon();
|
|
_term.loadAddon(_fitAddon);
|
|
|
|
// --- Link handling ---
|
|
// Two link systems work together:
|
|
//
|
|
// 1. WebLinksAddon: regex-detects plain-text URLs (http://..., https://...)
|
|
// and makes them clickable. Click opens in new tab.
|
|
//
|
|
// 2. OSC 8 hyperlinks: programs emit ESC]8;;URL\atext\aESC]8;;\a to make
|
|
// arbitrary text clickable (like Ghostty, iTerm2, etc.). xterm.js has
|
|
// built-in OscLinkProvider support, but its default handler shows a
|
|
// confirm() dialog. We override with a linkHandler that opens directly.
|
|
//
|
|
// Both open on plain click. Ctrl/Cmd+Click is not required — this is a
|
|
// browser-based terminal where the link-vs-select distinction comes from
|
|
// whether the user drags (select) or clicks (link).
|
|
|
|
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
|
|
if (WebLinksAddon) {
|
|
_term.loadAddon(new WebLinksAddon(function(event, uri) {
|
|
window.open(uri, '_blank');
|
|
}));
|
|
}
|
|
|
|
// Search addon — Ctrl+F to find text in terminal buffer
|
|
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
|
|
if (SearchAddon) {
|
|
_searchAddon = new SearchAddon();
|
|
_term.loadAddon(_searchAddon);
|
|
}
|
|
|
|
// Image addon — inline image rendering (Sixel, iTerm2 IIP, Kitty graphics)
|
|
// Needed for tools like yazi file manager that use graphic protocols
|
|
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
|
|
if (ImageAddon) {
|
|
_term.loadAddon(new ImageAddon());
|
|
}
|
|
}
|
|
|
|
// ——— 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 / close —————————————————————————————————————————————————
|
|
|
|
/**
|
|
* Open a terminal session inside #terminal-container.
|
|
* @param {string} sessionName
|
|
* @param {string} [remoteId] Optional federation remote ID.
|
|
* When provided, the WebSocket connects via the federation proxy path
|
|
* ws://host/federation/{remoteId}/terminal/ws (same origin, no cross-origin).
|
|
*/
|
|
function openTerminal(sessionName, remoteId, fontSize) {
|
|
// Null _currentSession first so any in-flight close handler on the old WS won't
|
|
// schedule a reconnect (it checks `if (!_currentSession) return;`).
|
|
_currentSession = null;
|
|
_reconnectAttempts = 0; // reset backoff on new session open
|
|
|
|
// Cancel any pending reconnect timer from the previous session.
|
|
if (_reconnectTimer) {
|
|
clearTimeout(_reconnectTimer);
|
|
_reconnectTimer = null;
|
|
}
|
|
|
|
// Close existing WebSocket so it can't write to the new terminal (Bug 1 fix).
|
|
if (_ws) {
|
|
_ws.close();
|
|
_ws = null;
|
|
}
|
|
|
|
_currentSession = sessionName;
|
|
|
|
const container = document.getElementById('terminal-container');
|
|
if (!container) {
|
|
console.warn('[openTerminal] #terminal-container not found');
|
|
return;
|
|
}
|
|
|
|
createTerminal(fontSize);
|
|
|
|
_term.open(container);
|
|
|
|
// --- Auto-refit on container resize (sidebar toggle, etc.) ---
|
|
// xterm.js FitAddon only resizes on explicit fit() calls. A ResizeObserver
|
|
// on the container handles ALL layout changes: sidebar toggle, window resize,
|
|
// and any future CSS geometry change. Debounced to coalesce rapid events
|
|
// (e.g. during CSS transition animation frames).
|
|
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);
|
|
}
|
|
|
|
// --- Clipboard integration ---
|
|
// Copy: Ctrl+Shift+C intercepts and copies selection to system clipboard
|
|
// Paste: handled natively by xterm.js (browser paste event → hidden textarea → onData → WebSocket)
|
|
// Cmd+V (macOS) and Ctrl+Shift+V (Linux) both trigger native browser paste events
|
|
_term.attachCustomKeyEventHandler(function(e) {
|
|
if (e.type !== 'keydown') return true;
|
|
|
|
// Ctrl+Shift+C → copy selection to clipboard
|
|
if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) {
|
|
var sel = _term.getSelection();
|
|
if (sel) _copyToClipboard(sel);
|
|
return false; // prevent xterm from processing
|
|
}
|
|
|
|
// Ctrl+F → open search bar
|
|
if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) {
|
|
_openSearch();
|
|
return false;
|
|
}
|
|
|
|
return true; // let xterm handle all other keys normally
|
|
});
|
|
|
|
// Auto-copy: when mouse selection ends, copy to system clipboard.
|
|
// Matches terminal emulator conventions (iTerm2, WezTerm, ttyd native).
|
|
// onSelectionChange fires whenever selection changes — copy if text is selected.
|
|
// When selection is cleared (empty string), we skip the clipboard write.
|
|
_term.onSelectionChange(function() {
|
|
var sel = _term.getSelection();
|
|
if (sel) {
|
|
_copyToClipboard(sel);
|
|
}
|
|
});
|
|
|
|
// OSC 52 clipboard integration — bridges tmux clipboard to the browser.
|
|
// When tmux copies text (with `set-clipboard on` in .tmux.conf), it sends
|
|
// an OSC 52 escape sequence to the terminal. xterm.js surfaces this via the
|
|
// parser API. We intercept and write the decoded text to the system clipboard
|
|
// so that: Ctrl+B [ → select → Enter (tmux copy) → system clipboard receives it.
|
|
_term.parser.registerOscHandler(52, function(data) {
|
|
// OSC 52 format: Pc ; Pd — Pc = selection target (c/p/q/s/0-7), Pd = base64 text
|
|
var parts = data.split(';');
|
|
if (parts.length >= 2) {
|
|
try {
|
|
var text = atob(parts[1]);
|
|
_copyToClipboard(text);
|
|
} catch (e) {
|
|
// Invalid base64 or unsupported — silently ignore
|
|
}
|
|
}
|
|
return true; // Handled — don't pass to xterm's default handler
|
|
});
|
|
|
|
if (_fitAddon) {
|
|
// requestAnimationFrame guarantees one full browser layout pass after the flex
|
|
// container becomes visible before fit() measures dimensions.
|
|
// iOS Safari defers flex layout — calling fit() synchronously here gives 0px width
|
|
// → 2-column terminal. The RAF and 500ms fallback fix this race condition.
|
|
// Falls back to immediate execution in Node.js test environments where RAF is absent.
|
|
const fitAddonRef = _fitAddon;
|
|
const raf = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : (fn) => fn();
|
|
raf(function() {
|
|
try { fitAddonRef.fit(); } catch (_) {}
|
|
// 500ms fallback for slow mobile layout engines (e.g. first paint on low-end devices)
|
|
setTimeout(function() {
|
|
try { if (_fitAddon) _fitAddon.fit(); } catch (_) {}
|
|
}, 500);
|
|
});
|
|
}
|
|
|
|
// Wire search bar buttons + keyboard handlers (idempotent — elements are static)
|
|
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) {
|
|
// Remove old listeners by replacing with cloned element (avoids duplicate handlers on reconnect)
|
|
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 newClose = searchClose.cloneNode(true);
|
|
searchClose.parentNode.replaceChild(newClose, searchClose);
|
|
newClose.addEventListener('click', _closeSearch);
|
|
}
|
|
if (searchNextBtn) {
|
|
var newNext = searchNextBtn.cloneNode(true);
|
|
searchNextBtn.parentNode.replaceChild(newNext, searchNextBtn);
|
|
newNext.addEventListener('click', _searchNext);
|
|
}
|
|
if (searchPrevBtn) {
|
|
var newPrev = searchPrevBtn.cloneNode(true);
|
|
searchPrevBtn.parentNode.replaceChild(newPrev, searchPrevBtn);
|
|
newPrev.addEventListener('click', _searchPrev);
|
|
}
|
|
|
|
// --- Right-click context menu ---
|
|
// Suppress the browser context menu on plain right-click inside the terminal
|
|
// so tmux's own menu (when `set -g mouse on`) isn't covered by the browser's.
|
|
// Shift+RMB and Ctrl+RMB still open the browser context menu as escape hatches.
|
|
container.addEventListener('contextmenu', function(e) {
|
|
if (e.shiftKey || e.ctrlKey || e.metaKey) return; // let modified clicks through
|
|
e.preventDefault();
|
|
});
|
|
|
|
connectWebSocket(sessionName, remoteId);
|
|
initVisualViewport(); /* defined in Task 14 */
|
|
|
|
// --- Mobile enhancements ---
|
|
_initAndroidIMEFix(container);
|
|
_initMobileToolbar();
|
|
}
|
|
|
|
// ——— switchTerminal — cache-aware session switching ————————————————
|
|
|
|
/**
|
|
* Switch to a terminal session, using cached instance if available.
|
|
* This is the preferred entry point for session switching — eliminates
|
|
* xterm.js teardown/recreation when switching between already-visited sessions.
|
|
* @param {string} sessionName
|
|
* @param {string} [remoteId] Optional federation remote ID.
|
|
* @param {number} [fontSize=14]
|
|
*/
|
|
function switchTerminal(sessionName, remoteId, fontSize) {
|
|
var key = _buildSessionKey(sessionName, remoteId);
|
|
|
|
// 1. Already showing this session — just focus and return
|
|
if (_activeSessionKey === key) {
|
|
if (_term) _term.focus();
|
|
return;
|
|
}
|
|
|
|
// 2. Hide the currently active entry (don't dispose — keep it cached)
|
|
if (_activeSessionKey && _terminalCache.has(_activeSessionKey)) {
|
|
var activeEntry = _terminalCache.get(_activeSessionKey);
|
|
// Save current module state back to the active entry before switching
|
|
activeEntry.ws = _ws;
|
|
activeEntry.reconnectTimer = _reconnectTimer;
|
|
activeEntry.overlayTimer = _overlayTimer;
|
|
activeEntry.reconnectAttempts = _reconnectAttempts;
|
|
if (activeEntry.container) {
|
|
activeEntry.container.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// 3. If target is in cache — instant switch, no network
|
|
if (_terminalCache.has(key)) {
|
|
var entry = _terminalCache.get(key);
|
|
if (entry.container) {
|
|
entry.container.style.display = '';
|
|
}
|
|
_syncModuleState(entry);
|
|
_activeSessionKey = key;
|
|
_touchCacheOrder(key);
|
|
if (_fitAddon) {
|
|
try { _fitAddon.fit(); } catch (_) {}
|
|
}
|
|
if (_term) _term.focus();
|
|
return;
|
|
}
|
|
|
|
// 4. Not in cache — create new entry
|
|
// Evict LRU if at capacity
|
|
if (_terminalCache.size >= _MAX_CACHED && _cacheOrder.length > 0) {
|
|
var evictKey = _cacheOrder[0];
|
|
destroyCachedEntry(evictKey);
|
|
}
|
|
|
|
// Create a new sub-container div inside #terminal-container
|
|
var parentContainer = document.getElementById('terminal-container');
|
|
if (!parentContainer) {
|
|
console.warn('[switchTerminal] #terminal-container not found');
|
|
return;
|
|
}
|
|
|
|
var subContainer = document.createElement('div');
|
|
subContainer.className = 'terminal-instance';
|
|
subContainer.setAttribute('data-session', key);
|
|
subContainer.style.width = '100%';
|
|
subContainer.style.height = '100%';
|
|
parentContainer.appendChild(subContainer);
|
|
|
|
// Reset module-level state for fresh creation
|
|
_currentSession = null;
|
|
_reconnectAttempts = 0;
|
|
_reconnectTimer = null;
|
|
_overlayTimer = null;
|
|
_ws = null;
|
|
|
|
_currentSession = sessionName;
|
|
|
|
createTerminal(fontSize);
|
|
_term.open(subContainer);
|
|
|
|
// Auto-refit on container resize
|
|
_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(subContainer);
|
|
}
|
|
|
|
// Clipboard integration
|
|
_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;
|
|
});
|
|
|
|
_term.onSelectionChange(function() {
|
|
var sel = _term.getSelection();
|
|
if (sel) _copyToClipboard(sel);
|
|
});
|
|
|
|
_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;
|
|
});
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
// Right-click context menu suppression
|
|
subContainer.addEventListener('contextmenu', function(e) {
|
|
if (e.shiftKey || e.ctrlKey || e.metaKey) return;
|
|
e.preventDefault();
|
|
});
|
|
|
|
connectWebSocket(sessionName, remoteId);
|
|
initVisualViewport();
|
|
_initAndroidIMEFix(subContainer);
|
|
_initMobileToolbar();
|
|
|
|
// Wire search bar (idempotent)
|
|
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); }
|
|
|
|
// Store in cache
|
|
var newEntry = {
|
|
container: subContainer,
|
|
term: _term,
|
|
fitAddon: _fitAddon,
|
|
ws: _ws,
|
|
searchAddon: _searchAddon,
|
|
resizeObserver: _resizeObserver,
|
|
reconnectTimer: _reconnectTimer,
|
|
overlayTimer: _overlayTimer,
|
|
reconnectAttempts: _reconnectAttempts,
|
|
currentSession: _currentSession,
|
|
remoteId: remoteId || '',
|
|
};
|
|
_terminalCache.set(key, newEntry);
|
|
_activeSessionKey = key;
|
|
_touchCacheOrder(key);
|
|
}
|
|
|
|
// ——— Cache entry teardown ————————————————————————————————————————
|
|
|
|
/**
|
|
* Full teardown of one cached entry: close WS, dispose term, remove container div, delete from cache.
|
|
* @param {string} key - session key to destroy
|
|
*/
|
|
function destroyCachedEntry(key) {
|
|
var entry = _terminalCache.get(key);
|
|
if (!entry) return;
|
|
|
|
// Clean up timers
|
|
if (entry.reconnectTimer) clearTimeout(entry.reconnectTimer);
|
|
if (entry.overlayTimer) clearTimeout(entry.overlayTimer);
|
|
|
|
// Close WebSocket
|
|
if (entry.ws) {
|
|
// Null out currentSession on entry so close handler won't reconnect
|
|
entry.currentSession = null;
|
|
entry.ws.close();
|
|
}
|
|
|
|
// Disconnect ResizeObserver
|
|
if (entry.resizeObserver) entry.resizeObserver.disconnect();
|
|
|
|
// Dispose xterm.js Terminal
|
|
if (entry.term) entry.term.dispose();
|
|
|
|
// Remove container div from DOM
|
|
if (entry.container && entry.container.parentNode) {
|
|
entry.container.parentNode.removeChild(entry.container);
|
|
}
|
|
|
|
// Remove from cache and LRU order
|
|
_terminalCache.delete(key);
|
|
var idx = _cacheOrder.indexOf(key);
|
|
if (idx !== -1) _cacheOrder.splice(idx, 1);
|
|
|
|
// If this was the active session, clear module state
|
|
if (_activeSessionKey === key) {
|
|
_activeSessionKey = null;
|
|
_syncModuleState(null);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Close the current terminal session and clean up all resources.
|
|
* If the active session has a cache entry, destroys it.
|
|
*/
|
|
function closeTerminal() {
|
|
// If we have a cached active session, destroy its cache entry
|
|
if (_activeSessionKey && _terminalCache.has(_activeSessionKey)) {
|
|
destroyCachedEntry(_activeSessionKey);
|
|
}
|
|
|
|
if (_vpHandler) {
|
|
if (window.visualViewport) window.visualViewport.removeEventListener('resize', _vpHandler);
|
|
_vpHandler = null;
|
|
}
|
|
|
|
if (_reconnectTimer) {
|
|
clearTimeout(_reconnectTimer);
|
|
_reconnectTimer = null;
|
|
}
|
|
|
|
if (_overlayTimer) {
|
|
clearTimeout(_overlayTimer);
|
|
_overlayTimer = null;
|
|
}
|
|
|
|
if (_ws) {
|
|
_ws.close();
|
|
_ws = null;
|
|
}
|
|
|
|
if (_resizeObserver) { _resizeObserver.disconnect(); _resizeObserver = null; }
|
|
|
|
// Clean up mobile enhancements
|
|
_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();
|
|
_currentSession = null;
|
|
_reconnectAttempts = 0; // reset backoff on intentional close
|
|
_activeSessionKey = null;
|
|
}
|
|
|
|
/**
|
|
* Destroy all cached terminal entries.
|
|
*/
|
|
function closeAllTerminals() {
|
|
var keys = Array.from(_terminalCache.keys());
|
|
for (var i = 0; i < keys.length; i++) {
|
|
destroyCachedEntry(keys[i]);
|
|
}
|
|
_activeSessionKey = null;
|
|
_cacheOrder = [];
|
|
// Also clean up any non-cached module state
|
|
closeTerminal();
|
|
}
|
|
|
|
/**
|
|
* Check if a session is cached with a live (open) WebSocket.
|
|
* @param {string} sessionKey
|
|
* @returns {boolean}
|
|
*/
|
|
function isTerminalCached(sessionKey) {
|
|
var entry = _terminalCache.get(sessionKey);
|
|
if (!entry) return false;
|
|
return entry.ws && entry.ws.readyState === WebSocket.OPEN;
|
|
}
|
|
|
|
// ——— Expose to app.js ——————————————————————————————————————————————
|
|
window._switchTerminal = switchTerminal; // NEW — preferred entry point (cache-aware)
|
|
window._openTerminal = openTerminal; // KEEP for backward compat (creates fresh, no cache check)
|
|
window._closeTerminal = closeTerminal;
|
|
window._closeAllTerminals = closeAllTerminals; // NEW — destroy all cached entries
|
|
window._destroyCachedTerminal = destroyCachedEntry; // NEW — for deleting specific sessions
|
|
window._isTerminalCached = isTerminalCached; // NEW — check if session is cached with live WS
|
|
window._openSearch = _openSearch;
|
|
window._closeSearch = _closeSearch;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// setTerminalFontSize — live font-size update without reconnecting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Update the terminal font size at runtime without reconnecting.
|
|
* Modifies _term.options.fontSize and refits the terminal to recalculate dimensions.
|
|
* No-op when no terminal is open.
|
|
* @param {number} size - font size in pixels
|
|
*/
|
|
function setTerminalFontSize(size) {
|
|
if (!_term) return;
|
|
_term.options.fontSize = size;
|
|
if (_fitAddon) {
|
|
try { _fitAddon.fit(); } catch (_) {}
|
|
}
|
|
}
|
|
|
|
window._setTerminalFontSize = setTerminalFontSize;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Android IME fix — prevent double-space-to-period from duplicating input
|
|
// Android keyboards use insertReplacementText beforeinput events to perform
|
|
// auto-corrections (e.g. " " → ". "). xterm.js's hidden textarea gets confused
|
|
// by these replacements, causing the entire composition buffer to replay.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function _initAndroidIMEFix(container) {
|
|
if (!/Android/i.test(navigator.userAgent)) return;
|
|
|
|
// Wait a tick for xterm.js to create its hidden textarea
|
|
setTimeout(function() {
|
|
var ta = container.querySelector('.xterm-helper-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) {
|
|
// Send backspace (delete the char being replaced) + replacement text
|
|
_ws.send(_encodePayload(0x30, '\x08' + data));
|
|
}
|
|
// Clear textarea to prevent stale IME state
|
|
ta.value = '';
|
|
}
|
|
}, true); // capture phase — run before xterm.js handlers
|
|
}, 100);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mobile toolbar — virtual keys for Esc, Tab, Ctrl, Alt, arrows, specials
|
|
// Ctrl and Alt are one-shot modifier toggles: tap Ctrl, then type a letter
|
|
// on the soft keyboard → sends Ctrl+letter, modifier auto-deactivates.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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"]');
|
|
|
|
// Single delegated handler for all toolbar keys.
|
|
// Use 'pointerdown' + preventDefault to avoid focusing xterm's hidden textarea
|
|
// (which brings up the soft keyboard). Modifier toggles (Ctrl/Alt) DO focus
|
|
// intentionally so the user can type the next character on the soft keyboard.
|
|
toolbar.addEventListener('pointerdown', function(e) {
|
|
var btn = e.target.closest('.mobile-toolbar__key');
|
|
if (!btn) return;
|
|
|
|
// --- Modifier toggles: focus terminal to bring up keyboard ---
|
|
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'); }
|
|
// Focus terminal so soft keyboard appears for the next character
|
|
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'); }
|
|
// Focus terminal so soft keyboard appears for the next character
|
|
if (_altActive && _term) _term.focus();
|
|
return;
|
|
}
|
|
|
|
// --- Direct key presses: send sequence WITHOUT focusing (no keyboard popup) ---
|
|
e.preventDefault(); // prevent focus transfer to xterm textarea
|
|
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) {
|
|
_ws.send(_encodePayload(0x30, seq));
|
|
}
|
|
// Do NOT focus terminal — keeps soft keyboard hidden
|
|
});
|
|
}
|