Files
triggershell/src/components/runs/xterm-view.tsx
T
valknar b77041cfa8 Size the run terminal by aspect ratio instead of viewport height
h-[60vh] made the terminal's height track the viewport regardless of
its actual width, so it read as too tall on narrower layouts.
aspect-video keeps it at 16/9 relative to its own width instead.
2026-08-16 18:15:17 +02:00

103 lines
3.0 KiB
TypeScript

"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 <pre> 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<XtermViewHandle, XtermViewProps>(
function XtermView({ initialData }, ref) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(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 (
<div
ref={containerRef}
className="aspect-video w-full overflow-hidden bg-[#101215] p-2"
/>
);
},
);