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).
|
// Matches ttyd's official client pattern: textDecoder.decode(payload) → _term.write(string).
|
||||||
const _decoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
|
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) {
|
function _encodePayload(typeChar, str) {
|
||||||
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
|
// Returns Uint8Array: [typeCharCode, ...utf8bytes]
|
||||||
var strBytes = _encoder ? _encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); }));
|
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 mobile = window.innerWidth < 600; // matches MOBILE_THRESHOLD in app.js
|
||||||
const effectiveFontSize = mobile ? Math.min(storedFontSize, 12) : storedFontSize;
|
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,
|
cursorBlink: true,
|
||||||
fontSize: effectiveFontSize,
|
fontSize: effectiveFontSize,
|
||||||
fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace",
|
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);
|
_term.loadAddon(_fitAddon);
|
||||||
|
|
||||||
// --- Link handling ---
|
// --- Link handling ---
|
||||||
@@ -384,25 +400,38 @@ function createTerminal(fontSize) {
|
|||||||
// browser-based terminal where the link-vs-select distinction comes from
|
// browser-based terminal where the link-vs-select distinction comes from
|
||||||
// whether the user drags (select) or clicks (link).
|
// whether the user drags (select) or clicks (link).
|
||||||
|
|
||||||
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
|
try {
|
||||||
if (WebLinksAddon) {
|
var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
|
||||||
_term.loadAddon(new WebLinksAddon(function(event, uri) {
|
if (WebLinksAddon) {
|
||||||
window.open(uri, '_blank');
|
_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
|
// Search addon — Ctrl+F to find text in terminal buffer
|
||||||
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
|
try {
|
||||||
if (SearchAddon) {
|
var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
|
||||||
_searchAddon = new SearchAddon();
|
if (SearchAddon) {
|
||||||
_term.loadAddon(_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)
|
// Image addon — inline image rendering (Sixel, iTerm2 IIP, Kitty graphics)
|
||||||
// Needed for tools like yazi file manager that use graphic protocols
|
// Needed for tools like yazi file manager that use graphic protocols
|
||||||
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
|
try {
|
||||||
if (ImageAddon) {
|
var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
|
||||||
_term.loadAddon(new 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;
|
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);
|
createTerminal(fontSize);
|
||||||
|
|
||||||
_term.open(container);
|
_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() — "
|
"so clicking ⋮ doesn't also trigger openSession() — "
|
||||||
"use: if (e.target.closest('.tile-options-btn')) return;"
|
"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