diff --git a/web/src/components/terminal/KeyBar.tsx b/web/src/components/terminal/KeyBar.tsx
new file mode 100644
index 0000000..219ece8
--- /dev/null
+++ b/web/src/components/terminal/KeyBar.tsx
@@ -0,0 +1,134 @@
+// React port of ../../attach.ts's `buildKeyBar` — the Termux-style special-key
+// bar for the soft keyboard (Esc/Tab/Ctrl/Alt/cursor keys/symbols it hides
+// behind a page switch). Ctrl/Alt are one-shot sticky modifiers: arming one
+// folds into the very next key, tapped here or typed on the soft keyboard,
+// then disarms.
+import { useCallback, useRef, useState } from "react";
+
+import { applyModsToChar, cursorSeq, tildeSeq } from "../../keys.js";
+
+type Modifier = "ctrl" | "alt";
+/** CSI final bytes: arrows A-D, Home `H`, End `F`. */
+type CursorFinal = "A" | "B" | "C" | "D" | "H" | "F";
+
+type KeyDef =
+ | { label: string; mod: Modifier }
+ | { label: string; cursor: CursorFinal }
+ | { label: string; tilde: number }
+ | { label: string; char: string }
+ | { label: string; seq: string };
+
+/**
+ * Row-major into an 8-column / 2-row grid (#7):
+ *
+ * Esc Tab Home ↑ End PgUp - /
+ * Ctrl Alt ← ↓ → PgDn | ~
+ */
+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: "~" },
+];
+
+/**
+ * Pending Ctrl/Alt state plus `consume`, which folds them into a soft-keyboard
+ * character before it reaches the PTY — used by the terminal's own `onData`
+ * handler, not by this component's own button clicks.
+ */
+export function useKeyBarModifiers() {
+ const armedRef = useRef<{ ctrl: boolean; alt: boolean }>({ ctrl: false, alt: false });
+ const [, setTick] = useState(0);
+ const rerender = useCallback(() => setTick((n) => n + 1), []);
+
+ const setMod = useCallback(
+ (mod: Modifier, on: boolean) => {
+ armedRef.current[mod] = on;
+ rerender();
+ },
+ [rerender],
+ );
+
+ const clearMods = useCallback(() => {
+ if (!armedRef.current.ctrl && !armedRef.current.alt) return;
+ armedRef.current = { ctrl: false, alt: false };
+ rerender();
+ }, [rerender]);
+
+ const consume = useCallback((data: string): string => {
+ const { ctrl, alt } = armedRef.current;
+ if ((ctrl || alt) && data.length === 1) {
+ const out = applyModsToChar(data, ctrl, alt);
+ armedRef.current = { ctrl: false, alt: false };
+ return out;
+ }
+ return data;
+ }, []);
+
+ return { armed: armedRef.current, setMod, clearMods, consume };
+}
+
+export interface KeyBarProps {
+ armed: { ctrl: boolean; alt: boolean };
+ onToggleMod(mod: Modifier): void;
+ onClearMods(): void;
+ onSend(data: string): void;
+}
+
+export function KeyBar({ armed, onToggleMod, onClearMods, onSend }: KeyBarProps) {
+ return (
+
+ {KEY_DEFS.map((def) => {
+ if ("mod" in def) {
+ return (
+
+ );
+ }
+ const bytes = () => {
+ if ("cursor" in def) return cursorSeq(def.cursor, armed.ctrl, armed.alt);
+ if ("tilde" in def) return tildeSeq(def.tilde, armed.ctrl, armed.alt);
+ if ("char" in def) return applyModsToChar(def.char, armed.ctrl, armed.alt);
+ return def.seq;
+ };
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/web/src/components/terminal/TerminalPane.test.tsx b/web/src/components/terminal/TerminalPane.test.tsx
new file mode 100644
index 0000000..0c14f37
--- /dev/null
+++ b/web/src/components/terminal/TerminalPane.test.tsx
@@ -0,0 +1,363 @@
+// @vitest-environment happy-dom
+import { cleanup, render } from "@testing-library/react";
+import { createRef } from "react";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
+import { attachWsUrl } from "../../routes.js";
+import type { ClientControl } from "../../types.js";
+
+class FakeTerminal {
+ static instances: FakeTerminal[] = [];
+ cols = 80;
+ rows = 24;
+ element: HTMLElement | undefined = undefined;
+ textarea: HTMLTextAreaElement | null = null;
+ disposed = false;
+ focus = vi.fn();
+ private dataCb: ((d: string) => void) | null = null;
+
+ constructor(public opts: unknown) {
+ FakeTerminal.instances.push(this);
+ }
+ loadAddon(): void {}
+ open(container: HTMLElement): void {
+ container.appendChild(document.createElement("div"));
+ }
+ onData(cb: (d: string) => void) {
+ this.dataCb = cb;
+ return { dispose: vi.fn() };
+ }
+ emitData(d: string): void {
+ this.dataCb?.(d);
+ }
+ onResize() {
+ return { dispose: vi.fn() };
+ }
+ onSelectionChange() {
+ return { dispose: vi.fn() };
+ }
+ write(_data: Uint8Array, cb?: () => void): void {
+ cb?.();
+ }
+ scrollToBottom(): void {}
+ resize(cols: number, rows: number): void {
+ this.cols = cols;
+ this.rows = rows;
+ }
+ clearTextureAtlas(): void {}
+ getSelection(): string {
+ return "";
+ }
+ dispose(): void {
+ this.disposed = true;
+ }
+}
+
+class FakeFitAddon {
+ fit = vi.fn();
+}
+
+class FakeWebglAddon {
+ static instances: FakeWebglAddon[] = [];
+ disposed = false;
+ private lossCb: (() => void) | null = null;
+ constructor() {
+ FakeWebglAddon.instances.push(this);
+ }
+ onContextLoss(cb: () => void): void {
+ this.lossCb = cb;
+ }
+ triggerLoss(): void {
+ this.lossCb?.();
+ }
+ dispose(): void {
+ this.disposed = true;
+ }
+}
+
+const policyEnabled = vi.fn(() => true);
+const policyNoteLoss = vi.fn();
+
+vi.mock("@xterm/xterm", () => ({ Terminal: FakeTerminal }));
+vi.mock("@xterm/addon-fit", () => ({ FitAddon: FakeFitAddon }));
+vi.mock("@xterm/addon-webgl", () => ({ WebglAddon: FakeWebglAddon }));
+vi.mock("../../webglRenderer.js", () => ({
+ WebglPolicy: vi.fn().mockImplementation(() => ({
+ enabled: policyEnabled,
+ noteContextLoss: policyNoteLoss,
+ })),
+}));
+
+class FakeWebSocket {
+ static OPEN = 1;
+ static CONNECTING = 0;
+ static CLOSING = 2;
+ static CLOSED = 3;
+ static instances: FakeWebSocket[] = [];
+ readyState = FakeWebSocket.CONNECTING;
+ binaryType = "";
+ sent: (string | ArrayBufferView)[] = [];
+ onopen: (() => void) | null = null;
+ onmessage: ((ev: { data: unknown }) => void) | null = null;
+ onerror: (() => void) | null = null;
+ onclose: ((ev: { code: number; reason: string }) => void) | null = null;
+
+ constructor(public url: string) {
+ FakeWebSocket.instances.push(this);
+ }
+ send(data: string | ArrayBufferView): void {
+ this.sent.push(data);
+ }
+ close(): void {
+ this.readyState = FakeWebSocket.CLOSED;
+ }
+ open(): void {
+ this.readyState = FakeWebSocket.OPEN;
+ this.onopen?.();
+ }
+ drop(code = 1006, reason = ""): void {
+ this.readyState = FakeWebSocket.CLOSED;
+ this.onclose?.({ code, reason });
+ }
+ lastControl(): ClientControl {
+ return JSON.parse(this.sent.at(-1) as string) as ClientControl;
+ }
+}
+
+beforeAll(() => {
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ observe(): void {}
+ disconnect(): void {}
+ unobserve(): void {}
+ },
+ );
+});
+
+beforeEach(() => {
+ FakeTerminal.instances = [];
+ FakeWebglAddon.instances = [];
+ FakeWebSocket.instances = [];
+ policyEnabled.mockReturnValue(true);
+ policyEnabled.mockClear();
+ policyNoteLoss.mockClear();
+ vi.stubGlobal("WebSocket", FakeWebSocket);
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+// Imported after the mocks above so it picks up the mocked xterm/webgl modules.
+const { TerminalPane } = await import("./TerminalPane.js");
+
+describe("TerminalPane lifecycle", () => {
+ it("opens an xterm terminal and connects to the session's attach socket on mount", () => {
+ render();
+
+ expect(FakeTerminal.instances).toHaveLength(1);
+ expect(FakeWebSocket.instances).toHaveLength(1);
+ expect(FakeWebSocket.instances[0].url).toBe(attachWsUrl("s1"));
+ });
+
+ it("sends the terminal size once the socket opens", () => {
+ render();
+ const sock = FakeWebSocket.instances[0];
+
+ sock.open();
+
+ expect(sock.lastControl()).toEqual({ type: "Resize", cols: 80, rows: 24 });
+ });
+
+ it("disposes the terminal and closes the socket on unmount", () => {
+ const { unmount } = render();
+ const term = FakeTerminal.instances[0];
+ const sock = FakeWebSocket.instances[0];
+
+ unmount();
+
+ expect(term.disposed).toBe(true);
+ expect(sock.readyState).toBe(FakeWebSocket.CLOSED);
+ });
+
+ it("reconnects to a fresh socket for a new sessionId", () => {
+ const { rerender } = render();
+ rerender();
+
+ expect(FakeWebSocket.instances).toHaveLength(2);
+ expect(FakeWebSocket.instances[1].url).toBe(attachWsUrl("s2"));
+ expect(FakeTerminal.instances[0].disposed).toBe(true);
+ });
+});
+
+describe("TerminalPane reconnect backoff", () => {
+ it("retries a dropped socket, doubling the delay from 500ms up to a 10s cap", () => {
+ vi.useFakeTimers();
+ render();
+
+ const drop = () => FakeWebSocket.instances.at(-1)!.drop(1006, "");
+ const expectStillWaiting = (ms: number, countBefore: number) => {
+ vi.advanceTimersByTime(ms - 1);
+ expect(FakeWebSocket.instances).toHaveLength(countBefore);
+ vi.advanceTimersByTime(1);
+ expect(FakeWebSocket.instances).toHaveLength(countBefore + 1);
+ };
+
+ expect(FakeWebSocket.instances).toHaveLength(1);
+ drop();
+ expectStillWaiting(INITIAL_RECONNECT_MS, 1); // 500ms -> #2
+ drop();
+ expectStillWaiting(INITIAL_RECONNECT_MS * 2, 2); // 1000ms -> #3
+ drop();
+ expectStillWaiting(INITIAL_RECONNECT_MS * 4, 3); // 2000ms -> #4
+ drop();
+ expectStillWaiting(INITIAL_RECONNECT_MS * 8, 4); // 4000ms -> #5
+ drop();
+ expectStillWaiting(INITIAL_RECONNECT_MS * 16, 5); // 8000ms -> #6
+ drop();
+ expectStillWaiting(MAX_RECONNECT_MS, 6); // capped at 10s -> #7
+ drop();
+ expectStillWaiting(MAX_RECONNECT_MS, 7); // stays capped -> #8
+ });
+
+ it("resets the backoff once a reconnected socket opens", () => {
+ vi.useFakeTimers();
+ render();
+
+ FakeWebSocket.instances[0].drop(1006, "");
+ vi.advanceTimersByTime(INITIAL_RECONNECT_MS);
+ FakeWebSocket.instances[1].open();
+ FakeWebSocket.instances[1].drop(1006, "");
+
+ vi.advanceTimersByTime(INITIAL_RECONNECT_MS - 1);
+ expect(FakeWebSocket.instances).toHaveLength(2);
+ vi.advanceTimersByTime(1);
+ expect(FakeWebSocket.instances).toHaveLength(3);
+ });
+
+ it("does not retry a clean close, and reports it closed", () => {
+ vi.useFakeTimers();
+ const onClosed = vi.fn();
+ render();
+
+ FakeWebSocket.instances[0].drop(1000, "");
+ vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
+
+ expect(FakeWebSocket.instances).toHaveLength(1);
+ expect(onClosed).toHaveBeenCalledOnce();
+ });
+
+ it("does not retry an application-level rejection, and writes it into the terminal", () => {
+ vi.useFakeTimers();
+ const onClosed = vi.fn();
+ render();
+ const term = FakeTerminal.instances[0];
+ const writeSpy = vi.spyOn(term, "write");
+
+ FakeWebSocket.instances[0].drop(4004, "unknown session");
+ vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
+
+ expect(FakeWebSocket.instances).toHaveLength(1);
+ expect(onClosed).not.toHaveBeenCalled();
+ expect(writeSpy).toHaveBeenCalledWith(expect.stringContaining("unknown session"));
+ });
+
+ it("honors a caller-supplied shouldReconnect policy", () => {
+ vi.useFakeTimers();
+ const onClosed = vi.fn();
+ render( false} onClosed={onClosed} />);
+
+ FakeWebSocket.instances[0].drop(1006, "");
+ vi.advanceTimersByTime(MAX_RECONNECT_MS * 2);
+
+ expect(FakeWebSocket.instances).toHaveLength(1);
+ expect(onClosed).toHaveBeenCalledOnce();
+ });
+});
+
+describe("TerminalPane WebGL context budget", () => {
+ it("acquires a WebGL context on mount, shared policy allowing", () => {
+ render();
+
+ expect(policyEnabled).toHaveBeenCalled();
+ expect(FakeWebglAddon.instances).toHaveLength(1);
+ });
+
+ it("never creates a context once the shared budget is exhausted", () => {
+ policyEnabled.mockReturnValue(false);
+ render();
+
+ expect(FakeWebglAddon.instances).toHaveLength(0);
+ });
+
+ it("releases its context on unmount, handing the budget back", () => {
+ const { unmount } = render();
+ const addon = FakeWebglAddon.instances[0];
+
+ unmount();
+
+ expect(addon.disposed).toBe(true);
+ });
+
+ it("counts a context loss and re-acquires while still mounted", () => {
+ render();
+ const first = FakeWebglAddon.instances[0];
+
+ first.triggerLoss();
+
+ expect(policyNoteLoss).toHaveBeenCalledOnce();
+ expect(first.disposed).toBe(true);
+ expect(FakeWebglAddon.instances).toHaveLength(2);
+ });
+
+ it("does not re-acquire a lost context once the budget is spent", () => {
+ render();
+ const first = FakeWebglAddon.instances[0];
+ policyEnabled.mockReturnValue(false);
+
+ first.triggerLoss();
+
+ expect(FakeWebglAddon.instances).toHaveLength(1);
+ });
+});
+
+describe("TerminalPane imperative handle", () => {
+ it("sendInput writes bytes to the PTY only once the socket is open", () => {
+ const ref = createRef();
+ render();
+ const sock = FakeWebSocket.instances[0];
+
+ ref.current!.sendInput("nope");
+ expect(sock.sent).toHaveLength(0);
+
+ sock.open();
+ sock.sent.length = 0; // drop the resize control frame from open()
+ ref.current!.sendInput("hi");
+
+ expect(sock.sent).toHaveLength(1);
+ expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("hi");
+ });
+
+ it("focusTerminal focuses the underlying xterm instance", () => {
+ const ref = createRef();
+ render();
+
+ ref.current!.focusTerminal();
+
+ expect(FakeTerminal.instances[0].focus).toHaveBeenCalledOnce();
+ });
+
+ it("routes typed keystrokes through to the socket", () => {
+ render();
+ const sock = FakeWebSocket.instances[0];
+ sock.open();
+ sock.sent.length = 0;
+
+ FakeTerminal.instances[0].emitData("q");
+
+ expect(new TextDecoder().decode(sock.sent[0] as Uint8Array)).toBe("q");
+ });
+});
diff --git a/web/src/components/terminal/TerminalPane.tsx b/web/src/components/terminal/TerminalPane.tsx
new file mode 100644
index 0000000..02b495c
--- /dev/null
+++ b/web/src/components/terminal/TerminalPane.tsx
@@ -0,0 +1,362 @@
+// React port of ../../attach.ts: an xterm terminal bound to the daemon's
+// attach WebSocket, plus ../../webglRenderer.ts's shared GPU-context budget
+// and ../../reconnect.ts's backoff schedule. Pane chrome (title, disconnect
+// button, sessions drawer) stays out of scope here — this is the thin
+// ref-based xterm wrapper the issue asked for; a header composes around it.
+import { FitAddon } from "@xterm/addon-fit";
+import { WebglAddon } from "@xterm/addon-webgl";
+import { Terminal } from "@xterm/xterm";
+import "@xterm/xterm/css/xterm.css";
+import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
+
+import { INITIAL_RECONNECT_MS, MAX_RECONNECT_MS } from "../../reconnect.js";
+import { attachWsUrl } from "../../routes.js";
+import type { ClientControl, Uuid } from "../../types.js";
+import { WebglPolicy } from "../../webglRenderer.js";
+import { KeyBar, useKeyBarModifiers } from "./KeyBar.js";
+
+/** One budget for the whole page — every mounted `TerminalPane` shares it, the
+ * same reasoning as attach.ts's own module-level policy. */
+const webglPolicy = new WebglPolicy();
+
+export type TerminalStatus = "connecting" | "open" | "reconnecting" | "closed" | "error";
+
+/** Application-defined close codes (daemon rejection) and the clean-close
+ * code — retrying either would only loop. Mirrors reconnect.ts's
+ * `shouldRetryClose`, minus the catalog lookup a standalone pane doesn't have. */
+function defaultShouldReconnect(code: number): boolean {
+ return code < 4000 && code !== 1000;
+}
+
+export interface TerminalPaneProps {
+ sessionId: Uuid;
+ /** Grabs the keyboard once the socket opens. A sidebar preview passes `false`. */
+ focus?: boolean;
+ /** Asked on every close: should the socket be retried? */
+ shouldReconnect?: (code: number) => boolean;
+ /** The socket closed for good (retry declined, or the daemon rejected the
+ * session) — not called on an ordinary unmount. */
+ onClosed?: () => void;
+ /** Connection status changes, for a header/status badge to render. */
+ onStatusChange?: (status: TerminalStatus, detail?: string) => void;
+ className?: string;
+}
+
+export interface TerminalPaneHandle {
+ focusTerminal(): void;
+ sendInput(data: string): void;
+}
+
+export const TerminalPane = forwardRef(
+ function TerminalPane(
+ { sessionId, focus = true, shouldReconnect = defaultShouldReconnect, onClosed, onStatusChange, className },
+ ref,
+ ) {
+ const bodyRef = useRef(null);
+ const sendInputRef = useRef<(data: string) => void>(() => {});
+ const focusTerminalRef = useRef<() => void>(() => {});
+ const keyBar = useKeyBarModifiers();
+ const [keybarActive, setKeybarActive] = useState(false);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ focusTerminal: () => focusTerminalRef.current(),
+ sendInput: (data: string) => sendInputRef.current(data),
+ }),
+ [],
+ );
+
+ useEffect(() => {
+ const body = bodyRef.current;
+ if (!body) return;
+
+ 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: {
+ background: "#121419",
+ foreground: "#e6e8ec",
+ cursor: "#5fe3a1",
+ cursorAccent: "#121419",
+ selectionBackground: "#2b6b4f",
+ },
+ scrollback: 5000,
+ allowProposedApi: true,
+ });
+ const fit = new FitAddon();
+ term.loadAddon(fit);
+ term.open(body);
+
+ let webgl: WebglAddon | null = null;
+ let dprMedia: MediaQueryList | null = null;
+
+ 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
+ }
+ }
+
+ function releaseWebgl(): void {
+ dprMedia?.removeEventListener("change", onDprChange);
+ dprMedia = null;
+ if (!webgl) return;
+ const addon = webgl;
+ webgl = null;
+ try {
+ addon.dispose();
+ } 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();
+ addon.onContextLoss(() => {
+ webglPolicy.noteContextLoss();
+ releaseWebgl();
+ if (mounted) acquireWebgl();
+ });
+ term.loadAddon(addon);
+ webgl = addon;
+ watchDpr();
+ } catch {
+ webglPolicy.noteContextLoss();
+ webgl = null;
+ }
+ }
+
+ let mounted = false;
+ const tryFit = () => {
+ if (!mounted) return;
+ 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();
+ return;
+ }
+ 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;
+ let awaitingReplay = true;
+ let disposed = false;
+
+ const sendControl = (msg: ClientControl) => {
+ if (ws.readyState !== WebSocket.OPEN) return;
+ ws.send(JSON.stringify(msg));
+ };
+
+ const sendInput = (data: string) => {
+ if (ws.readyState !== WebSocket.OPEN) return;
+ ws.send(new TextEncoder().encode(data));
+ };
+ sendInputRef.current = sendInput;
+ focusTerminalRef.current = () => term.focus();
+
+ const onResize = () => {
+ tryFit();
+ sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
+ };
+
+ function connect(): void {
+ awaitingReplay = true;
+ ws = new WebSocket(attachWsUrl(sessionId));
+ ws.binaryType = "arraybuffer";
+
+ ws.onopen = () => {
+ reconnectMs = INITIAL_RECONNECT_MS;
+ onStatusChange?.("open");
+ sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
+ if (mounted && focus) 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) {
+ const replay = awaitingReplay;
+ awaitingReplay = false;
+ term.write(new Uint8Array(ev.data), () => {
+ if (replay) term.scrollToBottom();
+ });
+ } else if (ev.data instanceof Blob) {
+ void ev.data.arrayBuffer().then((b) => {
+ const replay = awaitingReplay;
+ awaitingReplay = false;
+ term.write(new Uint8Array(b), () => {
+ if (replay) term.scrollToBottom();
+ });
+ });
+ }
+ };
+
+ ws.onerror = () => {
+ onStatusChange?.("error");
+ };
+
+ ws.onclose = (ev) => {
+ const reason = ev.reason?.trim();
+ if (ev.code >= 4000 && reason && !disposed) {
+ onStatusChange?.("error", reason);
+ term.write(`\r\n\x1b[31m⚠ cannot attach: ${reason}\x1b[0m\r\n`);
+ return;
+ }
+ if (disposed) return;
+
+ if (shouldReconnect(ev.code)) {
+ const delay = Math.min(reconnectMs, MAX_RECONNECT_MS);
+ onStatusChange?.("reconnecting");
+ reconnectTimer = window.setTimeout(() => {
+ reconnectTimer = null;
+ reconnectMs = Math.min(reconnectMs * 2, MAX_RECONNECT_MS);
+ connect();
+ }, delay);
+ return;
+ }
+
+ onStatusChange?.("closed", String(ev.code));
+ onClosed?.();
+ };
+ }
+
+ onStatusChange?.("connecting");
+ connect();
+
+ const dataDisp = term.onData((data) => {
+ sendInput(keyBar.consume(data));
+ });
+ const resizeDisp = term.onResize(({ cols, rows }) => {
+ sendControl({ type: "Resize", cols, rows });
+ });
+ const selectionDisp = term.onSelectionChange(() => {
+ const text = term.getSelection();
+ if (text) void navigator.clipboard?.writeText(text).catch(() => {});
+ });
+
+ const ta = term.textarea;
+ const onTaFocus = () => {
+ setKeybarActive(true);
+ requestAnimationFrame(tryFit);
+ };
+ const onTaBlur = () => {
+ setKeybarActive(false);
+ requestAnimationFrame(tryFit);
+ };
+ ta?.addEventListener("focus", onTaFocus);
+ ta?.addEventListener("blur", onTaBlur);
+
+ window.addEventListener("resize", onResize);
+ const visual = window.visualViewport ?? null;
+ visual?.addEventListener("resize", onResize);
+
+ const ro = new ResizeObserver(() => tryFit());
+ ro.observe(body);
+ document.fonts?.ready?.then(() => tryFit()).catch(() => {});
+
+ mounted = true;
+ acquireWebgl();
+ requestAnimationFrame(() => {
+ tryFit();
+ sendControl({ type: "Resize", cols: term.cols, rows: term.rows });
+ if (focus && ws.readyState === WebSocket.OPEN) term.focus();
+ });
+
+ return () => {
+ disposed = true;
+ mounted = false;
+ if (reconnectTimer !== null) {
+ window.clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+ ro.disconnect();
+ window.removeEventListener("resize", onResize);
+ visual?.removeEventListener("resize", onResize);
+ ta?.removeEventListener("focus", onTaFocus);
+ ta?.removeEventListener("blur", onTaBlur);
+ dataDisp.dispose();
+ resizeDisp.dispose();
+ selectionDisp.dispose();
+ releaseWebgl();
+ try {
+ ws.close();
+ } catch {
+ // ignore
+ }
+ term.dispose();
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- reconnecting
+ // a session change is a fresh connection, not a prop-driven re-render
+ }, [sessionId]);
+
+ return (
+
+
+ {keybarActive && (
+
keyBar.setMod(mod, !keyBar.armed[mod])}
+ onClearMods={keyBar.clearMods}
+ onSend={(data) => sendInputRef.current(data)}
+ />
+ )}
+
+ );
+ },
+);