// src/Terminal.js import { useEffect, useRef } from "react"; import { Terminal } from "xterm"; import { FitAddon } from "xterm-addon-fit"; import "xterm/css/xterm.css"; /** * Props: * - onData(line: string) -> called when user presses Enter with the typed line (no trailing \r) * - output (string) -> append output to the terminal when it changes */ export default function XTerm({ onData, output }) { const containerId = "terminal-container"; const termRef = useRef(null); const bufferRef = useRef(""); // collects user typed chars until Enter const fitRef = useRef(null); useEffect(() => { const term = new Terminal({ cursorBlink: true, fontSize: 14, disableStdin: false, convertEol: true, theme: { background: "#1e1e1e", foreground: "#ffffff", }, }); const fitAddon = new FitAddon(); fitRef.current = fitAddon; term.loadAddon(fitAddon); term.open(document.getElementById(containerId)); fitAddon.fit(); // Keep a reference termRef.current = term; // echo typed characters and detect Enter term.onData((data) => { // xterm sends strings including characters and control chars like '\r' // append to visible terminal term.write(data); // common Enter is '\r' (CR) if (data === "\r" || data === "\n") { // capture current buffer as line, trim trailing CR/LF const line = (bufferRef.current || "").replace(/\r?\n$/, ""); bufferRef.current = ""; // reset buffer // echo newline if not already // (we already wrote the '\r' above) // call parent handler try { if (typeof onData === "function" && line !== null) onData(line); } catch (e) { // swallow console.error("XTerm onData handler threw:", e); } return; } // backspace handling: if user presses backspace key, it may come as '\x7f' or '\b' if (data === "\x7f" || data === "\b") { bufferRef.current = bufferRef.current.slice(0, -1); return; } // other control sequences ignore if (data.charCodeAt(0) < 32) { // ignore other ctrl chars return; } // normal characters: append to buffer bufferRef.current += data; }); // expose a simple focus method on container element for external focusing const container = document.getElementById(containerId); if (container) container.tabIndex = 0; return () => { try { term.dispose(); } catch {} }; }, [onData]); // Append new output when `output` prop changes useEffect(() => { const term = termRef.current; if (!term || !output) return; // write a newline and the output text (preserves newlines) term.writeln(""); // if output includes multiple lines, write each const lines = output.split(/\r?\n/); lines.forEach((ln, idx) => { // avoid extra blank at very start if (idx === 0 && ln === "") return; term.writeln(ln); }); }, [output]); // helper to focus the hidden xterm textarea const focus = () => { // xterm's helper textarea is what receives keyboard input const ta = document.querySelector(`#${containerId} .xterm-helper-textarea`); if (ta) { ta.focus(); const len = ta.value?.length ?? 0; try { ta.setSelectionRange(len, len); } catch {} } else { const cont = document.getElementById(containerId); if (cont) cont.focus(); } }; // expose a global method in-case parent wants to call it (not ideal but handy) useEffect(() => { window.__xterm_focus = focus; return () => { try { delete window.__xterm_focus } catch {} }; }, []); return (
); }