/**
* 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 ;
}