Files
muxplex/docs/superpowers/plans/2026-05-28-phase1-ghostty-web-swap.md
T

21 KiB

Phase 1: ghostty-web Swap Implementation Plan

Execution: Use the subagent-driven-development workflow to implement this plan.

Goal: Replace xterm.js with ghostty-web as the terminal emulator frontend — same backend, better VT parsing.

Architecture: ghostty-web is a WASM-compiled port of Ghostty's VT100 parser with xterm.js API compatibility. It ships a UMD build (ghostty-web.umd.cjs) that exposes globals via <script> tag — matching the project's no-build-step vendoring pattern. The WASM file (ghostty-vt.wasm) must be served alongside it. ghostty-web bundles FitAddon natively; SearchAddon, WebLinksAddon, and ImageAddon have no ghostty-web equivalents yet and need compatibility investigation.

Tech Stack: ghostty-web (npm), WASM, vanilla JS (no build step), Python pytest for frontend structural tests

Spec: docs/superpowers/specs/2026-05-28-terminal-architecture-redesign.md (Phase 1 section)


Task 1: Research ghostty-web addon compatibility

Files:

  • Create: docs/superpowers/plans/ghostty-web-addon-research.md

Context: ghostty-web claims xterm.js API compatibility and bundles FitAddon natively. But we use four addons: FitAddon, SearchAddon, WebLinksAddon, and ImageAddon. This task determines which work, which don't, and what the fallback plan is.

Step 1: Download and inspect the ghostty-web npm package

Run:

cd /tmp && mkdir ghostty-web-research && cd ghostty-web-research
npm pack ghostty-web
tar xzf ghostty-web-*.tgz
ls -la package/
ls -la package/dist/

Examine the UMD build to understand what globals it exposes:

head -100 package/dist/ghostty-web.umd.cjs
# Look for: what global name it uses (GhosttyWeb? Terminal? etc.)
# Look for: FitAddon export
# Look for: init() function export

Check the WASM file size:

ls -la package/ghostty-vt.wasm
ls -la package/dist/ghostty-vt.wasm

Step 2: Check if xterm.js addons are loadable via ghostty-web's loadAddon() API

Read the UMD build to verify the loadAddon() method signature. The xterm.js addon interface is:

// Addon must implement: activate(terminal), dispose()
terminal.loadAddon(addonInstance);

If ghostty-web implements the same loadAddon() contract, existing xterm.js addons (SearchAddon, WebLinksAddon) may work as-is since they only depend on the Terminal API surface.

Step 3: Check FitAddon

ghostty-web bundles FitAddon natively (confirmed in its AGENTS.md: "Addons (FitAddon)"). Determine:

  • Is it exported from the UMD build as FitAddon?
  • Does it have the same API: new FitAddon(), .fit(), .proposeDimensions()?

Step 4: Check ghostty-web's init() requirement

ghostty-web requires await init() before creating Terminal instances (loads WASM). The UMD build needs:

  • Does init() accept a WASM URL parameter? (needed since we serve from /vendor/)
  • Or does it auto-detect the WASM location relative to the JS file?

Step 5: Document findings

Write docs/superpowers/plans/ghostty-web-addon-research.md with:

  • What globals the UMD build exposes
  • FitAddon: works/doesn't, API differences
  • SearchAddon: can xterm.js addon load via loadAddon()? If not, what's the fallback?
  • WebLinksAddon: same question. Note: ghostty-web may handle URL detection natively.
  • ImageAddon: same question. Note: this is the least critical — Sixel/iTerm2 image support is a nice-to-have.
  • init() behavior: WASM URL configuration
  • CSS: does ghostty-web need xterm.css or has its own styles?
  • Any API surface differences found (e.g., term.write() accepts Uint8Array? string only?)

Step 6: Commit

git add docs/superpowers/plans/ghostty-web-addon-research.md
git commit -m "docs: ghostty-web addon compatibility research"

Task 2: Vendor ghostty-web files

Files:

  • Create: muxplex/frontend/vendor/ghostty-web.umd.cjs
  • Create: muxplex/frontend/vendor/ghostty-vt.wasm
  • Create: muxplex/frontend/vendor/ghostty-web.css (if ghostty-web has its own CSS)

Depends on: Task 1 (need to know exact file names and what to vendor)

