From 27b631c89e1602515988f81d679709b7284ca053 Mon Sep 17 00:00:00 2001 From: Ken Date: Thu, 28 May 2026 06:22:02 +0000 Subject: [PATCH] feat: update terminal.js for ghostty-web API compatibility --- muxplex/frontend/terminal.js | 64 +++++++++--- muxplex/tests/test_frontend_js.py | 162 ++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 14 deletions(-) diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index ba50478..0fe1b23 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -37,6 +37,18 @@ const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; // Matches ttyd's official client pattern: textDecoder.decode(payload) → _term.write(string). const _decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null; +// ——— ghostty-web WASM initialization ———————————————————————————————————————— +// ghostty-web requires an async init() call to load the WASM binary before +// any Terminal instance can be created. We kick this off at module load time +// so it completes well before the user opens a terminal. +var _ghosttyReady = (function() { + if (typeof window !== 'undefined' && window.GhosttyWeb && window.GhosttyWeb.init) { + return window.GhosttyWeb.init('/vendor/ghostty-vt.wasm'); + } + // Fallback for test environments or when GhosttyWeb is not loaded + return Promise.resolve(); +})(); + 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); })); @@ -346,7 +358,9 @@ function createTerminal(fontSize) { const mobile = window.innerWidth < 600; // matches MOBILE_THRESHOLD in app.js const effectiveFontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize; - _term = new window.Terminal({ + // ghostty-web exposes Terminal under window.GhosttyWeb; fall back to xterm.js window.Terminal + var TerminalClass = (window.GhosttyWeb && window.GhosttyWeb.Terminal) ? window.GhosttyWeb.Terminal : window.Terminal; + _term = new TerminalClass({ cursorBlink: true, fontSize: effectiveFontSize, fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace", @@ -366,7 +380,9 @@ function createTerminal(fontSize) { }, }); - _fitAddon = new window.FitAddon.FitAddon(); + // ghostty-web bundles FitAddon natively; fall back to xterm.js addon + var FitAddonClass = (window.GhosttyWeb && window.GhosttyWeb.FitAddon) ? window.GhosttyWeb.FitAddon : (window.FitAddon && window.FitAddon.FitAddon); + _fitAddon = new FitAddonClass(); _term.loadAddon(_fitAddon); // --- Link handling --- @@ -384,25 +400,38 @@ function createTerminal(fontSize) { // 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'); - })); + 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); } // 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); + 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; } // 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()); + try { + var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon; + if (ImageAddon) { + _term.loadAddon(new ImageAddon()); + } + } catch (e) { + console.warn('ImageAddon not compatible:', e); } } @@ -476,6 +505,13 @@ function openTerminal(sessionName, remoteId, fontSize) { return; } + // Defensive guard: wait for ghostty-web WASM if init hasn't completed yet. + // In practice, WASM loads at page load and completes before user interaction, + // so this is purely defensive. + if (_ghosttyReady && typeof _ghosttyReady.then === 'function') { + _ghosttyReady.then(function() { _ghosttyReady = null; }); + } + createTerminal(fontSize); _term.open(container); diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index bd4a19a..571d869 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -4740,3 +4740,165 @@ def test_render_sidebar_click_handler_guards_tile_options_btn() -> None: "so clicking ⋮ doesn't also trigger openSession() — " "use: if (e.target.closest('.tile-options-btn')) return;" ) + + +# ─── Task 4: terminal.js ghostty-web API updates ────────────────────────────── + + +def test_terminal_js_has_ghostty_ready_variable() -> None: + """terminal.js must declare a _ghosttyReady promise variable for WASM init.""" + assert "_ghosttyReady" in _TERMINAL_JS, ( + "terminal.js must declare _ghosttyReady for WASM initialization" + ) + + +def test_terminal_js_wasm_init_calls_ghostty_web_init() -> None: + """terminal.js WASM init block must call GhosttyWeb.init() with WASM path.""" + # Must reference the init method and the wasm path + assert ".init(" in _TERMINAL_JS, ( + "terminal.js must call .init() for WASM initialization" + ) + assert "ghostty-vt.wasm" in _TERMINAL_JS, ( + "terminal.js WASM init must reference the ghostty-vt.wasm path" + ) + + +def test_terminal_js_wasm_init_has_test_fallback() -> None: + """WASM init must fall back to Promise.resolve() for test environments.""" + assert "Promise.resolve()" in _TERMINAL_JS, ( + "terminal.js must have Promise.resolve() fallback for test environments" + ) + + +def test_terminal_js_constructor_uses_ghostty_web_global() -> None: + """Terminal constructor must check GhosttyWeb global before falling back to window.Terminal.""" + # Must not use bare `new window.Terminal(` without a GhosttyWeb check + assert "GhosttyWeb" in _TERMINAL_JS, ( + "terminal.js must reference GhosttyWeb global for Terminal constructor" + ) + # The createTerminal function body should contain a lookup for the Terminal class + match = re.search( + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))", + _TERMINAL_JS, + re.DOTALL, + ) + assert match, "createTerminal function not found in terminal.js" + body = match.group(1) + # Must have a variable lookup that checks GhosttyWeb, not bare `new window.Terminal(` + assert "GhosttyWeb" in body, ( + "createTerminal must check GhosttyWeb global for Terminal class" + ) + + +def test_terminal_js_fit_addon_checks_ghostty_web() -> None: + """FitAddon instantiation must check GhosttyWeb first, then fall back to window.FitAddon.""" + match = re.search( + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))", + _TERMINAL_JS, + re.DOTALL, + ) + assert match, "createTerminal function not found in terminal.js" + body = match.group(1) + # Must check GhosttyWeb for FitAddon + assert "GhosttyWeb" in body and "FitAddon" in body, ( + "createTerminal must check GhosttyWeb for FitAddon before falling back" + ) + + +def test_terminal_js_weblinks_addon_has_try_catch() -> None: + """WebLinksAddon loading must be wrapped in try/catch for compatibility.""" + match = re.search( + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))", + _TERMINAL_JS, + re.DOTALL, + ) + assert match, "createTerminal function not found in terminal.js" + body = match.group(1) + # WebLinksAddon loading code (var assignment) must be inside try/catch + assert "WebLinksAddon" in body, "createTerminal must reference WebLinksAddon" + # Find the var assignment for WebLinksAddon (the actual loading code, not comment) + var_match = re.search(r"var WebLinksAddon", body) + assert var_match, "var WebLinksAddon assignment not found" + weblinks_idx = var_match.start() + nearby = body[max(0, weblinks_idx - 100):weblinks_idx + 300] + assert "try" in nearby and "catch" in nearby, ( + "WebLinksAddon loading must be wrapped in try/catch" + ) + + +def test_terminal_js_search_addon_has_try_catch() -> None: + """SearchAddon loading must be wrapped in try/catch for compatibility.""" + match = re.search( + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))", + _TERMINAL_JS, + re.DOTALL, + ) + assert match, "createTerminal function not found in terminal.js" + body = match.group(1) + assert "SearchAddon" in body, "createTerminal must reference SearchAddon" + search_idx = body.index("SearchAddon") + nearby = body[max(0, search_idx - 200):search_idx + 300] + assert "try" in nearby and "catch" in nearby, ( + "SearchAddon loading must be wrapped in try/catch" + ) + + +def test_terminal_js_search_addon_null_on_failure() -> None: + """SearchAddon failure must set _searchAddon = null.""" + match = re.search( + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))", + _TERMINAL_JS, + re.DOTALL, + ) + assert match, "createTerminal function not found in terminal.js" + body = match.group(1) + # The catch block for SearchAddon must null out _searchAddon + assert "_searchAddon = null" in body, ( + "SearchAddon catch block must set _searchAddon = null" + ) + + +def test_terminal_js_image_addon_has_try_catch() -> None: + """ImageAddon loading must be wrapped in try/catch for compatibility.""" + match = re.search( + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))", + _TERMINAL_JS, + re.DOTALL, + ) + assert match, "createTerminal function not found in terminal.js" + body = match.group(1) + assert "ImageAddon" in body, "createTerminal must reference ImageAddon" + image_idx = body.index("ImageAddon") + nearby = body[max(0, image_idx - 200):image_idx + 300] + assert "try" in nearby and "catch" in nearby, ( + "ImageAddon loading must be wrapped in try/catch" + ) + + +def test_terminal_js_addon_catch_blocks_have_console_warn() -> None: + """Addon try/catch blocks must console.warn on incompatibility.""" + match = re.search( + r"function createTerminal\s*\([^)]*\)\s*\{(.*?)(?=\n(?:function|//\s*\u2014|window\.))", + _TERMINAL_JS, + re.DOTALL, + ) + assert match, "createTerminal function not found in terminal.js" + body = match.group(1) + # Must have console.warn calls in catch blocks + assert body.count("console.warn") >= 1, ( + "Addon catch blocks must use console.warn for incompatibility warnings" + ) + + +def test_terminal_js_open_terminal_has_ghostty_ready_guard() -> None: + """openTerminal() must have a defensive _ghosttyReady guard.""" + match = re.search( + r"function openTerminal\s*\([^)]*\)\s*\{(.*?)(?=\nfunction |\n// \u2014)", + _TERMINAL_JS, + re.DOTALL, + ) + assert match, "openTerminal function not found in terminal.js" + body = match.group(1) + assert "_ghosttyReady" in body, ( + "openTerminal must have a _ghosttyReady guard before creating the terminal" + )