Skip to content

Commit 8394362

Browse files
committed
feat(web): add ghostty renderer behind query param
The browser terminal was previously hard-wired to xterm.js, which made it impossible to compare renderer behavior without code changes. This adds a runtime renderer switch so xterm and ghostty can be used in parallel. Architecture decisions: - Keep tmux websocket, pane routing, and input/output flow unchanged. - Introduce a tiny terminal runtime adapter (createTerminal/createFitAddon) so renderer-specific setup stays isolated from pane/session logic. - Vendor ghostty-web JS and WASM assets into embedded web assets to avoid introducing a frontend build pipeline. - Default to xterm and gracefully fall back to xterm if ghostty init fails. What was implemented: - Added query-param renderer selection in app runtime (`?term=ghostty` or `?term=xterm`, defaulting to xterm). - Added async boot path that initializes the selected runtime before processing tmux state updates. - Added dynamic ghostty runtime loader using `Ghostty.load` with explicit WASM path `/vendor/ghostty/ghostty-vt.wasm`. - Refactored terminal creation to use the shared runtime adapter. - Preserved active query parameters when rewriting fallback pane URLs so the selected terminal backend remains active across pane rebinding. - Added vendored assets: - internal/assets/web/vendor/ghostty/ghostty-web.js - internal/assets/web/vendor/ghostty/ghostty-vt.wasm Current state: Both renderers are now available at runtime with no server-side API changes. `?term=ghostty` activates ghostty-web and `?term=xterm` (or no param) uses xterm. Existing tmux control-mode behavior and tests continue to pass. Next steps for colleague: 1. Add browser e2e coverage that exercises `?term=ghostty` end-to-end. 2. Consider showing the active renderer in the UI for easier debugging. 3. Benchmark render/input latency differences under high output volume. Amp-Thread-Id: https://ampcode.com/threads/T-019c93d5-e152-767f-b73d-ae63c7b14e42
1 parent 038ff05 commit 8394362

3 files changed

Lines changed: 3023 additions & 6 deletions

File tree

internal/assets/web/app.js

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
const terminalHostEl = document.getElementById("terminal-host");
2+
const terminalRenderer = parseTerminalRenderer(location.search);
23

34
const initialTargetPaneId = parseTargetPaneId(location.pathname);
45

56
const state = {
7+
terminalRuntime: null,
68
ws: null,
79
panes: new Map(),
810
targetPaneId: initialTargetPaneId,
@@ -12,15 +14,62 @@ const state = {
1214
refreshTimer: null,
1315
};
1416

15-
connect();
16-
window.addEventListener("resize", schedulePaneResize);
17+
boot();
18+
19+
async function boot() {
20+
state.terminalRuntime = await loadTerminalRuntime(terminalRenderer);
21+
connect();
22+
window.addEventListener("resize", schedulePaneResize);
23+
}
1724

1825
function parseTargetPaneId(pathname) {
1926
const m = pathname.match(/^\/p\/([^/]+)$/);
2027
if (!m) return "";
2128
return normalizePublicPaneId(m[1]);
2229
}
2330

31+
function parseTerminalRenderer(search) {
32+
const params = new URLSearchParams(search);
33+
const value = (params.get("term") || "").trim().toLowerCase();
34+
return value === "ghostty" ? "ghostty" : "xterm";
35+
}
36+
37+
async function loadTerminalRuntime(renderer) {
38+
if (renderer === "ghostty") {
39+
try {
40+
const ghosttyModule = await import("/vendor/ghostty/ghostty-web.js");
41+
const ghostty = await ghosttyModule.Ghostty.load("/vendor/ghostty/ghostty-vt.wasm");
42+
return {
43+
renderer: "ghostty",
44+
createTerminal(options) {
45+
return new ghosttyModule.Terminal({ ...options, ghostty });
46+
},
47+
createFitAddon() {
48+
return new ghosttyModule.FitAddon();
49+
},
50+
};
51+
} catch (err) {
52+
console.warn("failed to initialize ghostty terminal backend, falling back to xterm", err);
53+
}
54+
}
55+
return createXtermRuntime();
56+
}
57+
58+
function createXtermRuntime() {
59+
if (typeof Terminal !== "function" || !FitAddon?.FitAddon) {
60+
throw new Error("xterm runtime is not available");
61+
}
62+
return {
63+
renderer: "xterm",
64+
createTerminal(options) {
65+
return new Terminal(options);
66+
},
67+
createFitAddon() {
68+
return new FitAddon.FitAddon();
69+
},
70+
};
71+
}
72+
2473
function connect() {
2574
const proto = location.protocol === "https:" ? "wss" : "ws";
2675
const ws = new WebSocket(`${proto}://${location.host}/ws`);
@@ -69,6 +118,7 @@ function handleServerMessage(msg) {
69118
}
70119

71120
if (msg.t === "tmux_state") {
121+
if (!state.terminalRuntime) return;
72122
applyState(msg.state);
73123
return;
74124
}
@@ -128,7 +178,7 @@ function applyState(snapshot) {
128178
resolved = resolveFallbackPane(state.panes);
129179
if (resolved) {
130180
state.targetPaneId = resolved.paneId;
131-
history.replaceState(null, "", `/p/${encodeURIComponent(resolved.paneId)}`);
181+
history.replaceState(null, "", paneURLFor(resolved.paneId));
132182
}
133183
}
134184

@@ -177,15 +227,14 @@ function createTerminal() {
177227
node.appendChild(termNode);
178228
terminalHostEl.appendChild(node);
179229

180-
const term = new Terminal({
230+
const term = state.terminalRuntime.createTerminal({
181231
convertEol: false,
182232
fontFamily: '"JetBrains Mono NF", "JetBrains Mono", Menlo, monospace',
183233
fontSize: 13,
184234
scrollback: 10000,
185-
allowTransparency: true,
186235
cursorBlink: true,
187236
});
188-
const fit = new FitAddon.FitAddon();
237+
const fit = state.terminalRuntime.createFitAddon();
189238
term.loadAddon(fit);
190239
term.open(termNode);
191240
fit.fit();
@@ -220,6 +269,11 @@ function requestModelSync() {
220269
sendArgv(["list-panes", "-a", "-F", "__WMUX___pane\t#{session_name}\t#{pane_id}\t#{window_id}\t#{pane_index}\t#{pane_active}\t#{pane_left}\t#{pane_top}\t#{pane_width}\t#{pane_height}\t#{pane_current_command}\t#{pane_title}"]);
221270
}
222271

272+
function paneURLFor(paneId) {
273+
const query = location.search || "";
274+
return `/p/${encodeURIComponent(paneId)}${query}`;
275+
}
276+
223277
function scheduleModelRefresh() {
224278
if (state.refreshTimer) return;
225279
state.refreshTimer = setTimeout(() => {
413 KB
Binary file not shown.

internal/assets/web/vendor/ghostty/ghostty-web.js

Lines changed: 2963 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)