import { FitAddon } from "@xterm/addon-fit"; import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { attachWsUrl } from "./routes.js"; import { FOCUS_SIDEBAR_EVENT } from "./catalog.js"; import { CommandSet, defineCommands } from "./commands.js"; import { escapeHtml } from "./html.js"; import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "./reconnect.js"; import { applyModsToChar, cursorSeq, tildeSeq } from "./keys.js"; import { requestToggleDrawer } from "./drawer.js"; import { splitCommand, toolIconSvg } from "./toolIcon.js"; import type { ClientControl, Uuid } from "./types.js"; import { WebglPolicy } from "./webglRenderer.js"; // The pane's half of the command registry (#148): plain Escape hands the // keyboard back to the session list, the pair to the list's Tab. const paneKeys = new CommandSet( defineCommands({ "pane.focusList": () => document.dispatchEvent(new CustomEvent(FOCUS_SIDEBAR_EVENT)), }), { preventDefault: false }, ); /** One budget for the whole page — see webglRenderer.ts for why contexts are * rationed rather than handed to every connection. */ const webglPolicy = new WebglPolicy(); /** * A single live attach: an xterm terminal bound to the daemon's attach WebSocket. * * The connection is **decoupled from the UI**: it owns a long-lived WS that * keeps streaming PTY output into the terminal buffer whether or not the view * is currently mounted. The shell moves `el` between the visible content pane * and a hidden cache; `onMount`/`onUnmount` only adjust focus + sizing, never * the connection. Only `dispose()` (eviction, an explicit disconnect, or the * session ending) tears the socket down — so switching away and back is * instant, with scrollback already in place. */ export interface AttachConnection { readonly sessionId: Uuid; /** The `.attach` element (header + terminal body) to mount into a pane. */ readonly el: HTMLElement; /** Becomes the visible view: fit to the pane, focus, sync size. * `focus: false` mounts without taking the keyboard — what a sidebar * preview needs, so j/k keeps working while the pane is on screen. */ onMount(opts?: { focus?: boolean }): void; /** Parked into the cache: keep streaming, just stop driving focus/resize. */ onUnmount(): void; /** Put the keyboard back into this terminal — what leaving the sidebar does. */ focusTerminal(): void; /** Type raw bytes straight into the PTY, bypassing the terminal's own key * handling — the leader's literal Ctrl-K escape hatch * (docs/COMMAND-BAR.md). */ sendInput(data: string): void; /** Fully disconnect: close the WS, dispose the terminal, remove the element. */ dispose(): void; } export interface AttachOptions { /** Invoked when the user clicks the view's explicit "disconnect" control. */ onRequestClose?: (sessionId: Uuid) => void; /** Invoked when the socket closes on its own — the session ended or the link * dropped — but NOT on an intentional `dispose()` (eviction / user close). The * shell uses this to auto-close the pane rather than leave a dead one. */ onClosed?: (sessionId: Uuid) => void; /** Asked on every close: should the socket be retried? The shell answers * from the catalog — see `shouldRetryClose`. */ shouldReconnect?: (sessionId: Uuid, code: number) => boolean; } /** What the header shows about the session (name / command). Optional — falls * back to a short session id when the session isn't known yet. */ export interface AttachInfo { name: string; command: string; } /** Header echoes the sidebar: the program becomes its tool icon when we have a * mark for it and stays as its bare name when we do not. Arguments are * dropped, as on the card. */ function commandFragment(command: string): string { const { tool, program } = splitCommand(command); const icon = tool ? toolIconSvg(tool) : null; const text = icon ? "" : program; return `${icon ?? ""}${text ? escapeHtml(text) : ""}`; } export function createAttachConnection( sessionId: Uuid, opts: AttachOptions = {}, info?: AttachInfo, ): AttachConnection { const el = document.createElement("div"); el.className = "attach"; // Useful identity over a bare UUID: name · command. The full id stays in the // tooltip for when you really need it. const titleHtml = info ? `${escapeHtml(info.name)}` + (info.command && info.command !== info.name ? commandFragment(info.command) : "") : `session ${escapeHtml(sessionId.slice(0, 8))}`; const titleAttr = info ? `${info.name} · ${info.command} (${sessionId})` : sessionId; el.innerHTML = `
${titleHtml} connecting…
`; const body = el.querySelector(".attach-body") as HTMLDivElement; const stateEl = el.querySelector(".attach-state") as HTMLSpanElement; const disconnectBtn = el.querySelector(".disconnect") as HTMLButtonElement; const keybar = buildKeyBar((data) => sendInput(data)); el.appendChild(keybar.el); const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => { stateEl.textContent = s; stateEl.dataset.state = kind; }; // Mobile-only (CSS hides it on desktop, where the sidebar is always in view): // the session list is an off-canvas drawer and this is what opens it. (el.querySelector(".sessions-btn") as HTMLElement).addEventListener("click", () => requestToggleDrawer(), ); disconnectBtn.addEventListener("click", () => { opts.onRequestClose?.(sessionId); }); const term = new Terminal({ cursorBlink: true, fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', fontSize: 13, convertEol: false, theme: { // The terminal stays dark in both app themes (convention); colors are // tuned to the maestro palette — signal-green cursor, slate background. background: "#121419", foreground: "#e6e8ec", cursor: "#5fe3a1", cursorAccent: "#121419", selectionBackground: "#2b6b4f", }, scrollback: 5000, allowProposedApi: true, }); const fit = new FitAddon(); term.loadAddon(fit); term.open(body); // GPU rendering is scoped to the mounted view, not the connection: acquired // in onMount, released in onUnmount. See webglRenderer.ts. let webgl: WebglAddon | null = null; let dprMedia: MediaQueryList | null = null; // The glyph atlas is rasterised for one device pixel ratio. Dragging the // window to a display with a different one (or an Android browser-zoom // change) leaves every cell blurry until the atlas is rebuilt. const onDprChange = () => { try { term.clearTextureAtlas(); } catch { // ignore — nothing to rebuild if the renderer already went away } watchDpr(); }; function watchDpr(): void { dprMedia?.removeEventListener("change", onDprChange); dprMedia = null; try { dprMedia = window.matchMedia?.(`(resolution: ${window.devicePixelRatio}dppx)`) ?? null; dprMedia?.addEventListener("change", onDprChange); } catch { // ignore — a browser without `resolution` media queries just keeps the // atlas it has, which is the pre-WebGL behaviour anyway } } function releaseWebgl(): void { dprMedia?.removeEventListener("change", onDprChange); dprMedia = null; if (!webgl) return; const addon = webgl; webgl = null; try { addon.dispose(); // xterm falls back to the DOM renderer } catch { // ignore — a lost context can throw on the way out } } function acquireWebgl(): void { if (webgl || disposed || !webglPolicy.enabled()) return; try { const addon = new WebglAddon(); // Context loss is routine on Android — backgrounding the tab is enough. // Drop back to the DOM renderer, then retry only while the shared budget // allows and this view is still the one on screen. addon.onContextLoss(() => { webglPolicy.noteContextLoss(); releaseWebgl(); if (mounted) acquireWebgl(); }); term.loadAddon(addon); webgl = addon; watchDpr(); } catch { // Context creation failed outright (no GPU, or the browser is already at // its context cap). Count it like a loss so we stop asking, and stay on // the DOM renderer. webglPolicy.noteContextLoss(); webgl = null; } } // Plain Escape hands the keyboard back to the session list instead of the // shell/app. `false` tells xterm to skip its own handling of this event — // which is also why the set doesn't `preventDefault`: swallowing here is // xterm's return value, not the browser's. term.attachCustomKeyEventHandler((e) => { if (e.type !== "keydown") return true; return !paneKeys.handle(e); }); // Touch scrolling: xterm's built-in touch handling is jittery (drags crawl a // line at a time and stall), so map finger drags ourselves. Two routes: // • normal buffer → term.scrollLines walks the scrollback. // • mouse-tracking apps (tmux, Claude Code on the alt screen) → synthesize // wheel events so xterm encodes them as mouse-wheel reports the app reads, // scrolling *its* content (the only scroll that exists on the alt screen). // • alt screen with no mouse tracking → nothing to do; the app owns input. // `touch-action: none` (CSS) keeps the browser from cancelling the custom // gesture stream; only the terminal surface opts out of native page panning. // Listeners MUST sit on `.xterm`, not `.xterm-viewport`: `.xterm-screen` is a // later sibling of the viewport, so it paints over it and takes every touch. { const root = term.element as HTMLElement | undefined; const screen = el.querySelector(".xterm-screen") as HTMLElement | null; const cellH = () => { const core = (term as unknown as { _core?: { _renderService?: { dimensions?: { css?: { cell?: { height: number } } } } }; })._core; return core?._renderService?.dimensions?.css?.cell?.height || 16; }; if (root) { let lastY = 0; let lastX = 0; let remainder = 0; root.addEventListener( "touchstart", (e) => { if (e.touches.length !== 1) return; lastY = e.touches[0].clientY; lastX = e.touches[0].clientX; remainder = 0; }, { passive: true }, ); root.addEventListener( "touchmove", (e) => { if (e.touches.length !== 1) return; const ch = cellH(); const y = e.touches[0].clientY; const px = lastY - y + remainder; // finger up → scroll toward newer lastY = y; const lines = Math.trunc(px / ch); remainder = px - lines * ch; if (lines === 0) return; // term.modes.mouseTrackingMode is "none" unless the app turned mouse // reporting on — that's the signal to forward instead of scrollback. const mouseTracked = term.modes.mouseTrackingMode !== "none"; if (mouseTracked && screen) { // One wheel event per crossed cell, dispatched at the touch point so // xterm computes valid cell coords for the mouse report. const dir = lines > 0 ? 1 : -1; for (let i = 0; i < Math.abs(lines); i++) { screen.dispatchEvent( new WheelEvent("wheel", { deltaY: dir * ch, deltaMode: 0, clientX: lastX, clientY: y, bubbles: true, cancelable: true, }), ); } e.preventDefault(); } else if (term.buffer.active.type !== "alternate") { term.scrollLines(lines); e.preventDefault(); } }, { passive: false }, ); } } // Mobile: the special-key bar is only useful while the soft keyboard is up, // so reveal it only when the terminal's hidden textarea is focused (CSS still // gates it to coarse pointers). Tapping a keycap keeps focus via pointerdown // preventDefault, so the bar stays put during use. Re-fit on the toggle since // showing/hiding the bar changes the terminal's available height. const ta = term.textarea; if (ta) { ta.addEventListener("focus", () => { keybar.el.classList.add("keybar-active"); requestAnimationFrame(tryFit); }); ta.addEventListener("blur", () => { keybar.el.classList.remove("keybar-active"); requestAnimationFrame(tryFit); }); } let mounted = false; // A previewed pane is visible but must not steal the keyboard from the // sidebar, so mounting can opt out of the focus grab (and out of the one // the socket does when it finishes connecting). let focusOnMount = true; // We compute the fit ourselves instead of FitAddon.fit(): the addon subtracts // a scrollbar width that xterm reports as `offsetWidth - scrollArea || 15`, // so with the scrollbar hidden it falls back to a phantom 15px and the grid // stops ~1–2 columns short of the edge. Same math, minus that subtraction. const tryFit = () => { if (!mounted) return; // parked views have no meaningful size to fit to try { const el2 = term.element; const parent = el2?.parentElement; const core = (term as unknown as { _core?: { _renderService?: { dimensions?: { css?: { cell?: { width: number; height: number } } } } }; })._core; const cell = core?._renderService?.dimensions?.css?.cell; if (!el2 || !parent || !cell || !cell.width || !cell.height) { fit.fit(); // fall back if internals aren't ready yet return; } // Measure the viewport's real inner width (clientWidth excludes any // scrollbar), not the container's — so columns always fit inside the // viewport whether or not xterm reserves a scrollbar. Falls back to the // padded container box before the viewport exists. const vp = el2.querySelector(".xterm-viewport") as HTMLElement | null; let availW: number; let availH: number; if (vp && vp.clientWidth > 0) { availW = vp.clientWidth; availH = vp.clientHeight; } else { const ps = getComputedStyle(parent); const ts = getComputedStyle(el2); availW = parseInt(ps.width) - (parseInt(ts.paddingLeft) + parseInt(ts.paddingRight) || 0); availH = parseInt(ps.height) - (parseInt(ts.paddingTop) + parseInt(ts.paddingBottom) || 0); } const cols = Math.max(2, Math.floor(availW / cell.width)); const rows = Math.max(1, Math.floor(availH / cell.height)); if (cols !== term.cols || rows !== term.rows) term.resize(cols, rows); } catch { try { fit.fit(); } catch { // ignore — fit fails if the container is detached or zero-sized } } }; let ws: WebSocket; let reconnectMs = INITIAL_RECONNECT_MS; let reconnectTimer: number | null = null; const sendControl = (msg: ClientControl) => { if (ws.readyState !== WebSocket.OPEN) return; ws.send(JSON.stringify(msg)); }; /** PTY input: raw bytes out as a binary frame. Shared by keystrokes and the * mobile key bar so both flow through the same path. */ const sendInput = (data: string) => { if (ws.readyState !== WebSocket.OPEN) return; ws.send(new TextEncoder().encode(data)); }; const onResize = () => { tryFit(); sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); }; function connect(): void { ws = new WebSocket(attachWsUrl(sessionId)); ws.binaryType = "arraybuffer"; ws.onopen = () => { reconnectMs = INITIAL_RECONNECT_MS; setState("connected", "open"); sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); if (mounted && focusOnMount) term.focus(); }; ws.onmessage = (ev) => { if (typeof ev.data === "string") { try { JSON.parse(ev.data) as ClientControl; } catch { // ignore unknown control frames } return; } if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); } else if (ev.data instanceof Blob) { // Some browsers may default to Blob despite binaryType="arraybuffer". ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b))); } }; ws.onerror = () => { setState("error", "error"); }; ws.onclose = (ev) => { // The web proxy relays a daemon rejection (unknown session) as an // application close code (4000+) carrying a human reason. Show it in the // pane — both the header and the terminal body — and KEEP the pane up so // the user can read why, instead of letting it flash and vanish (#39). const reason = ev.reason?.trim(); if (ev.code >= 4000 && reason && !disposed) { setState(reason, "error"); term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`); return; } // An intentional dispose has already set `disposed`; eviction and user // close handle their own pane teardown. if (disposed) return; if (opts.shouldReconnect?.(sessionId, ev.code)) { const delay = Math.min(reconnectMs, MAX_RECONNECT_MS); setState("reconnecting…", "connecting"); reconnectTimer = window.setTimeout(() => { reconnectTimer = null; reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS); connect(); }, delay); return; } setState(`closed (${ev.code})`, "closed"); opts.onClosed?.(sessionId); }; } connect(); // PTY input: every keystroke goes out as a binary frame. A pending key-bar // Ctrl modifier (mobile) folds into the next typed character here. const dataDisp = term.onData((data) => { sendInput(keybar.consume(data)); }); const resizeDisp = term.onResize(({ cols, rows }) => { sendControl({ type: "Resize", cols, rows }); }); window.addEventListener("resize", onResize); const visual = window.visualViewport ?? null; visual?.addEventListener("resize", onResize); // FitAddon only recomputes on the events we call it from — a window resize // misses container-driven changes (sidebar drag, mobile layout settling, // orientation, late font load). Observe the body directly so columns always // match the real rendered width and the terminal fills edge to edge. const ro = new ResizeObserver(() => tryFit()); ro.observe(body); // Re-fit once the monospace font is actually loaded — cell width changes when // the fallback is swapped out, which otherwise leaves stale columns. document.fonts?.ready?.then(() => tryFit()).catch(() => {}); let disposed = false; return { sessionId, el, onMount(mountOpts?: { focus?: boolean }) { mounted = true; focusOnMount = mountOpts?.focus !== false; acquireWebgl(); // Defer a tick so the element has its content-pane dimensions before fit. requestAnimationFrame(() => { tryFit(); sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); if (focusOnMount && ws.readyState === WebSocket.OPEN) term.focus(); }); }, onUnmount() { mounted = false; // Parked views hand their GPU context back: the browser's cap is small // (smaller still on Android) and MAX_PARKED connections outlive the pane. releaseWebgl(); }, focusTerminal() { term.focus(); }, // The closure above: the same bytes-out path keystrokes and the mobile key // bar already take. sendInput, dispose() { if (disposed) return; disposed = true; if (reconnectTimer !== null) { window.clearTimeout(reconnectTimer); reconnectTimer = null; } ro.disconnect(); window.removeEventListener("resize", onResize); visual?.removeEventListener("resize", onResize); dataDisp.dispose(); resizeDisp.dispose(); releaseWebgl(); try { ws.close(); } catch { // ignore } term.dispose(); el.remove(); }, }; } /** * One key in the mobile key bar. * - `mod` is a sticky one-shot modifier toggle (Ctrl / Alt). * - `cursor` is a cursor/navigation key, identified by its CSI final byte, so a * pending modifier can promote it to the `\x1b[1;` form. * - `tilde` is a `~`-terminated navigation key (PgUp/PgDn), identified by its * CSI parameter, whose modified form is `\x1b[;~`. * - `char` is a literal character (the symbols the soft keyboard buries behind * a page switch), folded through a pending modifier like typed input. * - `seq` is a plain key (Esc, Tab) sent verbatim. */ type KeyDef = | { label: string; mod: Modifier } | { label: string; cursor: CursorFinal } | { label: string; tilde: number } | { label: string; char: string } | { label: string; seq: string }; type Modifier = "ctrl" | "alt"; /** CSI final bytes: arrows A–D, Home `H`, End `F`. */ type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F"; /** * The keys the mobile soft keyboard hides, ordered to flow row-major into an * 8-column / 2-row grid (#7): * * Esc Tab Home ↑ End PgUp - / * Ctrl Alt ← ↓ → PgDn | ~ * * — Esc/Tab + the Ctrl/Alt sticky modifiers on the left, the cursor keys as a * real-keyboard inverted-T in the middle with Home/End filling its top corners, * then the paging pair and the shell symbols that cost a soft-keyboard page * switch to reach. Narrower screens scroll the bar sideways rather than * shrinking the caps below a thumb's width. */ const KEY_DEFS: KeyDef[] = [ { label: "Esc", seq: "\x1b" }, { label: "Tab", seq: "\t" }, { label: "Home", cursor: "H" }, { label: "↑", cursor: "A" }, { label: "End", cursor: "F" }, { label: "PgUp", tilde: 5 }, { label: "-", char: "-" }, { label: "/", char: "/" }, { label: "Ctrl", mod: "ctrl" }, { label: "Alt", mod: "alt" }, { label: "←", cursor: "D" }, { label: "↓", cursor: "B" }, { label: "→", cursor: "C" }, { label: "PgDn", tilde: 6 }, { label: "|", char: "|" }, { label: "~", char: "~" }, ]; /** * Fold a single character into its control code: letters and `@[\]^_` map to * 0x00–0x1f, space → NUL, `?` → DEL. Anything else is returned unchanged. */ interface KeyBar { readonly el: HTMLElement; /** Fold pending modifiers into typed input (soft keyboard), consuming them. */ consume(data: string): string; } /** * Build the Termux-style special-key bar. Hidden by default; CSS reveals it on * coarse-pointer (touch) devices. `send` delivers bytes to the PTY. * * Ctrl and Alt are one-shot sticky modifiers: arm them and the very next key — * tapped here or typed on the soft keyboard — carries the modifier, then they * disarm. Taps `preventDefault` on pointerdown so the terminal keeps focus and * the keyboard stays open. */ function buildKeyBar(send: (data: string) => void): KeyBar { const el = document.createElement("div"); el.className = "attach-keybar"; el.setAttribute("role", "toolbar"); el.setAttribute("aria-label", "Special keys"); const armed: Record = { ctrl: false, alt: false }; const modBtns: Partial> = {}; const setMod = (mod: Modifier, on: boolean) => { armed[mod] = on; modBtns[mod]?.setAttribute("aria-pressed", String(on)); }; const clearMods = () => { setMod("ctrl", false); setMod("alt", false); }; for (const def of KEY_DEFS) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "keycap"; btn.textContent = def.label; if ("mod" in def) { btn.classList.add("keycap-mod"); btn.setAttribute("aria-pressed", "false"); btn.setAttribute("aria-label", `${def.label} modifier`); modBtns[def.mod] = btn; btn.addEventListener("click", () => setMod(def.mod, !armed[def.mod])); } else if ("cursor" in def) { btn.addEventListener("click", () => { send(cursorSeq(def.cursor, armed.ctrl, armed.alt)); clearMods(); }); } else if ("tilde" in def) { btn.addEventListener("click", () => { send(tildeSeq(def.tilde, armed.ctrl, armed.alt)); clearMods(); }); } else if ("char" in def) { btn.addEventListener("click", () => { send(applyModsToChar(def.char, armed.ctrl, armed.alt)); clearMods(); }); } else { btn.addEventListener("click", () => { send(def.seq); clearMods(); }); } // Keep the terminal focused (soft keyboard up) — don't let the tap steal it. // preventDefault suppresses the native `:active` style, so flash an explicit // press animation here for tactile feedback. btn.addEventListener("pointerdown", (e) => { e.preventDefault(); btn.classList.remove("is-pressed"); void btn.offsetWidth; // restart the animation if re-tapped mid-flash btn.classList.add("is-pressed"); }); btn.addEventListener("animationend", () => btn.classList.remove("is-pressed")); el.appendChild(btn); } return { el, consume(data: string): string { if ((armed.ctrl || armed.alt) && data.length === 1) { const out = applyModsToChar(data, armed.ctrl, armed.alt); clearMods(); return out; } return data; }, }; }