Step 1: Copy the UMD build and WASM into vendor directory

Run:

# If not already unpacked from Task 1:
cd /tmp/ghostty-web-research || (mkdir -p /tmp/ghostty-web-research && cd /tmp/ghostty-web-research && npm pack ghostty-web && tar xzf ghostty-web-*.tgz)

# Copy UMD build
cp /tmp/ghostty-web-research/package/dist/ghostty-web.umd.cjs muxplex/frontend/vendor/ghostty-web.js

# Copy WASM file (check both locations)
cp /tmp/ghostty-web-research/package/dist/ghostty-vt.wasm muxplex/frontend/vendor/ghostty-vt.wasm \
  || cp /tmp/ghostty-web-research/package/ghostty-vt.wasm muxplex/frontend/vendor/ghostty-vt.wasm

Note: we rename ghostty-web.umd.cjs to ghostty-web.js to match the project's .js extension convention for vendor files.

Step 2: If ghostty-web has CSS, copy it

Check if there's a CSS file in the package:

find /tmp/ghostty-web-research/package -name "*.css" -type f

If found, copy to muxplex/frontend/vendor/ghostty-web.css.

Step 3: Verify the WASM file is served correctly

The Python FastAPI app serves muxplex/frontend/ as static files. WASM files need the correct MIME type (application/wasm). Check if FastAPI/Starlette handles this:

grep -r "wasm\|mime\|StaticFiles\|static" muxplex/main.py | head -20

If the static file server doesn't serve .wasm with the correct MIME type, we may need to add a MIME type mapping. Most modern versions of Starlette handle this automatically.

Step 4: Verify vendor files are present

Run:

ls -la muxplex/frontend/vendor/ghostty-web.js
ls -la muxplex/frontend/vendor/ghostty-vt.wasm
wc -c muxplex/frontend/vendor/ghostty-web.js muxplex/frontend/vendor/ghostty-vt.wasm

Expected: ghostty-web.js exists, ghostty-vt.wasm exists (~404KB).

Step 5: Commit

git add muxplex/frontend/vendor/ghostty-web.js muxplex/frontend/vendor/ghostty-vt.wasm
# Add CSS too if it exists:
# git add muxplex/frontend/vendor/ghostty-web.css
git commit -m "vendor: add ghostty-web UMD build and WASM"

Task 3: Update index.html script tags

Files:

  • Modify: muxplex/frontend/index.html

Depends on: Task 2 (vendor files must exist), Task 1 (know the CSS situation)

Step 1: Write the failing test

Add a test to muxplex/tests/test_frontend_js.py that verifies the new script tags:

# At the top of the file, add:
INDEX_HTML_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "index.html"
_INDEX_HTML: str = INDEX_HTML_PATH.read_text()


# Add these tests at the end of the file:

# -- ghostty-web migration (Phase 1) --


def test_index_html_loads_ghostty_web_js() -> None:
    """index.html must load ghostty-web.js vendor script."""
    assert "ghostty-web.js" in _INDEX_HTML, (
        "index.html must include a script tag for vendor/ghostty-web.js"
    )


def test_index_html_no_xterm_js_script() -> None:
    """index.html must not load xterm.js (replaced by ghostty-web)."""
    assert 'src="/vendor/xterm.js"' not in _INDEX_HTML, (
        "index.html must not load xterm.js — replaced by ghostty-web"
    )


def test_index_html_no_xterm_addon_fit_script() -> None:
    """index.html must not load xterm-addon-fit.js (FitAddon is built into ghostty-web)."""
    assert "xterm-addon-fit.js" not in _INDEX_HTML, (
        "index.html must not load xterm-addon-fit.js — FitAddon is built into ghostty-web"
    )

Step 2: Run tests to verify they fail

Run:

.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py::test_index_html_loads_ghostty_web_js -xvs

Expected: FAIL — ghostty-web.js not found in index.html yet.

Step 3: Update index.html

In muxplex/frontend/index.html, replace the xterm.js script tags (lines 272-276):

Before:

  <script src="/vendor/xterm.js"></script>
  <script src="/vendor/xterm-addon-fit.js"></script>
  <script src="/vendor/xterm-addon-web-links.js"></script>
  <script src="/vendor/xterm-addon-search.js"></script>
  <script src="/vendor/addon-image.js"></script>

