feat(desktop): the xterm surface, themed from the window's tokens, in a lazy bundle (terminal 5/9)

Merged
DA
5d ago
+715-1

PR 5 of 9 of the Terminal tab (docs/proposals/terminal-architecture.md §10, in #2574): the xterm surface as a lazy renderer bundle. It has no tab and no state UI; PR 6 mounts it. Dean approved the design (round 3) and the build.

Summary

A lazy bundle. terminal.js and terminal.css are built from src/renderer/terminal/ and set window.lukeTerminal, the same way file-tree.js and plan-editor.js work. contract.ts is all the panel and the bundle share. Its mount(host, { onInput, onResize, onLink }) returns { write(data, written), size, focus, unmount }. Only text, bytes and cell sizes cross it, and no xterm type does. write's written callback is what the host's flow control (ack every 5,000 chars) waits on.

xterm.js 6 (@xterm/xterm 6.0.0, pinned exactly) plus four addons:

  • fit: refits once per frame at most, and onResize fires only when the cell count changes;
  • unicode11: a CJK character or an emoji takes the two cells a shell expects;
  • web-links: ⌘-click opens a link, as in Mac terminals;
  • webgl: draws the screen.

The search addon comes with ⌘F in PR 9.

Tokens. New --terminal-* tokens in base.css, each restated in the light block:

  • the ground is --desktop-well, matching the round-3 show;
  • text and cursor are --code-text;
  • the selection is in the code's blue;
  • the 16 ANSI colours come from the same GitHub theme as --code-ground.

The canvas reads none of the stylesheet itself, so terminal-theme.ts reads the tokens' computed values off the host and reads them again when the appearance flips. Contrast is held at 4.5, as in VS Code's terminal.

Keys (terminal-keys.ts):

PressGoes to
Any ⌘ chord: ⌘K, ⌘F, ⌘W, ⇧⌘⏎, ⌘J later, ⌘C/⌘V through Edit's own itemsthe window (its keymap, then the menu bar)
⌘← / ⌘→ / ⌘⌫the shell, as ^A / ^E / ^U (start of line, end of line, delete to start), VS Code's Mac line keys
Shift+Returnthe shell, as a line feed, so a coding agent's prompt starts a new line instead of sending
Escape, ⌃ chords, everything elsethe shell. Escape no longer reaches the window's layered Escape while a terminal has focus, which is what vim needs

⌥ types the character the keyboard layout gives it (macOptionIsMeta: false). ⌥-drag selects text even while tmux owns the mouse.

CSP: unchanged (style-src 'self').

  • xterm writes three inline <style> elements inside its own element: the scrollbar's colours, and, for the DOM renderer, cell sizes and theme classes. The policy refuses them. adopted-styles.ts re-adopts their text as constructed stylesheets, which the policy admits (as for Pierre's tree in #2086), and drops them on unmount. The console still logs one refusal per element.
  • The DOM renderer also writes 24-bit colours and contrast-adjusted colours as style attributes, which the policy refuses as well. When WebGL can't be had, the surface therefore sets contrast to 1, so every palette colour reaches the screen as a class. A lost WebGL context is recreated rather than left on DOM.

Bundle budget. There's a new row: renderer/terminal.js at 133,576 gz. This is a deliberate library adoption: xterm core is about 345 kB minified and the WebGL addon about 127 kB. renderer.js is unchanged, because nothing in the panel imports the bundle.

Privacy. Bytes cross only write and onInput. Nothing here touches app state, a trace, a counted event, or the voice window. ph-no-capture goes on the tab root in PR 6, with its test.

Evidence

  • ./scripts/check.sh: exit 0 on the head commit.
  • New tests in terminal/index.test.ts, against the real xterm in jsdom, through window.lukeTerminal.mount:
    • ⌘K and ⇧⌘⏎ reach the window, and the shell gets nothing. Without the rule, xterm sends ⇧⌘⏎ as a Return and swallows it.
    • Shift+Return sends \n, and Return sends \r.
    • ⌘←, ⌘→ and ⌘⌫ send ^A, ^E and ^U, and the window never sees them, so the keymap can't read ⌘⌫ as deleting the plan.
    • Escape and ⌃C go to the shell, and Escape never reaches the window's layers.
    • The terminal draws in the tokens' colours, redraws in a changed token's colour after an appearance flip, and drops its adopted sheets on unmount.
    • Without WebGL, a colour below the contrast ratio is drawn by its palette class, never by a style attribute.
    • Each test was checked to fail with its rule removed.
  • CI could not verify the Mac. A pull request builds nothing for the Mac. ./scripts/verify.sh was not run, because this was built in a Linux cloud VM. The release rehearsal is the Mac gate.

Visual evidence

The built terminal.js and terminal.css, mounted under the panel's own CSP meta and styles.css in headless Chrome on Linux, 760×420, with canned ANSI output. These are not verify.sh captures, and the typeface is Linux's fallback, not SF Mono.

DarkLight
WebGLdarklight
DOM fallback (WebGL disabled), its rules adopteddom darkdom light

🤖 Generated with Claude Code


Summary by cubic

Adds the @xterm/xterm terminal surface as its own lazy-loaded bundle (terminal.js/terminal.css), so the bundle every window parses never carries xterm; the new bundle budget row lands at 133,576 gz. There's no tab or state UI yet — PR 6 mounts it.

The surface mounts through window.lukeTerminal.mount(host, { onInput, onResize, onLink }), returning { write, size, focus, unmount }; only text, bytes, and cell sizes cross that contract, and no xterm type does. It draws with WebGL (falling back to the DOM renderer), is themed from new --terminal-* tokens in base.css, and re-reads them when the appearance flips.

Key routing

  • Every ⌘ chord reaches the window's keymap and menu bar.
  • ⌘←, ⌘→, and ⌘⌫ go to the shell as ^A, ^E, and ^U, like Mac terminal line keys.
  • Shift+Return sends a line feed; Return sends a carriage return.
  • Escape and Control chords go to the shell, so Escape no longer reaches the window's layered escape.

CSP

  • xterm writes inline <style> elements that the panel's style-src 'self' policy refuses, so their rules are re-adopted as constructed stylesheets and dropped on unmount.
  • Without WebGL, the DOM renderer is held to palette colours, since it writes 24-bit and contrast-adjusted colours as refused style attributes; a lost WebGL context is recreated.