Spaces:
Running
Running
| // 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 ( | |
| <div | |
| id={containerId} | |
| style={{ | |
| width: "100%", | |
| height: "180px", | |
| background: "#1e1e1e", | |
| borderTop: "1px solid #333", | |
| outline: "none", | |
| }} | |
| /> | |
| ); | |
| } | |