feat: update terminal.js for ghostty-web API compatibility
This commit is contained in:
@@ -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,26 +400,39 @@ function createTerminal(fontSize) {
|
||||
// browser-based terminal where the link-vs-select distinction comes from
|
||||
// whether the user drags (select) or clicks (link).
|
||||
|
||||
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
|
||||
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
|
||||
try {
|
||||
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
|
||||
if (ImageAddon) {
|
||||
_term.loadAddon(new ImageAddon());
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('ImageAddon not compatible:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// ——— Search helpers ——————————————————————————————————————————————————————————————————————
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user