Render run output with xterm.js instead of plain text

Replaces the plain-text output <div> with a real xterm.js terminal
(@xterm/xterm + @xterm/addon-fit), so ANSI color/control codes from
scripts render as actual colors instead of raw escape characters.
stderr chunks are wrapped in ANSI red so failures stand out even from
tools that don't colorize their own output. Also sets FORCE_COLOR=1/
CLICOLOR_FORCE=1 as env defaults (real/script env still wins) since
scripts run without a real TTY and most tools auto-disable color
without one of these overrides.

Fixed a React Strict Mode bug found while testing: the initial log was
written to the terminal via a "write once" ref flag in the parent,
but Strict Mode's dev-only mount->cleanup->remount cycle creates a
fresh Terminal on the real mount, so that flag silently skipped
writing to the surviving instance - the terminal looked completely
blank until live output arrived. Fixed by writing initialData inside
the same effect that creates the Terminal, so it's correct by
construction regardless of how many times the effect runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 20:29:03 +02:00
co-authored by Claude Sonnet 5
parent 20989987f6
commit 7269886f9e
5 changed files with 2675 additions and 4932 deletions
+102
View File
@@ -0,0 +1,102 @@
"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: "#09090b",
foreground: "#f4f4f5",
cursor: "#f4f4f5",
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.4,
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="h-[60vh] overflow-hidden rounded-lg bg-zinc-950 p-2"
/>
);
},
);