After:

  <script src="/vendor/ghostty-web.js"></script>
  <script src="/vendor/xterm-addon-web-links.js"></script>
  <script src="/vendor/xterm-addon-search.js"></script>
  <script src="/vendor/addon-image.js"></script>

Note: We keep WebLinksAddon, SearchAddon, and ImageAddon for now. They use the xterm.js addon interface (loadAddon). Task 1 research will determine if they're compatible. If not, Task 4 will handle fallbacks. FitAddon is dropped because ghostty-web bundles it natively.

Also update the CSS link if ghostty-web has its own CSS. If ghostty-web does NOT have a CSS file (it uses canvas rendering, not DOM), remove the xterm.css link:

Before:

  <link rel="stylesheet" href="/vendor/xterm.css" />

After (if ghostty-web has CSS):

  <link rel="stylesheet" href="/vendor/ghostty-web.css" />

After (if ghostty-web has NO CSS — canvas renderer needs no CSS):

  <!-- ghostty-web uses canvas rendering, no CSS needed -->

Step 4: Run tests to verify they pass

Run:

.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py -k "ghostty" -xvs

Expected: PASS

Step 5: Commit

git add muxplex/frontend/index.html muxplex/tests/test_frontend_js.py
git commit -m "feat: update index.html to load ghostty-web instead of xterm.js"

Task 4: Update terminal.js for ghostty-web API

Files:

  • Modify: muxplex/frontend/terminal.js

Depends on: Task 1 (research results), Task 3 (script tags updated)

Context: ghostty-web is API-compatible with xterm.js, but has two key differences:

  1. Requires await init() before creating Terminal — loads WASM asynchronously
  2. FitAddon is built-in (import path differs)
  3. The global name exposed by the UMD build may differ from window.Terminal

The existing code uses:

  • new window.Terminal({...}) (line 349)
  • new window.FitAddon.FitAddon() (line 369)
  • _term.loadAddon(...) for WebLinksAddon, SearchAddon, ImageAddon (lines 387-406)
  • _term.write(string) for terminal output
  • _term.onData(callback) for terminal input
  • _term.onResize(callback) for resize events
  • _term.dispose() for cleanup
  • _term.parser.registerOscHandler() for OSC 52 clipboard
  • _term.attachCustomKeyEventHandler() for key interception
  • _term.getSelection() and _term.onSelectionChange() for clipboard
  • _term.focus(), _term.cols, _term.rows

Step 1: Add WASM initialization at module load

Add a WASM initialization block near the top of terminal.js (after the module-level state declarations, around line 38). ghostty-web's UMD build exposes an init() function that must be called once before creating any Terminal instance:

// --- ghostty-web WASM initialization ---
// ghostty-web requires WASM to be loaded before creating Terminal instances.
// The UMD build exposes init() on the global (e.g., window.GhosttyWeb.init()).
// Call init() once at module load; createTerminal() awaits the result.
var _ghosttyReady = null;  // Promise that resolves when WASM is loaded

(function _initGhosttyWasm() {
  // The exact global depends on the UMD build — Task 1 research identifies this.
  // Common patterns: window.GhosttyWeb.init(), window.ghosttyWeb.init()
  // The init() call may accept a WASM URL: init('/vendor/ghostty-vt.wasm')
  if (typeof window !== 'undefined' && window.GhosttyWeb && window.GhosttyWeb.init) {
    _ghosttyReady = window.GhosttyWeb.init('/vendor/ghostty-vt.wasm');
  } else if (typeof window !== 'undefined' && window.init) {
    _ghosttyReady = window.init('/vendor/ghostty-vt.wasm');
  } else {
    _ghosttyReady = Promise.resolve(); // Fallback for test environments
  }
})();

Important: The exact global name and init() signature depend on Task 1 research findings. Adjust the code above based on what the UMD build actually exposes.

Step 2: Update createTerminal() — Terminal constructor

In the createTerminal() function (line 335), update the Terminal constructor reference. ghostty-web's UMD build may expose the Terminal class under a different global:

