"use client"; import { forwardRef, useEffect, useImperativeHandle, useRef } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; export interface XtermViewHandle { write: (data: string) => void; } const theme = { background: "#101215", foreground: "#edeef0", cursor: "#e9a857", selectionBackground: "#3f3f46", black: "#18181b", red: "#f87171", green: "#4ade80", yellow: "#facc15", blue: "#60a5fa", magenta: "#c084fc", cyan: "#22d3ee", white: "#e4e4e7", brightBlack: "#3f3f46", brightRed: "#fca5a5", brightGreen: "#86efac", brightYellow: "#fde047", brightBlue: "#93c5fd", brightMagenta: "#d8b4fe", brightCyan: "#67e8f9", brightWhite: "#f4f4f5", }; interface XtermViewProps { /** Written into the terminal as soon as it's created. Only read once, on mount. */ initialData?: string; } /** A read-only xterm.js instance: interprets ANSI colors/control codes from streamed script * output, unlike a plain
which would just show raw escape characters. * * `initialData` is written inside the same effect that creates the Terminal - not via a * ref call from the parent after the fact - specifically because React's Strict Mode (dev * only) mounts, cleans up, and re-mounts every effect once: a fresh Terminal is created on * each real mount, so a "write once" flag in the parent would write to the first (disposed) * instance and silently skip the surviving one, leaving the terminal blank until the next * live write arrives. */ export const XtermView = forwardRef( function XtermView({ initialData }, ref) { const containerRef = useRef (null); const termRef = useRef (null); useEffect(() => { if (!containerRef.current) return; const term = new Terminal({ convertEol: true, disableStdin: true, cursorBlink: false, fontSize: 12, lineHeight: 1.2, fontFamily: "ui-monospace, Menlo, monospace", theme, }); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.open(containerRef.current); fitAddon.fit(); if (initialData) term.write(initialData); termRef.current = term; const resizeObserver = new ResizeObserver(() => { try { fitAddon.fit(); } catch { // container has zero size (e.g. mid-layout-shift) - next resize will retry } }); resizeObserver.observe(containerRef.current); return () => { resizeObserver.disconnect(); term.dispose(); termRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps -- initialData is intentionally only used on mount }, []); useImperativeHandle(ref, () => ({ write: (data: string) => termRef.current?.write(data), })); return ( ); }, );