web/mux.js
Ref: Size: 82.0 KiB History
// `mux web` shell (M-web Task 8). Thin by construction: every decision that
// can live in Zig lives in mux_core.wasm; this file is glue — WebSocket
// pump, canvas painter, event translation.
//
// THE ONE DISCIPLINE: never cache a view of wasm memory. Any call can
// grow linear memory, and growth detaches every ArrayBuffer view. Every
// access below re-reads exports.memory.buffer. (Spike-measured: caching
// gives silent garbage or a TypeError, and not immediately.)
'use strict';
// Wire bytes (protocol.zig MsgType / webhub.zig envelope).
const MSG = {
attach: 0x01, input: 0x02, resize: 0x03, detach: 0x04, fetch_scrollback: 0x05,
selection_req: 0x0b,
snapshot: 0x95, exit_status: 0x82, scrollback_chunk: 0x97, delta: 0x96,
pty_mode: 0x88, term_modes: 0x8d, term_event: 0x8f, selection_reply: 0x90,
};
const CLIENT_ACTION = {
ignored: 0, terminalModes: 1, clipboard: 2, bell: 3, selection: 4,
};
const ENV_FRAME = 0x00, ENV_CONTROL = 0x01;
// OSC 52's Pc for the system clipboard, the only target this page can honour.
const CLIPBOARD_TARGET_C = 0x63; // 'c'
// input.Key by @intFromEnum (wasm_core.zig pins the table).
const KEY = {
char: 0, Enter: 1, Tab: 2, Backspace: 3, Escape: 4,
ArrowUp: 5, ArrowDown: 6, ArrowLeft: 7, ArrowRight: 8,
Home: 9, End: 10, Insert: 11, Delete: 12, PageUp: 13, PageDown: 14,
F1: 15, F2: 16, F3: 17, F4: 18, F5: 19, F6: 20, F7: 21, F8: 22,
F9: 23, F10: 24, F11: 25, F12: 26,
};
// Pastes chunk at 32 KiB so no browser→hub message approaches the hub's
// 64 KiB inbound bound (webhub.zig ws_buffer_len).
const PASTE_CHUNK = 32 * 1024;
// A missing history reply must not permanently lock wheel/drag movement.
// Two seconds tolerates an ordinary remote round trip without letting the
// 120ms drag interval flood retries while one request is still outstanding.
const SCROLL_REQUEST_TIMEOUT_MS = 2000;
// Extraction may format substantially more data than a viewport fetch. Five
// seconds bounds old-daemon/no-reply behavior without treating normal remote
// extraction of a large selection as unavailable too eagerly.
const SELECTION_REQUEST_TIMEOUT_MS = 5000;
// The reconnect schedule, and it is NOT a new one: this is client.zig's
// nextBackoffMs (src/client/client.zig — "The reconnect pacing, M7's numbers")
// spelled in JS because the page has no Zig. Iteration zero waits not at
// all, then 200ms doubling to a 2s cap, and no retry cap ever. The hub
// paces its daemon leg by that function; the browser leg pacing itself
// differently would make one flapping link behave two ways depending on
// which half broke. Change both or neither.
const nextBackoffMs = (prev) => (prev === 0 ? 200 : Math.min(prev * 2, 2000));
// The states a tile reaches ON ITS OWN and does not leave on link news
// alone (see setStatus). Everything else in the badge vocabulary —
// connecting, up, reconnecting, gone — is narration ABOUT the link,
// from this socket's own lifecycle or from the hub's control messages.
const TERMINAL = new Set(['stuck', 'exited', 'refused']);
// How many straight failures to OPEN before a tile reads 'gone' rather
// than 'reconnecting'. Four is where the schedule hits its cap
// (0+200+400+800 = 1.4s of trying), which is long enough that restarting
// the hub under a live page never flashes 'gone'.
const GONE_AFTER_FAILURES = 4;
// --- settings: the page's theme and font ---
//
// Both belong to the browser alone. Colour never reaches either VT engine:
// a cell carries a palette INDEX and colorOf invents the hex at paint
// time, so a theme is repainted, never sent. Font is the exception that
// talks back — it decides how many cells fit the zoom box, which is a
// resize like any other, and only a zoomed tile is entitled to make it.
const SETTINGS_KEY = 'mux.settings.v1';
const DEFAULT_FONT_FAMILY = 'ui-monospace, monospace';
const DEFAULT_FONT_SIZE = 14;
// A cell must stay measurable: at size 0 the zoom box divides by a
// zero-width cell, zoomCols is Infinity, and setUint16 hands the daemon a
// resize to no columns at all.
const FONT_SIZE_MIN = 6, FONT_SIZE_MAX = 72;
// A theme is a couple of dozen short lines. The cap is what stops a
// dropped PNG or a log file from being read into memory to find that out.
const THEME_BYTES_MAX = 1 << 16;
const ANSI_DEFAULT = [
'#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5',
'#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff',
];
const BUILTIN_FG = '#c8ccd4', BUILTIN_BG = '#000000', BUILTIN_SELECTION = '#6ab0e0';
// The 240 colours above the ANSI 16 are an xterm formula, not a theme's to
// set: a theme that redefined one would leave the cube incoherent with the
// sixteen it is derived alongside. Themes name 0-15; we compute the rest.
function buildPalette(base16) {
const p = base16.slice();
const lv = [0, 95, 135, 175, 215, 255];
for (let r = 0; r < 6; r++) for (let g = 0; g < 6; g++) for (let b = 0; b < 6; b++)
p.push(`rgb(${lv[r]},${lv[g]},${lv[b]})`);
for (let i = 0; i < 24; i++) { const v = 8 + i * 10; p.push(`rgb(${v},${v},${v})`); }
return p;
}
let FONT = `${DEFAULT_FONT_SIZE}px ${DEFAULT_FONT_FAMILY}`;
let DEFAULT_FG = BUILTIN_FG, DEFAULT_BG = BUILTIN_BG;
let PALETTE = buildPalette(ANSI_DEFAULT);
// The overlays stay translucent so the glyph underneath survives them:
// they are a theme colour plus an alpha byte, not colours of their own.
let CURSOR_FILL = `${BUILTIN_FG}88`, SELECTION_FILL = `${BUILTIN_SELECTION}55`;
function colorOf(packed, dflt) {
const kind = packed >>> 24;
if (kind === 1) return PALETTE[packed & 0xff];
if (kind === 2) return `rgb(${(packed >> 16) & 0xff},${(packed >> 8) & 0xff},${packed & 0xff})`;
return dflt;
}
// The shared Zig core has already authenticated OSC 52's target, size,
// and base64 alphabet. The browser adds its one platform constraint here:
// the decoded clipboard must be valid UTF-8 text.
function clipboardText(base64Bytes) {
try {
let ascii = '';
for (const byte of base64Bytes) ascii += String.fromCharCode(byte);
const binary = atob(ascii);
const decoded = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) decoded[i] = binary.charCodeAt(i);
return new TextDecoder('utf-8', { fatal: true }).decode(decoded);
} catch (_) {
return null;
}
}
// ghostty accepts #rrggbb, #rgb, and bare rrggbb. Anything else — a CSS
// colour name, a truncated hex, a line of a PNG — is not a colour here:
// refusing it is what lets "0 colours found" mean "not a theme file".
function normalizeHex(raw) {
const s = raw.trim().replace(/^#/, '').toLowerCase();
if (/^[0-9a-f]{3}$/.test(s)) return `#${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}`;
if (/^[0-9a-f]{6}$/.test(s)) return `#${s}`;
return null;
}
const THEME_KEYS = {
background: 'bg',
foreground: 'fg',
'cursor-color': 'cursor',
'selection-background': 'selection',
};
// A ghostty theme file is `key = value` lines with `#` comments. The one
// trap is that a palette line's VALUE contains an `=` of its own
// (`palette = 0=#45475a`), so every split here takes the FIRST one only —
// which is also what stops `keybind = ctrl+a=new_tab` in a whole config
// from being read as anything at all.
//
// Returns null when nothing was recognized: a file that is not a theme
// must be refused, never applied as an empty one.
function parseGhosttyTheme(text) {
const theme = { palette: new Array(16).fill(null), fg: null, bg: null, cursor: null, selection: null };
let applied = 0, ignored = 0;
for (const line of text.replace(/^\uFEFF/, '').split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq < 0) { ignored++; continue; }
const key = trimmed.slice(0, eq).trim();
const value = trimmed.slice(eq + 1).trim();
if (key === 'palette') {
const inner = value.indexOf('=');
const index = inner < 0 ? NaN : Number(value.slice(0, inner).trim());
const hex = inner < 0 ? null : normalizeHex(value.slice(inner + 1));
if (!Number.isInteger(index) || index < 0 || index > 15 || !hex) { ignored++; continue; }
theme.palette[index] = hex; // duplicates: last wins, as ghostty reads them
applied++;
continue;
}
const slot = THEME_KEYS[key];
const hex = slot ? normalizeHex(value) : null;
// cursor-text and selection-foreground land here: a translucent
// overlay cannot honour a text colour, so naming one is not applying
// it and the count must say so.
if (!slot || !hex) { ignored++; continue; }
theme[slot] = hex;
applied++;
}
return applied === 0 ? null : { theme, applied, ignored };
}
function clampFontSize(size) {
const n = Number(size);
if (!Number.isFinite(n) || n <= 0) return DEFAULT_FONT_SIZE;
return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, Math.round(n)));
}
// One cell's font metrics, measured against the real font. Re-measured
// whenever the font changes, because every geometry below — the grid a
// zoomed tile claims, the canvas size, the cell under the pointer —
// divides by this.
function measureMetrics(font) {
const c = document.createElement('canvas').getContext('2d');
c.font = font;
const m = c.measureText('M');
// Fallbacks for browsers without fontBoundingBox*: proportions of the
// em, since the 11/3 they replace only ever matched 14px.
const px = parseFloat(font) || DEFAULT_FONT_SIZE;
const ascent = Math.ceil(m.fontBoundingBoxAscent || px * 0.79);
const descent = Math.ceil(m.fontBoundingBoxDescent || px * 0.21);
// Floored at one pixel: a zero-width cell is how a resize to zero
// columns reaches the daemon.
return { w: Math.max(1, Math.ceil(m.width)), h: Math.max(1, ascent + descent), ascent };
}
let METRICS = measureMetrics(FONT);
// Chrome with cookies blocked throws on ACCESSING localStorage, not just
// on write, and verify.js's shell has none at all — so every touch goes
// through here and a browser that says no costs us the setting, not boot.
function settingsStorage() {
try { return window.localStorage || null; } catch (_) { return null; }
}
function defaultSettings() {
return { theme: null, fontFamily: null, fontSize: DEFAULT_FONT_SIZE };
}
function loadSettings() {
// No try around the read: settingsStorage is the one place that knows
// storage can refuse, and a second catch here would make its guard
// untestable — both would have to be removed before anything failed.
const raw = settingsStorage()?.getItem(SETTINGS_KEY) ?? null;
if (!raw) return defaultSettings();
try {
const stored = JSON.parse(raw);
// Version-gated rather than merged: a shape from a build that spelled
// these differently is not worth guessing at.
if (!stored || stored.v !== 1) return defaultSettings();
return { ...defaultSettings(), ...stored };
} catch (_) {
return defaultSettings();
}
}
function saveSettings(settings) {
try {
settingsStorage()?.setItem(SETTINGS_KEY, JSON.stringify({ ...settings, v: 1 }));
} catch (_) {
// Quota, private mode, or a browser that refuses: the setting is
// still live on this page, it just will not outlive the tab.
}
}
// The one place the render globals move. Every tile repaints from what
// its core already holds — a theme sends nothing — and the zoomed tile
// re-claims the grid, because the cell it fits its box with just changed.
function applySettings(settings) {
const theme = settings.theme;
FONT = `${clampFontSize(settings.fontSize)}px ${settings.fontFamily || DEFAULT_FONT_FAMILY}`;
METRICS = measureMetrics(FONT);
PALETTE = buildPalette(ANSI_DEFAULT.map((builtin, i) => theme?.palette[i] || builtin));
DEFAULT_FG = theme?.fg || BUILTIN_FG;
DEFAULT_BG = theme?.bg || BUILTIN_BG;
CURSOR_FILL = `${theme?.cursor || DEFAULT_FG}88`;
SELECTION_FILL = `${theme?.selection || BUILTIN_SELECTION}55`;
// The canvas element's own background is terminal ground: it is what
// shows before the first frame lands and through the sub-pixel edge
// sizeCanvas's rounding leaves. Hardcoded black flashed through a light
// theme, so the CSS reads it from here.
document.documentElement?.style?.setProperty('--term-bg', DEFAULT_BG);
for (const tile of tilesById.values()) tile.repaintForSettings();
zoomedTile?.sendResizeIfDiffers();
}
// The page's live settings. Boot replaces this with what was persisted;
// the panel edits it through updateSettings so no caller can apply a
// change without saving it, or save one without applying it.
let settings = defaultSettings();
function updateSettings(patch) {
settings = { ...settings, ...patch };
applySettings(settings);
saveSettings(settings);
}
let compiledCore = null; // one compile, one instance per tile
class Tile {
constructor(id, label, wallEl, session) {
// The hub's tile id, stable for the run and NOT a position: the wall is
// reorderable, so an index would name a different tile after one drag.
this.id = id;
this.label = label;
this.dead = false; // shutdown ran: this tile reconnects no more
this.reconnectTimer = null;
// The daemon session this tile attaches to, '' for the default. It is
// the hub's `TARGET#NAME` suffix, handed over by /tiles rather than dug
// back out of the label — see sendAttach for what happens to it.
this.session = session || '';
this.zoomed = false;
this.scrollPages = 0;
this.gotState = false; // JS mirror of the CLI's state_since_attach
this.wsBackoffMs = 0; // browser-leg reconnect, client.zig's schedule
this.wsFailures = 0; // straight failures to OPEN, for the badge
this.wsOpened = false; // did THIS socket ever open?
this.replayFailures = 0; // straight failures to APPLY a frame
this.replayBackoffMs = 0;
this.replayDead = false; // give up: send nothing further
this.drawScale = 0; // logical→CSS factor the backing store is sized for
// The badge's source of truth. The DOM renders this; nothing reads
// it back (see renderBadge).
this.status = 'connecting';
this.statusText = 'connecting';
this.pendingClipboard = null;
this.clipboardVersion = 0;
this.clipboardWriteActive = false;
this.copyUiOwner = null; // null, 'clipboard', or 'selection'
this.selectionCopyVersion = 0;
this.viewStartRow = 0;
this.scrollRequest = null; // {fromPages, start, count}; at most one in flight
this.scrollRequestTimer = null;
this.selection = null; // {anchor, active, requestId, text}
this.nextSelectionId = 0;
this.selectionRequestTimer = null;
this.selectionRequestVersion = 0;
this.activeSelectionRequest = null; // {selection, id, generation}
this.selectionFeedbackTimer = null;
this.selectionFeedbackVersion = 0;
this.drag = null;
this.lastPointerX = null;
this.lastPointerY = null;
this.selectionScrollTimer = null;
this.el = document.createElement('div');
this.el.className = 'tile';
// The id lives on the node too: reorder reads the wall's order back out
// of the DOM, and the node is what a drag moves.
this.el.dataset.tileId = String(id);
this.el.innerHTML =
`<header><span class="label"></span><button class="copy-request" type="button">Copy</button><button class="spawn" type="button" title="new session here">+</button><span class="badge connecting">connecting</span></header>`;
// The label only: tiles come and go as sessions do, so a baked-in
// number would lie the moment anything ended.
this.el.querySelector('.label').textContent = label;
// "New session here": the wall's one door. There is nothing to type —
// the tile already names the daemon, and the daemon names the session.
this.el.querySelector('.spawn').addEventListener('click', (ev) => {
ev.stopPropagation(); // a spawn is not a zoom
spawnHere(this.id);
// Same reason the copy control does it: clicking a button takes
// focus, and the hidden IME is what a zoomed tile's keys come from.
ime.focus();
});
this.copyButton = this.el.querySelector('.copy-request');
this.copyButton.addEventListener('click', (ev) => {
ev.stopPropagation();
if (this.copyUiOwner === 'clipboard') this.copyPendingClipboard();
else if (this.copyUiOwner === 'selection') this.copySelection();
ime.focus();
});
this.canvas = document.createElement('canvas');
this.el.appendChild(this.canvas);
this.ctx = this.canvas.getContext('2d');
wallEl.appendChild(this.el);
this.el.addEventListener('click', (ev) => {
if (!this.zoomed) { ev.stopPropagation(); zoom(this); }
});
this.el.addEventListener('wheel', (ev) => {
if (this.zoomed) { ev.preventDefault(); this.onWheel(ev); }
// Wall tiles don't scroll (spec): the event falls through to the page.
}, { passive: false });
// Drag to reorder. The whole tile is the handle: the header is a thin
// strip and a wall tile has no other inert surface to grab.
this.canvas.addEventListener('pointerdown', (ev) => this.beginSelection(ev));
this.canvas.addEventListener('pointermove', (ev) => this.moveSelection(ev));
this.canvas.addEventListener('pointerup', (ev) => this.endSelection(ev));
this.canvas.addEventListener('pointercancel', (ev) => {
if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection();
});
this.canvas.addEventListener('lostpointercapture', (ev) => {
if (this.drag && ev.pointerId === this.drag.pointerId) this.clearSelection();
});
}
async start() {
// NOT `const { instance }`: WebAssembly.instantiate has two return
// shapes, and which one you get depends on what you passed. Given
// BYTES it resolves to {module, instance}; given an already-compiled
// Module — which is what compiledCore is — it resolves to the
// Instance itself. Destructuring `instance` off an Instance yields
// undefined, and the TypeError lands inside an async method whose
// rejection nobody was reading. (verify.js passes bytes, so it takes
// the other shape and is right to destructure.)
const instance = await WebAssembly.instantiate(compiledCore, {});
this.core = instance.exports;
if (this.core.mux_init(80, 24) !== 0) { this.setStatus('gone', 'init failed'); return; }
this.sizeCanvas();
this.connect();
}
// The browser leg's own reconnect. The hub reconnects its daemon leg
// and narrates it; nothing was reconnecting THIS socket, so a restarted
// hub (or a laptop that slept) left every tile permanently 'gone' with
// a reload as the only cure. The wasm core survives across this, so the
// re-attach on `up` still quotes have_seq/have_epoch and resumes.
connect() {
this.wsOpened = false;
this.ws = new WebSocket(`ws://${location.host}/ws/${this.id}`);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.cancelScrollRequest();
this.clearSelection();
this.wsOpened = true;
this.wsBackoffMs = 0; // a socket that opened earns a fresh schedule
this.wsFailures = 0;
// A genuinely new socket is a genuinely new chance: the hub may
// have been restarted onto a different session, so the frame this
// tile choked on may simply not exist any more, and the session
// that refused may exist by now. Without this a tile that gave up
// once would stay dead until the page reloaded.
// The hub narrates from here on: connecting → up.
this.revive('connecting', 'connecting');
};
this.ws.onmessage = (ev) => this.onMessage(new Uint8Array(ev.data));
// onerror always precedes onclose; the badge is decided in one place.
this.ws.onerror = () => {};
this.ws.onclose = () => {
// FIRST: a shut-down tile's socket closes BECAUSE it was shut down.
// Reconnecting it would resurrect a tile the wall no longer has, and
// a status change would paint a node already out of the document.
if (this.dead) return;
this.cancelScrollRequest();
this.clearSelection();
this.wsFailures = this.wsOpened ? 0 : this.wsFailures + 1;
const dead = this.wsFailures >= GONE_AFTER_FAILURES;
this.setStatus(dead ? 'gone' : 'reconnecting', dead ? 'gone' : 'reconnecting');
const wait = this.wsBackoffMs;
this.wsBackoffMs = nextBackoffMs(this.wsBackoffMs);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connect();
}, wait);
};
}
// The tile is gone from the wall (the hub said so). Everything this tile
// owns that outlives its DOM node — the socket, the pending reconnect —
// is cut here, because a timer that fires later would dial a `/ws/<id>`
// the hub answers with a plain 404 and narrate it onto a detached node.
shutdown() {
this.dead = true;
if (zoomedTile === this) unzoom(); // never leave the shade over nothing
if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
this.cancelScrollRequest();
this.clearSelection(false);
this.ws?.close();
this.el.remove();
}
// Every recovery from a bad frame answers with a re-attach, and the
// daemon answers THAT with the same snapshot that just failed — so
// "recover and retry" is a flood unless it is bounded. Bounded on the
// machinery the socket's own reconnect already uses: the same schedule,
// the same ceiling, and then it stops for good. A tile stuck on one
// frame is a bad tile; a tile hammering the hub is a bad wall.
//
// `terminal` is what the badge says when it gives up — the cap case
// and the core-refused case fail for different reasons and are worth
// telling apart when someone comes to read it.
replayFailed(why, terminal) {
if (this.replayDead) return;
this.replayFailures++;
console.warn(`mux tile ${this.id}: ${why} (replay failure ${this.replayFailures})`);
if (this.replayFailures >= GONE_AFTER_FAILURES) {
this.replayDead = true; // sendAttach is gated on this
this.setStatus('stuck', terminal);
return;
}
const wait = this.replayBackoffMs;
this.replayBackoffMs = nextBackoffMs(this.replayBackoffMs);
setTimeout(() => this.sendAttach(true), wait);
}
// A frame that applies cleanly is the recovery every terminal state was
// waiting for — the session shrank and its snapshot fits again, the
// attach that was refused was admitted by the time we re-attached — so
// it revives unconditionally. This is the clearing the old code spelled
// twice (stuck→up here, refused→up at the call site) and still missed once.
replaySucceeded() { this.revive('up', 'up'); }
// The core is unusable — re-init and re-attach from nothing. Everything
// it held is gone, so the attach quotes (0,0) and the daemon answers
// with a snapshot.
resetCore(why) {
this.clearSelection(false);
this.cancelScrollRequest();
this.scrollPages = 0;
if (this.core.mux_init(80, 24) !== 0) {
this.clearBackingCanvas();
this.replayDead = true;
this.setStatus('stuck', 'core failed');
return;
}
this.gotState = false;
this.renderBadge(); // scrollPages changed; the mask must lift with it
this.drawScale = 0; // force the backing store to be re-sized
this.reflow(); // paint only after mux_init made the replacement core valid
this.replayFailed(`re-initialized the core after ${why}`, 'replay failed');
}
// --- wasm memory access, always through fresh views ---
mem() { return new Uint8Array(this.core.memory.buffer); }
stage(bytes) {
if (bytes.length > this.core.mux_input_cap()) return false;
this.mem().set(bytes, this.core.mux_input_ptr());
return true;
}
outBytes() {
const ptr = this.core.mux_output_ptr(), len = this.core.mux_output_len();
return this.mem().slice(ptr, ptr + len);
}
// --- wire out ---
sendFrame(type, payload) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return false;
const msg = new Uint8Array(6 + payload.length);
msg[0] = ENV_FRAME;
msg[1] = type;
new DataView(msg.buffer).setUint32(2, payload.length, true);
msg.set(payload, 6);
try {
this.ws.send(msg);
return true;
} catch (_) {
return false;
}
}
sendAttach(fresh) {
// THE PASSIVITY CONTRACT: an unzoomed wall tile attaches at 0x0, which
// is how `mux a` and an argv-only CLI wall view (`mux wall SPELLING...`)
// spell the same thing — no size claim at all. Every CLI tile that
// CREATES claims its own rect instead, and has done since panes
// landed. The daemon answers a unicast snapshot carrying the true
// grid and the slot stays 0x0 forever, so this tile can never move the
// shared session. Only the zoomed tile claims its real size.
//
// 0x0 rather than a small size on purpose: 1x1 is a size a client on a
// tiny terminal genuinely sends, so it cannot mean "no claim" without
// also meaning "this client wants 1x1" (server.zig resolveSession).
// ONE gate for every attach, wherever it comes from — the `up`
// control message included. A tile that gave up on replaying must
// not be talked back into asking for the same frame again.
// A shut-down tile has no wall to talk for: replayFailed's retry may
// already be scheduled, and would otherwise repaint a detached node.
if (this.dead) return;
// A session the user ENDED stays ended — wallview's pump ends on an
// exit_status and leaves the tile dead, and this is that ruling on
// the page's side of the wire. Not decoration: a ZOOMED tile attaches
// at its real size below, and a sized attach CREATES (server.zig
// resolveSession is attach-or-create for any usable size), so without
// this typing `exit` in a zoomed tile spawns a fresh shell. Unzoomed
// tiles were always safe by the passivity contract; the zoom is what
// carries the size.
//
// Here rather than at the callers, because there are three and the
// dangerous one is the least obvious: replayFailed's retry is a bare
// setTimeout that nothing cancels, so a replay failure followed
// within its wait by the shell exiting lands a sized attach on a tile
// already badged exited. Beside `dead` rather than beside
// `replayDead` so the clears below are skipped: a dead tile's last
// screen is still worth selecting text off, and a hub that redials
// every few seconds would otherwise wipe the selection each time.
//
// Exactly 'exited', not the whole TERMINAL set. 'refused' MUST keep
// re-attaching — the hub's restore heal is refused → it births the
// session → re-dial → `up` → this attach — and 'stuck' is what
// `replayDead` below already answers. The latch is cleared by
// ws.onopen's revive: a browser-leg reconnect (reload, hub restart)
// is a new epoch and may recreate, which is the deliberate escape.
if (this.status === 'exited') return;
this.cancelScrollRequest();
this.clearSelection();
if (this.replayDead) return;
const cols = this.zoomed ? this.zoomCols() : 0;
const rows = this.zoomed ? this.zoomRows() : 0;
const n = this.core.mux_attach_payload(cols, rows, fresh ? 1 : 0);
if (n > 0) {
// The session name is transport dressing, appended AFTER the payload
// the replica built for itself — which is why wasm_core knows nothing
// about it. Everything past the attach's fixed 20 bytes is the name
// (protocol.encodeAttachNamed does exactly this append), so a tile
// with no session sends exactly the bytes it sent before M18: the
// empty name IS the default session, on the wire and here.
// outBytes() already copies out of wasm memory, so the join below is
// reading a buffer nothing else can move under it.
let payload = this.outBytes();
if (this.session) {
const name = new TextEncoder().encode(this.session);
const joined = new Uint8Array(payload.length + name.length);
joined.set(payload); joined.set(name, payload.length);
payload = joined;
}
this.gotState = false; this.sendFrame(MSG.attach, payload);
}
}
sendKey(keyId, cp, mods) {
this.clearSelection();
const n = this.core.mux_key_encode(keyId, cp, mods);
if (n > 0) this.sendFrame(MSG.input, this.outBytes());
}
// Plain UTF-8 bytes, no wrap. This is TYPING: an IME's compositionend
// hands over finished text, and finished text is what the user typed,
// however they reached it. Bracketing it would announce a paste to the
// application — vim would skip paste mode's indentation, a shell's
// bracketed-paste guard would refuse to run it.
sendText(text) {
this.clearSelection();
const bytes = new TextEncoder().encode(text);
for (let off = 0; off < bytes.length; off += PASTE_CHUNK) {
const chunk = bytes.subarray(off, off + PASTE_CHUNK);
if (!this.stage(chunk)) return;
if (this.core.mux_text_encode(chunk.length) > 0)
this.sendFrame(MSG.input, this.outBytes());
}
}
// At most ONE wrap around the WHOLE paste, however many messages it
// takes: optional begin, N unwrapped chunks, optional end. The WASM
// semantic core owns the sampled mode-2004 policy; the shell sends only
// the marker frames it returns.
sendPaste(text) {
if (this.core.mux_paste_begin() > 0) this.sendFrame(MSG.input, this.outBytes());
// Closed even when a chunk fails to STAGE — that is the case this
// buys, and it is worth buying: a short paste beats an application
// left in paste mode.
//
// It buys nothing against the socket dying mid-paste. sendFrame drops
// silently when the WebSocket is not OPEN, so the end marker never
// transits and the application sits in paste mode with bytes it will
// treat as pasted. Reconnecting cannot clear that: the state is on
// the far side of the pty, and nothing this client can send is
// distinguishable from more paste. Untreated in v1.
try {
this.sendText(text);
} finally {
if (this.core.mux_paste_end() > 0) this.sendFrame(MSG.input, this.outBytes());
}
}
sendResizeIfDiffers() {
// Only the zoomed tile, and only when its computed size actually
// differs from the session's grid (spec: passive wall, latest-wins
// respected).
if (!this.zoomed) return;
const cols = this.zoomCols(), rows = this.zoomRows();
if (cols === this.core.mux_cols() && rows === this.core.mux_rows()) return;
// A scrollback reply encodes the grid row count sampled by its request.
// Never apply those bytes after changing the grid they describe.
this.cancelScrollRequest();
const p = new Uint8Array(4);
const dv = new DataView(p.buffer);
dv.setUint16(0, cols, true);
dv.setUint16(2, rows, true);
this.sendFrame(MSG.resize, p);
}
// The zoomed tile's OWN box, not the window: index.html insets it
// (3vh 4vw) so a ring of shade stays visible and clickable, and that
// ring is the only way out of zoom. Measuring the element rather than
// re-deriving the inset here means the CSS stays the single source of
// the geometry — change the inset and the grid follows.
zoomBox() {
const r = this.el.getBoundingClientRect();
const hdr = this.el.querySelector('header').getBoundingClientRect().height;
// border-box: the rect includes the 1px border on each side.
return { w: Math.max(0, r.width - 2), h: Math.max(0, r.height - hdr - 2) };
}
zoomCols() { return Math.max(2, Math.floor(this.zoomBox().w / METRICS.w)); }
zoomRows() { return Math.max(2, Math.floor(this.zoomBox().h / METRICS.h)); }
// --- wire in ---
onMessage(bytes) {
if (bytes.length < 1) return;
if (bytes[0] === ENV_CONTROL) {
const { state } = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
// The hub speaks about the DAEMON leg, which is not the whole of
// this tile: setStatus withholds the badge while the tile holds a
// terminal state of its own. The bookkeeping below runs regardless
// — sendAttach has its own gates (dead, exited, replayDead) and
// keeps them.
if (state !== 'up') {
this.cancelScrollRequest();
this.clearSelection();
}
this.setStatus(state, state);
if (state === 'up') this.sendAttach(false); // browser owns re-attach
return;
}
if (bytes[0] !== ENV_FRAME || bytes.length < 6) return;
const type = bytes[1];
const len = new DataView(bytes.buffer, bytes.byteOffset).getUint32(2, true);
if (6 + len !== bytes.length) return;
const payload = bytes.subarray(6);
switch (type) {
case MSG.snapshot:
case MSG.delta: {
if (type === MSG.snapshot) {
// A snapshot can replace the grid dimensions. History scratch and
// retained coordinates name the old geometry, so leave history
// without painting it; the successful apply below owns the one
// repaint from the new live snapshot.
this.clearSelection(false);
this.cancelScrollRequest();
this.scrollPages = 0;
this.renderBadge();
}
// replica.zig's pinned subtlety, mirrored: a DELTA's arrival alone
// proves the attach was admitted — decodable or not — while a
// short snapshot proves nothing. Without this an undecodable
// delta followed by exit_status read as a refusal instead of as
// the shell exiting.
if (type === MSG.delta) this.gotState = true;
if (!this.stage(payload)) {
// Over the core's 256 KiB staging cap. Silently returning left
// the replica permanently behind the session; a re-attach at
// (0,0) costs one snapshot and is correct — but the snapshot it
// asks for is the one that just failed, so it is bounded.
this.replayFailed(
`frame of ${payload.length} B over the staging cap`, 'frame too big');
return;
}
const r = this.core.mux_apply_frame(type, payload.length);
if (r === 0) {
this.gotState = true;
this.replaySucceeded();
if (this.scrollPages === 0) this.paintLive();
return;
}
if (r === 1) { this.sendAttach(true); return; } // RESYNC: quote (0,0)
// Negative is the core refusing: -1 uninitialized or readout
// buffers left stale by a failed grid move (wasm_core.zig
// documents that one as fatal-re-init), -2 over the cap, -3 a
// payload it will not replay. None of them leave a replica that
// can be trusted to keep painting.
this.resetCore(`mux_apply_frame(0x${type.toString(16)}) = ${r}`);
return;
}
case MSG.exit_status: {
this.cancelScrollRequest();
this.clearSelection();
// Before any state this is the daemon refusing the attach — the
// CLI's own discriminator, mirrored, and its own word for it. WHY
// it refused is the daemon's business and it does not say; a
// guess ("session full") was wrong more often than not.
if (!this.gotState) this.setStatus('refused', 'refused');
else this.setStatus('exited', `exited ${payload[0] ?? 0}`);
return;
}
case MSG.scrollback_chunk: {
if (this.scrollPages === 0) return;
if (payload.length < 6) {
if (this.scrollRequest) this.scrollRequestFailed(this.scrollRequest);
return;
}
const echoed = new DataView(payload.buffer, payload.byteOffset, 6);
const start = echoed.getUint32(0, true);
const count = echoed.getUint16(4, true);
// A viewport becomes addressable by pointer coordinates only after
// the exact request for it decoded and painted. Delayed older wheel
// replies must never relabel the cells currently on the canvas.
const request = this.scrollRequest;
if (!request || start !== request.start || count !== request.count) return;
// The WHOLE chunk, echoed header included: the rows are CellRows and
// only that header says how many of them there are.
if (!this.stage(payload)) { this.scrollRequestFailed(request); return; }
if (this.core.mux_scroll_feed(payload.length) === 0) {
this.clearScrollRequestTimer();
this.scrollRequest = null;
this.viewStartRow = start;
this.moveDragToCurrentPointer();
this.paintScroll();
} else this.scrollRequestFailed(request);
return;
}
case MSG.term_modes:
case MSG.term_event:
case MSG.selection_reply: {
if (!this.stage(payload)) return;
const action = this.core.mux_client_frame(type, payload.length);
if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect();
else if (action === CLIENT_ACTION.selection) this.onSelectionReply();
return;
}
case MSG.pty_mode: // no prediction in the web client (spec non-goal)
default:
return;
}
}
onClipboardEffect() {
if (!this.zoomed) return;
// The shared core accepts every Pc xterm defines, because the native
// client can honour them: `p` is PRIMARY, `s` the selection, `0`-`7`
// the cut buffers. A browser has one destination and no way to route
// the others, so writing them all to the system clipboard would let an
// ordinary X11 mouse drag inside the session — which emits
// `ESC]52;p;…` — silently clobber what the human last copied on their
// own machine. Non-`c` targets stay valid on the wire and are simply
// not this tile's business.
if (this.core.mux_clipboard_target() !== CLIPBOARD_TARGET_C) return;
// The semantic core's clipboard payload borrows the staging buffer.
// Copy it before calling or awaiting anything: either can move WASM
// memory or let the next frame replace the borrowed bytes.
const ptr = this.core.mux_clipboard_ptr();
const len = this.core.mux_clipboard_len();
const encoded = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len);
const text = clipboardText(encoded);
if (text === null) return;
this.clearSelectionFeedback();
const version = ++this.clipboardVersion;
this.pendingClipboard = text;
this.renderCopyUi('clipboard', 'copy-request', 'Copy');
return this.tryClipboardWrite(version, true);
}
renderCopyUi(owner, className, text) {
this.copyUiOwner = owner;
this.copyButton.className = className;
this.copyButton.textContent = text;
}
// `.copy-request` is display:none without `.on`, and hiding a focused
// element drops focus to <body>. The hidden IME is what receives
// compositionend, so a tile that lost focus that way accepts no IME input
// at all until the user clicks — and nothing on screen says why. The
// paths that need this are the ones nobody asked for: the timers that
// retire feedback under a button the user tabbed to (mux.js's Tab
// affordance) and never activated. Every user-driven exit already hands
// focus back in the click handler and is left alone.
restoreImeFocusIfHidden() {
if (document.activeElement !== this.copyButton) return;
if (this.copyButton.classList.contains('on')) return;
ime.focus();
}
releaseSelectionCopyUi() {
if (this.copyUiOwner !== 'selection') return;
if (this.pendingClipboard !== null) {
this.renderCopyUi(
'clipboard',
this.clipboardWriteActive ? 'copy-request' : 'copy-request on',
'Copy',
);
} else {
this.renderCopyUi(null, 'copy-request', 'Copy');
}
}
async tryClipboardWrite(version, automatic) {
if (this.clipboardWriteActive) return;
if (version !== this.clipboardVersion || this.pendingClipboard === null) return;
const text = this.pendingClipboard;
this.clipboardWriteActive = true;
let succeeded = false;
try {
await this.writeClipboardText(text);
succeeded = true;
} catch (_) {
// Expected platform failures are rendered below, never allowed to
// reject the promise launched by onMessage or the button handler.
}
this.clipboardWriteActive = false;
// A newer event owns the one pending slot. Serialize its write after
// this one and skip every intermediate value it already replaced.
if (version !== this.clipboardVersion || this.pendingClipboard !== text) {
if (this.zoomed && this.pendingClipboard !== null)
return this.tryClipboardWrite(this.clipboardVersion, true);
return;
}
if (!succeeded) {
if (this.copyUiOwner === 'clipboard') {
this.renderCopyUi(
'clipboard',
automatic ? 'copy-request on' : 'copy-request on error',
automatic ? 'Copy' : 'Copy failed',
);
}
return;
}
this.pendingClipboard = null;
if (this.copyUiOwner !== 'clipboard') return;
this.renderCopyUi('clipboard', 'copy-request on', 'Copied');
setTimeout(() => {
if (version !== this.clipboardVersion || this.pendingClipboard !== null ||
this.copyUiOwner !== 'clipboard') return;
this.renderCopyUi(null, 'copy-request', 'Copy');
this.restoreImeFocusIfHidden();
}, 1200);
}
copyPendingClipboard() {
if (!this.zoomed || this.copyUiOwner !== 'clipboard' || this.pendingClipboard === null) return;
return this.tryClipboardWrite(this.clipboardVersion, false);
}
async writeClipboardText(text) {
const writeText = navigator.clipboard?.writeText;
if (typeof writeText !== 'function') throw new Error('clipboard unavailable');
await writeText.call(navigator.clipboard, text);
}
async copySelection() {
const selection = this.selection;
if (!this.zoomed || !selection || selection.text === null) return;
this.clearSelectionFeedback();
// An explicit copy is the newest statement of what the human wants on
// the clipboard, so it revokes the automatic lane rather than racing
// it. A write already handed to the browser genuinely cannot be
// cancelled — but the RE-DISPATCH that write performs when it settles
// is ours to start, and starting it would put terminal text on the
// clipboard after the gesture that asked for something else. Retiring
// the version and the queued value together is what suppresses it.
this.clipboardVersion++;
this.pendingClipboard = null;
const version = ++this.selectionCopyVersion;
this.renderCopyUi('selection', 'copy-request', 'Copy');
let succeeded = false;
try {
// This call occurs synchronously before the first await, preserving
// the key/click user activation required by the Clipboard API.
// It cannot cancel an OSC52 write already handed to the browser, so
// those two calls may overlap. Versions order only our UI; the browser
// owns external clipboard completion ordering for the active calls.
await this.writeClipboardText(selection.text);
succeeded = true;
} catch (_) {
// Clipboard refusal is visible browser UI, never an unhandled event
// promise rejection.
}
if (!this.zoomed || this.selection !== selection ||
version !== this.selectionCopyVersion || this.copyUiOwner !== 'selection') return;
if (!succeeded) {
this.renderCopyUi('selection', 'copy-request on error', 'Copy failed');
return;
}
this.renderCopyUi('selection', 'copy-request on', 'Copied');
setTimeout(() => {
if (!this.zoomed || this.selection !== selection ||
version !== this.selectionCopyVersion || this.copyUiOwner !== 'selection') return;
this.releaseSelectionCopyUi();
this.restoreImeFocusIfHidden();
}, 1200);
}
onSelectionReply() {
const active = this.activeSelectionRequest;
if (!this.selection || !active || active.selection !== this.selection ||
active.id !== this.selection.requestId ||
active.generation !== this.selectionRequestVersion) return;
// A Zig u32 result reaches JavaScript as a signed WASM i32.
if ((this.core.mux_selection_id() >>> 0) !== active.id) return;
this.invalidateSelectionRequest();
if (this.core.mux_selection_status() !== 0) {
this.selection.text = null;
this.showSelectionUnavailable();
return;
}
// Ordinary output RAISES the daemon's history without renaming a single
// row — new rows are appended below the ones already there. Only a page
// eviction lowers it, and that renumbers every absolute row the request
// named, so a reply from below the sampled base is text belonging to
// lines the user never highlighted. Discarding it is the same outcome
// as any other unusable reply, so it takes the same path rather than
// inventing a second kind of failure.
if ((this.core.mux_selection_history_rows() >>> 0) < active.historyBase) {
this.selection.text = null;
this.showSelectionUnavailable();
return;
}
const ptr = this.core.mux_selection_ptr();
const len = this.core.mux_selection_len();
// Selection text borrows the staging buffer. Snapshot it before any
// other WASM call or asynchronous clipboard operation can replace it.
const bytes = new Uint8Array(this.core.memory.buffer).slice(ptr, ptr + len);
try {
this.selection.text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
this.clearSelectionFeedback();
this.releaseSelectionCopyUi();
} catch (_) {
this.selection.text = null;
this.showSelectionUnavailable();
}
}
// --- retained selection ---
cellAtPointer(ev, clampY = true) {
const rect = this.canvas.getBoundingClientRect();
const cols = this.core.mux_cols(), rows = this.core.mux_rows();
const x = Math.max(0, Math.min(cols - 1,
Math.floor((ev.clientX - rect.left) / this.drawScale / METRICS.w)));
let y = Math.floor((ev.clientY - rect.top) / this.drawScale / METRICS.h);
if (clampY) y = Math.max(0, Math.min(rows - 1, y));
return { col: x, viewRow: y, row: this.viewStartRow + y };
}
orderedSelection() {
if (!this.selection) return null;
const a = this.selection.anchor, b = this.selection.active;
if (a.row < b.row || (a.row === b.row && a.col <= b.col)) return [a, b];
return [b, a];
}
clearSelection(repaint = true) {
const hadSelection = this.selection !== null;
this.invalidateSelectionRequest();
this.clearSelectionFeedback();
if (hadSelection) this.selectionCopyVersion++;
this.selection = null;
this.stopSelectionDrag();
this.releaseSelectionCopyUi();
if (repaint && hadSelection && this.core) this.reflow();
}
// Nulling the handle and bumping the generation are ONE fact, not two:
// nothing revokes a selection request without doing both, in this one
// place. That makes the handle-identity comparisons downstream subsume
// the generation comparisons beside them — several of those conditions
// cannot fire on their own. They are deliberate belt-and-braces against a
// future second revoker that forgets half the act, and are not the
// invariant themselves. Do not "simplify" one away in isolation: the
// pairing here is what would have to change first.
invalidateSelectionRequest() {
if (this.selectionRequestTimer !== null) clearTimeout(this.selectionRequestTimer);
this.selectionRequestTimer = null;
this.activeSelectionRequest = null;
this.selectionRequestVersion++;
}
activateSelectionRequest(selection) {
this.invalidateSelectionRequest();
const request = {
selection,
id: selection.requestId,
generation: this.selectionRequestVersion,
// The absolute rows in `selection` are counted from the oldest
// RETAINED history row, so they only name lines while that row keeps
// its identity. Sampled here, which is the last instant before the
// request becomes authoritative, and compared against the daemon's
// own reading in onSelectionReply.
historyBase: this.core.mux_history_rows() >>> 0,
};
this.activeSelectionRequest = request;
return request;
}
// The two timers below compare setTimeout HANDLES by identity. Per the
// HTML spec an id is unique only among timers that are still active, so a
// handle can legitimately be reused once its predecessor has fired or
// been cleared. That is why the version fields exist beside it rather
// than instead of it: identity is the cheap first check, and the
// monotonic generation is what actually settles which request is meant.
startSelectionRequestTimeout(request) {
const selection = request.selection;
const timer = setTimeout(() => {
if (this.selectionRequestTimer !== timer ||
this.activeSelectionRequest !== request ||
this.selectionRequestVersion !== request.generation ||
this.selection !== selection || selection.requestId !== request.id) return;
this.invalidateSelectionRequest();
selection.text = null;
this.showSelectionUnavailable();
}, SELECTION_REQUEST_TIMEOUT_MS);
this.selectionRequestTimer = timer;
}
clearSelectionFeedback() {
if (this.selectionFeedbackTimer !== null) clearTimeout(this.selectionFeedbackTimer);
this.selectionFeedbackTimer = null;
this.selectionFeedbackVersion++;
}
showSelectionUnavailable() {
const selection = this.selection;
if (!selection) return;
this.clearSelectionFeedback();
const version = this.selectionFeedbackVersion;
this.renderCopyUi('selection', 'copy-request on error', 'Selection unavailable');
const timer = setTimeout(() => {
if (this.selectionFeedbackTimer !== timer) return;
this.selectionFeedbackTimer = null;
if (this.selectionFeedbackVersion !== version ||
this.selection !== selection || this.copyUiOwner !== 'selection') return;
this.selectionFeedbackVersion++;
this.releaseSelectionCopyUi();
this.restoreImeFocusIfHidden();
}, 1200);
this.selectionFeedbackTimer = timer;
}
stopSelectionDrag() {
const pointerId = this.drag?.pointerId;
this.drag = null;
this.lastPointerX = null;
this.lastPointerY = null;
if (this.selectionScrollTimer !== null) clearInterval(this.selectionScrollTimer);
this.selectionScrollTimer = null;
// Clear drag state before release: releasePointerCapture queues a
// lostpointercapture event, which must not erase a completed selection.
if (pointerId !== undefined && this.canvas.hasPointerCapture(pointerId))
this.canvas.releasePointerCapture(pointerId);
}
beginSelection(ev) {
if (!this.zoomed || ev.button !== 0) return;
ev.preventDefault();
const point = this.cellAtPointer(ev);
this.clearSelection(false);
this.selection = { anchor: point, active: point, requestId: 0, text: null };
this.drag = { pointerId: ev.pointerId, moved: false };
this.lastPointerX = ev.clientX;
this.lastPointerY = ev.clientY;
this.canvas.setPointerCapture(ev.pointerId);
this.selectionScrollTimer = setInterval(() => this.autoScrollSelection(), 120);
this.reflow();
}
moveSelection(ev) {
if (!this.drag || ev.pointerId !== this.drag.pointerId) return;
ev.preventDefault();
this.lastPointerX = ev.clientX;
this.lastPointerY = ev.clientY;
const point = this.cellAtPointer(ev);
const endpointChanged = point.row !== this.selection.active.row ||
point.col !== this.selection.active.col;
if (endpointChanged) this.drag.moved = true;
this.selection.active = point;
this.reflow();
}
endSelection(ev) {
if (!this.drag || ev.pointerId !== this.drag.pointerId) return;
ev.preventDefault();
this.lastPointerX = ev.clientX;
this.lastPointerY = ev.clientY;
const point = this.cellAtPointer(ev);
const endpointChanged = point.row !== this.selection.active.row ||
point.col !== this.selection.active.col;
if (endpointChanged) this.drag.moved = true;
this.selection.active = point;
const moved = this.drag.moved;
this.stopSelectionDrag();
if (!moved) { this.clearSelection(); return; }
this.nextSelectionId = (this.nextSelectionId + 1) >>> 0;
this.selection.requestId = this.nextSelectionId;
const a = this.selection.anchor, b = this.selection.active;
const n = this.core.mux_selection_request(
this.nextSelectionId, a.row, a.col, b.row, b.col,
);
if (n > 0) {
const selection = this.selection;
// Establish reply authority before send: a host is allowed to deliver
// a matching semantic reply synchronously from its send hook.
const request = this.activateSelectionRequest(selection);
if (!this.sendFrame(MSG.selection_req, this.outBytes())) {
this.clearSelection();
return;
}
if (endpointChanged) this.reflow();
if (this.activeSelectionRequest === request)
this.startSelectionRequestTimeout(request);
}
else this.clearSelection();
}
pointerOutsideDirection() {
if (!this.drag || this.lastPointerY === null) return 0;
const rect = this.canvas.getBoundingClientRect();
return this.lastPointerY < rect.top ? 1 :
(this.lastPointerY >= rect.bottom ? -1 : 0);
}
moveDragToCurrentPointer() {
if (!this.drag || !this.selection ||
this.lastPointerX === null || this.lastPointerY === null) return false;
const point = this.cellAtPointer({
clientX: this.lastPointerX,
clientY: this.lastPointerY,
});
if (point.row !== this.selection.active.row || point.col !== this.selection.active.col)
this.drag.moved = true;
this.selection.active = point;
return true;
}
autoScrollSelection() {
const direction = this.pointerOutsideDirection();
if (direction === 0 || this.scrollRequest) return;
const paintedStart = this.viewStartRow;
if (!this.changeScrollPages(direction)) return;
// Returning to the live viewport paints synchronously in exitScroll.
// History requests update the endpoint only in scrollback_chunk after
// their matching bytes have decoded and become the painted viewport.
if (!this.scrollRequest && this.viewStartRow !== paintedStart &&
this.moveDragToCurrentPointer()) this.reflow();
}
// --- painting ---
clearBackingCanvas() {
// resetCore calls this only when mux_init failed, so it must not consult
// any geometry in the invalid old core.
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.drawScale = 0;
}
// A wall tile shows a full 80+ column grid in ~420 CSS pixels, so it
// must be drawn small. Two ways to do that, and only one is legible:
// draw at full logical size into a big bitmap and let CSS squash it
// (every glyph resampled, twice over on a HiDPI screen), or draw
// THROUGH a scaled context so the glyphs rasterize once, at device
// resolution, at the size they will actually be seen. This is the
// second. Everything below paints in logical cell coordinates and the
// transform does the rest.
sizeCanvas() {
const cols = this.core.mux_cols(), rows = this.core.mux_rows();
const logicalW = cols * METRICS.w, logicalH = rows * METRICS.h;
const dpr = window.devicePixelRatio || 1;
// The width the layout gives this tile. The zoomed tile computed its
// grid FROM that box, so it lands at scale 1; a wall tile scales down.
// Never up: stretching a terminal is not a feature.
const availW = this.zoomed ? this.zoomBox().w : this.el.clientWidth;
const scale = availW > 0 ? Math.min(1, availW / logicalW) : 1;
const cssW = logicalW * scale, cssH = logicalH * scale;
// The backing store is CSS pixels × devicePixelRatio: the resolution
// the screen can actually show, and no more.
const bw = Math.max(1, Math.round(cssW * dpr));
const bh = Math.max(1, Math.round(cssH * dpr));
if (this.canvas.width !== bw || this.canvas.height !== bh || this.drawScale !== scale) {
this.canvas.width = bw;
this.canvas.height = bh;
this.canvas.style.width = `${cssW}px`;
this.canvas.style.height = `${cssH}px`;
this.drawScale = scale;
this.core.mux_mark_all_dirty(); // assigning width cleared the bitmap
}
// Re-applied every paint, not just on resize: assigning canvas.width
// resets the transform, and a paint that skipped this would draw one
// grid's worth of cells into the top-left corner of the bitmap.
this.ctx.setTransform(scale * dpr, 0, 0, scale * dpr, 0, 0);
}
// Repaint after the theme or the font moved under pixels that are
// already on the canvas. Painting straight over them is not enough: a
// wall tile draws through a scaled transform, so every cell fill lands
// on fractional device pixels and its antialiased seam keeps a blend of
// what was underneath. Repainting a light theme over a dark one that
// way leaves the old colour in a lattice along every cell boundary.
repaintForSettings() {
if (!this.core) return;
this.clearBackingCanvas();
this.reflow();
}
// The tile's CSS width moved (a window resize reflows the wall grid):
// re-fit and repaint from what the core already holds. No frame is
// requested and nothing is sent — a wall tile is passive.
reflow() {
if (!this.core) return;
this.core.mux_mark_all_dirty();
if (this.scrollPages === 0) this.paintLive();
else this.paintScroll();
}
paintLive() {
this.viewStartRow = this.core.mux_history_rows();
this.sizeCanvas();
const n = this.core.mux_read_viewport();
const paintedRows = new Set();
for (let i = 0; i < n; i++) {
const row = this.core.mux_dirty_row(i);
this.paintRow(row);
paintedRows.add(row);
}
// The overlay is translucent. Restore every selected row from the
// terminal cells before compositing it again, even when a delta dirtied
// some other row, or repeated live paints would darken the selection.
const ordered = this.orderedSelection();
if (ordered) {
const rows = this.core.mux_rows();
const first = Math.max(0, ordered[0].row - this.viewStartRow);
const last = Math.min(rows - 1, ordered[1].row - this.viewStartRow);
for (let row = first; row <= last; row++) {
if (!paintedRows.has(row)) this.paintRow(row);
}
}
this.paintCursor();
this.paintSelection();
}
paintScroll() {
this.sizeCanvas();
this.core.mux_read_scroll_viewport();
const rows = this.core.mux_rows();
for (let y = 0; y < rows; y++) this.paintRow(y);
this.paintSelection();
}
paintRow(y) {
const cols = this.core.mux_cols();
const base = this.core.mux_viewport_ptr() + y * cols * 16;
// Fresh view per row paint; see the header discipline.
const cells = new DataView(this.core.memory.buffer, base, cols * 16);
const ctx = this.ctx;
ctx.font = FONT;
ctx.textBaseline = 'alphabetic';
for (let x = 0; x < cols; x++) {
const cp = cells.getUint32(x * 16, true);
const fg = cells.getUint32(x * 16 + 4, true);
const bg = cells.getUint32(x * 16 + 8, true);
const flags = cells.getUint32(x * 16 + 12, true);
if (flags & (1 << 17)) continue; // spacer: the wide cell painted it
const wide = (flags & (1 << 16)) !== 0;
const inverse = (flags & (1 << 4)) !== 0;
let fgC = colorOf(fg, DEFAULT_FG), bgC = colorOf(bg, DEFAULT_BG);
if (inverse) { const t = fgC; fgC = bgC; bgC = t; }
const px = x * METRICS.w, py = y * METRICS.h, cw = METRICS.w * (wide ? 2 : 1);
ctx.fillStyle = bgC;
ctx.fillRect(px, py, cw, METRICS.h);
if (cp !== 0 && cp !== 32) {
const bold = (flags & 1) !== 0, faint = (flags & 4) !== 0, italic = (flags & 2) !== 0;
ctx.fillStyle = fgC;
ctx.globalAlpha = faint ? 0.6 : 1;
ctx.font = `${italic ? 'italic ' : ''}${bold ? 'bold ' : ''}${FONT}`;
ctx.fillText(String.fromCodePoint(cp), px, py + METRICS.ascent);
ctx.globalAlpha = 1;
ctx.font = FONT;
}
const ustyle = (flags >> 8) & 7;
if (ustyle !== 0) {
ctx.fillStyle = fgC;
ctx.fillRect(px, py + METRICS.h - 2, cw, ustyle === 2 ? 2 : 1); // double→thick
}
if (flags & (1 << 6)) { // strikethrough
ctx.fillStyle = fgC;
ctx.fillRect(px, py + (METRICS.h >> 1), cw, 1);
}
}
}
paintCursor() {
if (this.scrollPages !== 0) return;
const x = this.core.mux_cursor_x(), y = this.core.mux_cursor_y();
this.ctx.fillStyle = CURSOR_FILL;
this.ctx.fillRect(x * METRICS.w, y * METRICS.h, METRICS.w, METRICS.h);
}
paintSelection() {
const ordered = this.orderedSelection();
if (!ordered) return;
const [start, end] = ordered;
const cols = this.core.mux_cols(), rows = this.core.mux_rows();
this.ctx.fillStyle = SELECTION_FILL;
for (let y = 0; y < rows; y++) {
const screenRow = this.viewStartRow + y;
if (screenRow < start.row || screenRow > end.row) continue;
const first = screenRow === start.row ? start.col : 0;
const last = screenRow === end.row ? end.col : cols - 1;
if (last < first) continue;
this.ctx.fillRect(
first * METRICS.w, y * METRICS.h,
(last - first + 1) * METRICS.w, METRICS.h,
);
}
}
// --- scrollback ---
changeScrollPages(pagesUpDelta) {
if (this.scrollRequest) return false;
const rows = this.core.mux_rows();
const maxPages = Math.ceil(this.core.mux_history_rows() / rows);
const next = Math.max(0, Math.min(maxPages, this.scrollPages + pagesUpDelta));
if (next === this.scrollPages) return false;
if (next === 0) { this.exitScroll(); return true; }
const fromPages = this.scrollPages;
this.scrollPages = next;
this.renderBadge();
const start = this.core.mux_scroll_start(this.scrollPages, rows);
const request = { fromPages, start, count: rows };
this.scrollRequest = request;
const p = new Uint8Array(6);
const dv = new DataView(p.buffer);
dv.setUint32(0, start, true);
dv.setUint16(4, rows, true);
if (!this.sendFrame(MSG.fetch_scrollback, p)) {
this.scrollRequestFailed(request);
return false;
}
// WebSocket delivery is asynchronous in browsers, but keep the state
// transition correct for any host that can answer during send().
if (this.scrollRequest !== request) return true;
this.scrollRequestTimer = setTimeout(() => {
if (this.scrollRequest === request) this.scrollRequestFailed(request);
}, SCROLL_REQUEST_TIMEOUT_MS);
return true;
}
clearScrollRequestTimer() {
if (this.scrollRequestTimer !== null) clearTimeout(this.scrollRequestTimer);
this.scrollRequestTimer = null;
}
cancelScrollRequest() {
if (this.scrollRequest) this.scrollRequestFailed(this.scrollRequest);
else this.clearScrollRequestTimer();
}
scrollRequestFailed(request) {
if (this.scrollRequest !== request) return;
this.clearScrollRequestTimer();
this.scrollRequest = null;
this.scrollPages = request.fromPages;
this.renderBadge();
}
onWheel(ev) {
this.changeScrollPages(ev.deltaY < 0 ? 1 : -1);
}
exitScroll() {
this.clearScrollRequestTimer();
if (this.scrollPages === 0) { this.scrollRequest = null; return; }
this.scrollRequest = null;
this.scrollPages = 0;
this.renderBadge();
this.reflow(); // mark-all + paint live: the same repaint, one owner
}
// --- chrome ---
// THE BADGE IS A RENDERING, NOT A STATE. Reading the class back — which
// this used to do — made the DOM the state machine's memory and every
// writer a peer of every other.
//
// Two independent facts share the one badge and scroll wins while it
// lasts: a tile paged into history says so, and says whatever it was
// saying again the moment it returns to live — including a state that
// arrived while the user was reading history.
renderBadge() {
const [cls, text] = this.scrollPages > 0
? ['scroll', `history -${this.scrollPages}`]
: [this.status, this.statusText];
const b = this.el.querySelector('.badge');
b.className = `badge ${cls}`;
b.textContent = text;
}
// Link narration — this socket's lifecycle and the hub's control
// messages alike — never overwrites a state the tile reached locally
// and terminally. A `stuck` tile whose DAEMON leg re-dials is told
// 'up': painting that green while sendAttach stays gated on replayDead
// is exactly a silent tile that reads healthy. Terminal states do
// replace each other — the newest fact about a dead session is still
// the true one. The arguable exception, considered and accepted: a
// browser-leg 'gone' (the page can no longer observe the session at
// all) is also withheld while terminal. The word is then stale but the
// color is not — index.html renders all four in the same red — and
// splitting "link news that supersedes" from "link news that doesn't"
// would reintroduce the peer-writers problem this field deleted.
setStatus(cls, text) {
if (TERMINAL.has(this.status) && !TERMINAL.has(cls)) return;
this.status = cls;
this.statusText = text;
this.renderBadge();
}
// The ONE place a terminal state is left, and it clears replayDead in
// the same breath because the two are one fact: the badge says gave
// up, the flag gates every attach. Moving one without the other either
// lies (badge stuck, tile sending fine) or goes silent (badge up,
// sendAttach gated). Two callers: a frame that applied, a socket that
// opened.
revive(cls, text) {
this.replayDead = false;
this.replayFailures = 0;
this.replayBackoffMs = 0;
this.status = cls; // deliberately past setStatus's precedence
this.statusText = text;
this.renderBadge();
}
}
// --- zoom / focus ---
let zoomedTile = null;
const shade = document.getElementById('shade');
const ime = document.getElementById('ime');
function zoom(tile) {
// The click is bound in the constructor and start() is async: a click
// that lands in that window has no core to size, resize or key against.
// Gated HERE and not at each call because zoom is what makes the tile
// reachable at all — no zoom, no keys, no wheel, no paste (spec).
// `core` alone is not enough: a tile whose mux_init failed has a truthy
// core and no socket — connect() was never reached.
if (!tile.core || !tile.ws) return;
if (zoomedTile) unzoom();
// Settings belong to the wall, and the wall is what zoom covers. Closing
// the panel keeps it from sitting open and focusable behind the shade.
// It re-opens on the next unzoom (the tile is built with `open`).
settingsTileEl?.querySelector('.settings')?.removeAttribute('open');
zoomedTile = tile;
tile.zoomed = true;
tile.el.classList.add('zoomed');
shade.classList.add('on');
ime.focus();
tile.sendResizeIfDiffers();
// Re-fit now rather than at the next frame: an idle session sends
// nothing, and a tile that zoomed but kept its wall-sized canvas would
// sit there postage-stamped until something happened to be typed.
tile.reflow();
}
function unzoom() {
if (!zoomedTile) return;
const was = zoomedTile;
// Invalidate every outstanding write and feedback timer before any
// exit-scroll or wall reflow work can run.
was.clearSelection(false);
was.clipboardVersion++;
was.pendingClipboard = null;
was.renderCopyUi(null, 'copy-request', 'Copy');
was.exitScroll();
was.zoomed = false;
was.el.classList.remove('zoomed');
zoomedTile = null;
shade.classList.remove('on');
ime.blur();
// Unzoom sends NOTHING: the session stays attached, the grid stays
// where it is (spec). The tile keeps painting as a wall tile — at the
// wall's scale, which is the one thing that does have to be redone.
was.reflow();
}
// The visible ring of shade around the inset tile IS the unzoom control.
// Escape is deliberately not bound: it belongs to the application.
shade.addEventListener('click', unzoom);
// Keys go ONLY to the zoomed tile — no zoom, no bytes (spec).
document.addEventListener('keydown', (ev) => {
// The settings tile's fields are real form controls: while one has focus
// its keys are a setting being edited, not input for any terminal. The
// whole tile — zoom hides the panel behind the shade, but Tab still
// reaches it, and a key answered by both would type the user's font size
// into their shell.
if (settingsTileEl?.contains(document.activeElement)) return;
const t = zoomedTile;
if (!t) return;
if (ev.isComposing) return; // IME owns it; compositionend delivers
// A visible clipboard retry is real browser chrome inside the tile.
// Let its native Enter/Space activation and Tab navigation work rather
// than translating those keys into terminal input.
if (ev.target === t.copyButton &&
(ev.key === 'Enter' || ev.key === ' ' || ev.key === 'Spacebar' || ev.key === 'Tab')) return;
// The hidden IME normally owns focus, so plain Tab would otherwise be
// encoded for the terminal. When a retry is visible, use that one key
// to make the recovery control keyboard reachable.
if (ev.target === ime && ev.key === 'Tab' &&
!ev.shiftKey && !ev.altKey && !ev.ctrlKey && !ev.metaKey &&
t.copyButton.classList.contains('on') && (
(t.copyUiOwner === 'clipboard' && t.pendingClipboard !== null) ||
(t.copyUiOwner === 'selection' && t.selection?.text !== null &&
t.selection?.text !== undefined && t.copyButton.classList.contains('error'))
)) {
ev.preventDefault();
t.copyButton.focus();
return;
}
const lower = ev.key.toLowerCase();
const hasSelection = t.selection?.text !== null && t.selection?.text !== undefined;
// Copy is terminal-owned only when daemon-authoritative text exists.
// With no retained result, Firefox keeps Ctrl+Shift+C for Inspector and
// macOS keeps its Cmd family; plain Ctrl+C continues to shared input.
const selectionCopy = lower === 'c' && (
ev.metaKey || (ev.ctrlKey && !ev.altKey)
);
if (selectionCopy && hasSelection) {
ev.preventDefault();
t.copySelection();
return;
}
const mods = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0);
// Leave genuine browser chords alone (copy/paste arrive as events):
// Ctrl+Shift+C/V everywhere, and on macOS the WHOLE Cmd family, which
// is how a mac copies and pastes at all. Consuming Cmd+V typed a
// literal 'v' into the session and suppressed the paste event with it.
// No preventDefault and no bytes — shared input has no meta concept by
// design, so a Cmd chord the browser does not want is simply dropped.
if (ev.metaKey) return;
if (ev.ctrlKey && ev.shiftKey && (lower === 'c' || lower === 'v')) return;
if (t.scrollPages > 0 && ev.key !== 'PageUp' && ev.key !== 'PageDown') {
t.clearSelection(false);
t.exitScroll(); // any other key leaves scroll mode, swallowed (CLI rule)
ev.preventDefault();
return;
}
if (KEY[ev.key] !== undefined) {
ev.preventDefault();
t.sendKey(KEY[ev.key], 0, mods);
return;
}
if ([...ev.key].length === 1) {
ev.preventDefault();
t.sendKey(KEY.char, ev.key.codePointAt(0), mods);
}
});
document.addEventListener('paste', (ev) => {
if (!zoomedTile) return;
ev.preventDefault();
const text = ev.clipboardData?.getData('text');
if (!text) return;
// Input returns the tile to live, exactly as every keystroke path does.
// It is NOT swallowed the way a stray keystroke is: a paste is never
// stray — it carries content the user explicitly asked to send, and
// discarding it would cost them the copy as well as the page.
zoomedTile.clearSelection(false);
zoomedTile.exitScroll();
zoomedTile.sendPaste(text);
});
ime.addEventListener('compositionend', (ev) => {
// sendText, NOT sendPaste: composed text is typing (see sendText).
if (zoomedTile && ev.data) zoomedTile.sendText(ev.data);
ime.value = '';
});
// --- the wall ---
// Keyed by the hub's tile id, never by position: two entries share a label
// whenever one daemon has two sessions, and an id is never reused. The DOM
// under #wall carries the hub's order.
const tilesById = new Map();
let settingsTileEl = null; // built once in boot, always last
window.addEventListener('resize', () => {
// The zoomed tile may claim a new grid; every wall tile just re-fits to
// the width the reflowed grid gave it.
zoomedTile?.sendResizeIfDiffers();
for (const t of tilesById.values()) if (t !== zoomedTile) t.reflow();
zoomedTile?.reflow();
});
// `GET /tiles` is the truth about the wall; this makes the page match it.
// The hub polls its daemons once a second and the page asks it just as
// often, so a session born or ended ANYWHERE — another browser, a
// terminal, a `mux a` — reaches this wall without the page being told.
// There is one shape of "what the wall is" and no local guess to drift.
//
// Two refetches can be in flight at once (Enter twice quickly, a reorder
// overlapping an add) and nothing orders their responses. The last one
// STARTED is the only one allowed to reconcile: an older cfg landing last
// would shut down a tile the newer cfg still contains, and the tile would
// vanish from the wall until something else refetched. One counter, checked
// after every await that precedes a side effect.
let refetchGen = 0;
async function refetchWall() {
const gen = ++refetchGen;
const res = await fetch('/tiles');
if (gen !== refetchGen) return;
// A 500 answers in plain text, not JSON: parsing it would reject and take
// the caller's await with it. The wall simply keeps what it has.
if (!res.ok) { console.error(`mux wall: GET /tiles said ${res.status}`); return; }
const cfg = await res.json();
if (gen !== refetchGen) return;
const wallEl = document.getElementById('wall');
const liveIds = new Set(cfg.map((t) => t.id));
for (const [id, tile] of tilesById) {
if (!liveIds.has(id)) { tilesById.delete(id); tile.shutdown(); }
}
for (const t of cfg) {
let tile = tilesById.get(t.id);
if (!tile) {
// Each entry is {id, label, session}: the label is what the tile calls
// itself, the session is what it attaches to. Two entries can name the
// same host and differ only in the session — that is the whole point.
tile = new Tile(t.id, t.label, wallEl, t.session);
tilesById.set(t.id, tile);
// start() is async: instantiate or mux_init can fail, and an unhandled
// rejection left the tile stuck on 'connecting' with the reason only
// in the console's rejection noise. The settled promise is kept:
// "new session here" zooms the tile it just added, and zoom() refuses
// a tile whose core and socket start() has not installed yet.
tile.startup = tile.start().catch((err) => {
tile.setStatus('gone', 'gone');
console.error(`mux tile ${t.id} (${t.label}): start failed`, err);
});
}
// appendChild MOVES an attached node: walking cfg in order lays the
// wall out in wall order, existing sockets undisturbed.
wallEl.appendChild(tile.el);
}
if (settingsTileEl) wallEl.appendChild(settingsTileEl); // stays last
}
// How many refetches a birth may wait for its tile, and how long between
// them. The tile cannot exist when the POST returns: `Hub.spawn` stores a
// poke and answers, and the host's poller feels that poke only at its next
// 50 ms slice, then still has to dial the daemon and diff the list. Six
// unspaced fetches on a kept-alive localhost socket all finish inside
// ~10 ms, so the loop lost that race and the user got a session born and
// unzoomed — the "sits on a badge" outcome the zoom exists to prevent.
// Measured against a live hub: the tile is on `/tiles` 59 ms after the
// POST answers (Debug, so a ceiling — the dial is the only part release
// speeds up). 250 ms clears that with room for a slow daemon; 6 of them
// bound the wait at 1.5 s, after which the 1 s interval picks it up.
const SPAWN_POLLS = 6;
const SPAWN_POLL_MS = 250;
// The wait between two of those refetches. Its own function so the delay
// is a thing a test can reach: verify.js cannot run `spawnHere`'s network
// path, and a bare inline `setTimeout` would be invisible to it.
function spawnPollDelay() {
return new Promise((resolve) => setTimeout(resolve, SPAWN_POLL_MS));
}
// The `+`: a new session on THAT tile's daemon, named by the daemon. The
// tile appears through refetchWall like every other tile — one road onto
// the wall — and the zoom is what lands the user in the shell, because an
// unzoomed wall tile attaches passive at 0x0 (see sendResize).
async function spawnHere(id) {
const tile = tilesById.get(id);
try {
const res = await fetch(`/tiles/${id}`, { method: 'POST' });
if (!res.ok) {
// The hub's own words for why, on the badge of the tile that asked —
// there is no add box left to put them in.
const why = (await res.text()).trim() || `hub said ${res.status}`;
console.error(`mux spawn on tile ${id}: ${why}`);
tile?.setStatus('gone', why);
return;
}
// The hub answers {"session":"NAME"}. Parsed defensively: the session
// IS made, so a body we cannot read must not read as a failed birth.
const body = await res.json().catch(() => null);
const label = tile?.label ?? null;
for (let i = 0; i < SPAWN_POLLS && body !== null; i++) {
// BEFORE the fetch, first pass included: the hub answered this POST
// before its poller had heard about the birth, so asking now can
// only ever miss.
await spawnPollDelay();
await refetchWall();
const fresh = [...tilesById.values()].find(
(t) => t.session === body.session && t.label === label,
);
if (!fresh) continue;
await fresh.startup; // refetchWall fires start(), it does not await it
// Re-checked after the await: a poll may have retired this tile, and
// a zoom the user did meanwhile is theirs to keep.
if (tilesById.get(fresh.id) === fresh && !zoomedTile) zoom(fresh);
return;
}
} catch (err) {
console.error(`mux spawn on tile ${id}: request failed`, err);
}
await refetchWall().catch((err) => {
console.error('mux wall: refetch failed', err);
});
}
// The settings panel, wired inside the settings tile. Every change goes through
// updateSettings — no caller can apply one without saving it — and every
// answer lands in .note, including the count of lines a file did NOT give
// us, which is how "I uploaded my whole ghostty config" reads as partial
// success instead of silence.
function wireSettingsPanel(el) {
const file = el.querySelector('.theme-file');
const clear = el.querySelector('.theme-clear');
const family = el.querySelector('.font-family');
const size = el.querySelector('.font-size');
const note = el.querySelector('.note');
const say = (text, bad) => {
note.textContent = text;
note.className = bad ? 'note bad' : 'note';
};
family.value = settings.fontFamily || '';
size.value = clampFontSize(settings.fontSize);
file.addEventListener('change', async () => {
const chosen = file.files?.[0];
if (!chosen) return;
// Capped before a byte is read: no theme needs 64 KiB, and this is
// the whole defence against someone picking a video.
if (chosen.size > THEME_BYTES_MAX) {
say(`too big for a theme file (${chosen.size} bytes)`, true);
return;
}
let text = '';
try {
text = await chosen.text();
} catch (_) {
say('could not read that file', true);
return;
}
const parsed = parseGhosttyTheme(text);
if (!parsed) {
say('no colours in that file — is it a ghostty theme?', true);
return;
}
updateSettings({ theme: parsed.theme });
const skipped = parsed.ignored ? `, ${parsed.ignored} lines ignored` : '';
say(`${parsed.applied} colours applied${skipped}`, false);
});
clear.addEventListener('click', () => {
updateSettings({ theme: null });
file.value = ''; // else re-picking the same file fires no change event
say('theme cleared', false);
});
const applyFont = () => {
const wanted = clampFontSize(size.value);
size.value = wanted; // the clamp is shown, not applied behind the value
updateSettings({ fontFamily: family.value.trim() || null, fontSize: wanted });
};
family.addEventListener('change', applyFont);
// 'change', not 'input': typing 18 passes through 1, and each keystroke
// would claim a new grid for every client on the zoomed session.
size.addEventListener('change', applyFont);
}
// The settings tile: a tile-shaped panel at the end of the wall. It is NOT
// a Tile — no core, no socket, never zooms — so the zoom click path (bound
// per Tile) cannot reach it. Nothing here authors the wall: the wall is the
// hosts file, and `mux hosts add|rm` is how it changes.
function buildSettingsTile() {
const el = document.createElement('div');
el.className = 'tile settings-tile';
el.innerHTML =
`<details class="settings" open><summary>settings</summary>` +
`<div class="row"><span>theme</span>` +
`<input type="file" class="theme-file" aria-label="ghostty theme file">` +
`<button type="button" class="theme-clear">clear</button></div>` +
`<div class="row"><span>font</span>` +
`<input type="text" class="font-family" aria-label="font family"` +
` autocomplete="off" spellcheck="false" placeholder="${DEFAULT_FONT_FAMILY}">` +
`<input type="number" class="font-size" aria-label="font size"` +
` min="${FONT_SIZE_MIN}" max="${FONT_SIZE_MAX}"></div>` +
`<div class="caveat">tiles are the live sessions of the daemons in your` +
` hosts file — <code>mux hosts add HOST</code> to list one, + on a tile` +
` to start a session there; theme is this browser's alone, and font size` +
` claims a new grid, so every client on the zoomed session reflows</div>` +
`<div class="note"></div></details>`;
const settingsEl = el.querySelector('.settings');
wireSettingsPanel(settingsEl);
// Nothing above this tile should read a click meant for its own controls.
el.addEventListener('click', (ev) => ev.stopPropagation());
return el;
}
// --- boot ---
(async function boot() {
// Before any tile exists, so the first frame is painted in the user's
// colours at the user's cell size rather than repainted into them.
settings = loadSettings();
applySettings(settings);
// A second tab of the same hub is the same wall: settings follow it
// live rather than waiting for a reload. A cleared key (newValue null)
// means "back to defaults".
window.addEventListener('storage', (ev) => {
if (ev.key !== SETTINGS_KEY) return;
settings = loadSettings();
applySettings(settings);
});
compiledCore = await WebAssembly.compileStreaming(fetch('/mux_core.wasm'));
settingsTileEl = buildSettingsTile();
await refetchWall();
// The hub's own poll is a second, so asking it any faster only costs
// requests. This interval is the whole of how a session born or ended
// elsewhere reaches this page: nothing pushes.
setInterval(() => {
refetchWall().catch((err) => console.error('mux wall: refetch failed', err));
}, 1000);
})();