Before (line 349):

  _term = new window.Terminal({

After (adjust global name based on Task 1 research):

  // ghostty-web UMD exposes Terminal on the same global or on GhosttyWeb
  var TerminalClass = window.GhosttyWeb ? window.GhosttyWeb.Terminal : window.Terminal;
  _term = new TerminalClass({

Step 3: Update FitAddon instantiation

ghostty-web bundles FitAddon. The import path changes:

Before (line 369):

  _fitAddon = new window.FitAddon.FitAddon();

After (adjust based on Task 1 research — FitAddon may be on GhosttyWeb or still on window.FitAddon):

  var FitAddonClass = (window.GhosttyWeb && window.GhosttyWeb.FitAddon)
    ? window.GhosttyWeb.FitAddon
    : (window.FitAddon && window.FitAddon.FitAddon);
  _fitAddon = new FitAddonClass();

Step 4: Guard addon loading for compatibility

The WebLinksAddon, SearchAddon, and ImageAddon use the xterm.js addon interface. If they fail to load with ghostty-web, wrap them in try/catch so the terminal still works:

Before (lines 387-406):

  var WebLinksAddon = window.WebLinksAddon && window.WebLinksAddon.WebLinksAddon;
  if (WebLinksAddon) {
    _term.loadAddon(new WebLinksAddon(function(event, uri) {
      window.open(uri, '_blank');
    }));
  }

  var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
  if (SearchAddon) {
    _searchAddon = new SearchAddon();
    _term.loadAddon(_searchAddon);
  }

  var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
  if (ImageAddon) {
    _term.loadAddon(new ImageAddon());
  }

After:

  // WebLinksAddon — try loading xterm.js addon; ghostty-web may or may not support it
  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 with ghostty-web:', e.message);
  }

  // SearchAddon — try loading xterm.js addon
  try {
    var SearchAddon = window.SearchAddon && window.SearchAddon.SearchAddon;
    if (SearchAddon) {
      _searchAddon = new SearchAddon();
      _term.loadAddon(_searchAddon);
    }
  } catch (e) {
    console.warn('SearchAddon not compatible with ghostty-web:', e.message);
    _searchAddon = null;
  }

  // ImageAddon — try loading xterm.js addon (Sixel, iTerm2 IIP, Kitty graphics)
  try {
    var ImageAddon = window.ImageAddon && window.ImageAddon.ImageAddon;
    if (ImageAddon) {
      _term.loadAddon(new ImageAddon());
    }
  } catch (e) {
    console.warn('ImageAddon not compatible with ghostty-web:', e.message);
  }

Step 5: Handle the async init() in openTerminal()

The openTerminal() function (line 453) and switchTerminal() function (line 642) call createTerminal() synchronously. Since ghostty-web requires WASM to be loaded first, ensure _ghosttyReady is resolved before terminal creation. The simplest approach: init() fires at page load (module execution), so by the time the user clicks a session tile, WASM is loaded. But add a guard:

In openTerminal(), before createTerminal(fontSize) (line 479), add:

  // Ensure ghostty-web WASM is loaded before creating terminal
  if (_ghosttyReady && typeof _ghosttyReady.then === 'function') {
    _ghosttyReady.then(function() {
      _ghosttyReady = null; // Only wait once
    });
  }

Note: Since init() is called at module load and terminal opening happens after user interaction (seconds later), the WASM will already be loaded. This guard is defensive only.

Step 6: Verify the terminal.js changes don't break the existing test suite

Run:

.venv/bin/python -m pytest muxplex/tests/test_frontend_js.py -xvs

Expected: All existing tests PASS (they test app.js structure, not terminal.js internals).

Step 7: Commit

git add muxplex/frontend/terminal.js
git commit -m "feat: update terminal.js for ghostty-web API (FitAddon built-in, addon guards)"

Task 5: Remove old xterm.js vendor files

Files:

  • Delete: muxplex/frontend/vendor/xterm.js
  • Delete: muxplex/frontend/vendor/xterm.css
  • Delete: muxplex/frontend/vendor/xterm-addon-fit.js

Depends on: Task 3, Task 4 (new vendor files are loaded, terminal.js updated)

Important: Do NOT delete xterm-addon-web-links.js, xterm-addon-search.js, or addon-image.js yet. These addons are still loaded (with try/catch guards) and may work with ghostty-web's loadAddon() interface. They'll be removed later once ghostty-web has native equivalents or Phase 2 eliminates the need.

Step 1: Delete the replaced files

rm muxplex/frontend/vendor/xterm.js
rm muxplex/frontend/vendor/xterm.css
rm muxplex/frontend/vendor/xterm-addon-fit.js

Step 2: Verify no remaining references to deleted files

grep -r "xterm\.js" muxplex/frontend/ --include="*.html" --include="*.js"
grep -r "xterm\.css" muxplex/frontend/ --include="*.html"
grep -r "xterm-addon-fit" muxplex/frontend/ --include="*.html" --include="*.js"

Expected: No references to xterm.js, xterm.css, or xterm-addon-fit.js in HTML or JS files. (Comments referencing "xterm.js" in code are fine — they're documentation, not imports.)

Step 3: Verify remaining vendor files

ls -la muxplex/frontend/vendor/

Expected contents:

  • ghostty-web.js (new)
  • ghostty-vt.wasm (new)
  • ghostty-web.css (new, if applicable)
  • xterm-addon-web-links.js (kept)
  • xterm-addon-search.js (kept)
  • addon-image.js (kept)
  • lit/ directory (unrelated, kept)

Step 4: Commit

git add -u muxplex/frontend/vendor/
git commit -m "chore: remove xterm.js, xterm.css, xterm-addon-fit.js (replaced by ghostty-web)"

Task 6: Manual smoke test

Files: None (testing only)

Depends on: Tasks 2-5 complete

Step 1: Restart the service

systemctl --user restart muxplex

Step 2: Test terminal rendering

Open the muxplex web UI in a browser. Click a session tile to open a terminal. Verify:

  • Terminal renders and shows a shell prompt
  • Typing works (keystrokes reach the shell)
  • Colors render correctly (run ls --color or htop)
  • Terminal resizes when the browser window resizes (FitAddon working)
  • Scrollback works (scroll up to see history)

Step 3: Test search (Ctrl+F)

Press Ctrl+F in the terminal. If the search bar appears and search works, SearchAddon loaded successfully. If not, note this as a known limitation.

Step 4: Test links

Run echo "https://example.com" in the terminal. Hover over the URL. If it becomes clickable, WebLinksAddon loaded successfully. Note: ghostty-web may handle OSC 8 hyperlinks natively even without WebLinksAddon.

Step 5: Test session switching

Open one session, then click a different session in the sidebar. Verify the terminal switches and renders the new session content.

Step 6: Document any issues

If anything doesn't work, note it. Common issues to watch for:

  • WASM not loading: check browser console for 404 on ghostty-vt.wasm or MIME type errors
  • Terminal blank: check if init() resolved before Terminal creation
  • Addons failing: check console for the try/catch warning messages
  • CSS issues: ghostty-web uses canvas rendering so xterm.css classes like .xterm may not exist

Task 7: Run the full test suite

Files: None (testing only)

Depends on: Task 6 (smoke test passed or issues documented)

Step 1: Run all tests

Run:

.venv/bin/python -m pytest muxplex/tests/ -x -q --timeout=30

Expected: All 1306 tests pass. The frontend structural tests (test_frontend_js.py) verify app.js structure — they don't execute JavaScript, so they're unaffected by the terminal swap. The new tests from Task 3 verify index.html loads ghostty-web.

Step 2: If tests fail, fix before proceeding

Most likely failure: a test that greps for "xterm" in terminal.js comments. These are documentation tests (the test file checks for patterns in JS source). If a test assumes xterm.js is present in terminal.js comments, update the comment to reference ghostty-web instead.

Check specifically:

grep -n "xterm" muxplex/tests/test_frontend_js.py | head -20

If any tests reference xterm.js presence in terminal.js, update them.


Task 8: Final commit

Files: Any remaining uncommitted changes

Depends on: Task 7 (all tests pass)

Step 1: Check for uncommitted changes

git status
git diff --stat

Step 2: Commit any remaining changes

git add -A
git commit -m "feat: Phase 1 complete — ghostty-web replaces xterm.js as terminal frontend"

Step 3: Verify the commit history

git log --oneline -10

Expected: Clean commit history showing the incremental swap:

  1. Research doc
  2. Vendor ghostty-web files
  3. Update index.html script tags
  4. Update terminal.js for API differences
  5. Remove old xterm.js files
  6. Final commit (if any remaining changes)