/** * TerminalPane is an interactive shell in the browser: xterm.js on the front, * a real PTY on the backend, joined by the /ws/terminal WebSocket (raw output * as binary frames, input and resizes as JSON — see the protocol reference). * * The pane is kept mounted (hidden, not unmounted) while other tabs are * active, so the shell session survives tab switches. * * @example * */ import { useEffect, useRef } from "react"; import "@xterm/xterm/css/xterm.css"; /** terminalWsUrl derives the terminal endpoint from the page's origin. */ export function terminalWsUrl(): string { const scheme = window.location.protocol === "https:" ? "wss" : "ws"; return `${scheme}://${window.location.host}/ws/terminal`; } export function TerminalPane() { const containerRef = useRef(null); useEffect(() => { const container = containerRef.current; if (!container) { return; } let disposed = false; let cleanup: (() => void) | undefined; // xterm is heavy: loaded on first use only. Promise.all([import("@xterm/xterm"), import("@xterm/addon-fit")]).then( ([{ Terminal }, { FitAddon }]) => { if (disposed) { return; } // Always dark, IDE-style, whatever the app theme. const term = new Terminal({ fontSize: 13, cursorBlink: true, theme: { background: "#1e1e1e" }, }); const fit = new FitAddon(); term.loadAddon(fit); term.open(container); fit.fit(); const socket = new WebSocket(terminalWsUrl()); socket.binaryType = "arraybuffer"; socket.onmessage = (event) => term.write(new Uint8Array(event.data as ArrayBuffer)); socket.onopen = () => socket.send( JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows, }), ); socket.onclose = () => term.write("\r\n[terminal session closed]\r\n"); const inputSubscription = term.onData((data) => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: "input", data })); } }); const resizeSubscription = term.onResize(({ cols, rows }) => { if (socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: "resize", cols, rows })); } }); const observer = new ResizeObserver(() => fit.fit()); observer.observe(container); cleanup = () => { observer.disconnect(); inputSubscription.dispose(); resizeSubscription.dispose(); socket.close(); term.dispose(); }; }, ); return () => { disposed = true; cleanup?.(); }; }, []); return
; }