// ----------------------------------------------------------------------------
// TerminalEmulator Component.
//
// This component renders a terminal emulator using xterm.js and connects to a
// log stream API. It displays incoming log messages in real-time and shows
// connection status. The component is styled to resemble a classic terminal
// window and is responsive to container size changes.
// ----------------------------------------------------------------------------
import { useEffect, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { useLogStream } from "./logStream";
import "./styles.css";
// These strings are internal SSE stream status messages emitted by the server. They can
// appear in replay history from older log entries and should never surface to dashboard users.
const SUPPRESSED_STARTUP_LINES = new Set(["Waiting for log stream...", "[stream] connected"]);
const terminalTheme = {
background: "#171b20",
foreground: "#d9e1ea",
cursor: "#78dce8",
black: "#1b1f24",
red: "#ff8f8f",
green: "#7bd88f",
yellow: "#ffd580",
blue: "#78dce8",
magenta: "#c792ea",
cyan: "#89ddff",
white: "#d9e1ea",
brightBlack: "#5c6773",
brightRed: "#ff8f8f",
brightGreen: "#7bd88f",
brightYellow: "#ffd580",
brightBlue: "#89ddff",
brightMagenta: "#d8b4ff",
brightCyan: "#89ddff",
brightWhite: "#ffffff",
};
const terminalConfig = {
convertEol: true,
cursorBlink: false,
disableStdin: true,
fontFamily: '"JetBrains Mono", "SFMono-Regular", Menlo, monospace',
fontSize: 13,
lineHeight: 1.4,
scrollback: 5000,
theme: terminalTheme,
};
interface TerminalEmulatorProps {
apiUrl: string;
}
function TerminalEmulator({ apiUrl }: TerminalEmulatorProps) {
const { logs, connected, error, isInitializing } = useLogStream(apiUrl);
const [showLoading, setShowLoading] = useState(false);
const terminalContainerRef = useRef<HTMLDivElement | null>(null);
const terminalRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const lastLogIndexRef = useRef(0);
const initBufferRef = useRef<string[]>([]);
useEffect(() => {
if (!terminalContainerRef.current) {
return;
}
const term = new Terminal(terminalConfig);
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalContainerRef.current);
// defer fit until after browser layout
let rafId = requestAnimationFrame(() => {
fitAddon.fit();
term.write('\x1b[?7l');
});
terminalRef.current = term;
fitAddonRef.current = fitAddon;
const handleResize = () => {
fitAddonRef.current?.fit();
};
const resizeObserver = new ResizeObserver(() => {
fitAddonRef.current?.fit();
});
resizeObserver.observe(terminalContainerRef.current);
window.addEventListener("resize", handleResize);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", handleResize);
resizeObserver.disconnect();
fitAddonRef.current = null;
terminalRef.current = null;
term.dispose();
};
}, []);
useEffect(() => {
if (!terminalRef.current) {
return;
}
const nextLogs = logs.slice(lastLogIndexRef.current);
if (!nextLogs.length) {
return;
}
const nextMessages = nextLogs
.map((log) => log.message)
.filter((message) => !SUPPRESSED_STARTUP_LINES.has(message.trim())); // drop internal stream noise
lastLogIndexRef.current = logs.length;
if (!nextMessages.length) {
return;
}
if (isInitializing) {
initBufferRef.current.push(...nextMessages);
return;
}
const bufferedMessages = initBufferRef.current;
initBufferRef.current = [];
const allMessages = bufferedMessages.concat(nextMessages);
const batchedOutput = `${allMessages.join("\r\n")}\r\n`;
terminalRef.current.write(batchedOutput);
}, [isInitializing, logs]);
useEffect(() => {
if (!isInitializing) {
setShowLoading(false);
return;
}
const timeoutId = window.setTimeout(() => {
setShowLoading(true);
}, 250);
return () => {
window.clearTimeout(timeoutId);
};
}, [isInitializing]);
useEffect(() => {
if (!terminalRef.current || !error) {
return;
}
terminalRef.current.writeln(`\x1b[31m[stream] ${error}\x1b[0m`);
}, [error]);
return (
<>
<section className="terminal-window" aria-label="Log terminal">
<div className="terminal-window__header">
<div className="terminal-window__controls" aria-hidden="true">
<span className="terminal-window__dot terminal-window__dot--close" />
<span className="terminal-window__dot terminal-window__dot--minimize" />
<span className="terminal-window__dot terminal-window__dot--maximize" />
</div>
<div className="terminal-window__title">logs@smarter:~</div>
<div
className={`terminal-window__status ${connected ? "is-online" : "is-offline"}`}
>
{connected ? "connected" : "disconnected"}
</div>
</div>
<div className="terminal-window__body" role="log" aria-live="polite">
{showLoading && (
<div className="terminal-window__loading" aria-label="Loading logs">
<span className="terminal-window__loading-spinner" aria-hidden="true" />
<span>Loading logs…</span>
</div>
)}
<div ref={terminalContainerRef} className="terminal-window__xterm" />
</div>
</section>{" "}
</>
);
}
export default TerminalEmulator;