# 🖥️ Terminal Implementation Reference **Component**: `TerminalView` **Location**: `zlh-portal` (Next.js frontend) **Stack**: xterm.js (`@xterm/xterm`) + WebSocket + React **Last Updated**: February 22, 2026 --- ## Overview ZeroLagHub uses a reusable `TerminalView` React component for both game server consoles and dev container consoles. It wraps xterm.js with a WebSocket transport, exposes a minimal prop API, and handles its own lifecycle cleanup. The component is intentionally display-agnostic — it doesn't know whether it's showing a Minecraft console or a dev shell. The caller owns the WebSocket URL and decides whether input is enabled. --- ## Component Source ```typescript "use client"; import { useEffect, useRef } from "react"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; type TerminalViewProps = { welcomeMessage?: string; wsUrl?: string; enableInput?: boolean; onSend?: ((send: ((data: string) => void) | null) => void) | null; onStatus?: ((status: "idle" | "connecting" | "open" | "closed" | "error") => void) | null; }; export default function TerminalView({ welcomeMessage = "Terminal ready. Awaiting connection...", wsUrl, enableInput = false, onSend = null, onStatus = null, }: TerminalViewProps) { const containerRef = useRef(null); const terminalRef = useRef(null); const socketRef = useRef(null); const onSendRef = useRef(onSend); const onStatusRef = useRef(onStatus); useEffect(() => { onSendRef.current = onSend; }, [onSend]); useEffect(() => { onStatusRef.current = onStatus; }, [onStatus]); useEffect(() => { if (!containerRef.current) return; const terminal = new Terminal({ cursorBlink: false, scrollback: 2000, fontSize: 13, disableStdin: !enableInput, }); const fitAddon = new FitAddon(); const inputDisposable = enableInput ? terminal.onData((data) => { if (socketRef.current?.readyState === WebSocket.OPEN) { socketRef.current.send(data); } }) : null; terminal.loadAddon(fitAddon); terminal.open(containerRef.current); fitAddon.fit(); terminal.writeln(welcomeMessage); terminalRef.current = terminal; const handleResize = () => fitAddon.fit(); window.addEventListener("resize", handleResize); return () => { window.removeEventListener("resize", handleResize); if (inputDisposable) inputDisposable.dispose(); if (socketRef.current) { socketRef.current.close(); socketRef.current = null; } if (onSendRef.current) onSendRef.current(null); terminalRef.current?.dispose(); terminalRef.current = null; }; }, [welcomeMessage, enableInput]); useEffect(() => { if (!wsUrl || !terminalRef.current || socketRef.current) { if (!wsUrl && onSendRef.current) onSendRef.current(null); if (!wsUrl && onStatusRef.current) onStatusRef.current("idle"); return; } if (onStatusRef.current) onStatusRef.current("connecting"); terminalRef.current.writeln("Connecting to stream..."); const socket = new WebSocket(wsUrl); socketRef.current = socket; const send = (data: string) => { if (socketRef.current?.readyState === WebSocket.OPEN) { socketRef.current.send(data); } }; if (onSendRef.current) onSendRef.current(send); socket.addEventListener("message", (event) => { // Write raw chunks — do not add extra newlines per message terminalRef.current?.write(String(event.data)); }); socket.addEventListener("open", () => { terminalRef.current?.writeln("Connected."); if (onStatusRef.current) onStatusRef.current("open"); }); socket.addEventListener("close", () => { terminalRef.current?.writeln("Connection closed."); if (onSendRef.current) onSendRef.current(null); if (onStatusRef.current) onStatusRef.current("closed"); }); socket.addEventListener("error", () => { terminalRef.current?.writeln("Connection error."); if (onStatusRef.current) onStatusRef.current("error"); }); }, [wsUrl]); return
; } ``` --- ## Props | Prop | Type | Default | Description | |------|------|---------|-------------| | `welcomeMessage` | `string` | `"Terminal ready. Awaiting connection..."` | First line written to terminal on mount | | `wsUrl` | `string \| undefined` | — | WebSocket URL to connect to. Changing this URL reconnects. | | `enableInput` | `boolean` | `false` | If true, keystrokes are sent over the WebSocket. Set false for read-only consoles (game servers). | | `onSend` | `((send) => void) \| null` | `null` | Callback that receives a `send(data)` function when connected, or `null` when disconnected. Lets the parent send commands programmatically. | | `onStatus` | `((status) => void) \| null` | `null` | Callback fired on connection state changes: `idle \| connecting \| open \| closed \| error` | --- ## Architecture Notes ### Two separate `useEffect` blocks — intentional The terminal mount/unmount lifecycle and the WebSocket lifecycle are deliberately separated: - **Effect 1** (`[welcomeMessage, enableInput]`): Creates and destroys the xterm.js terminal instance. Runs once on mount in practice. - **Effect 2** (`[wsUrl]`): Creates and connects the WebSocket. Re-runs when `wsUrl` changes, enabling seamless reconnection by simply changing the URL prop. This prevents the WebSocket from being torn down and recreated every time an unrelated prop changes. ### Callback refs pattern (`onSendRef`, `onStatusRef`) `onSend` and `onStatus` are stored in refs and kept current via their own `useEffect` syncs. This avoids stale closure bugs inside the WebSocket event handlers — the handlers always call the latest version of the callback without needing to re-register when the parent re-renders. ### Raw write, not writeln The `message` handler uses `terminal.write()` not `terminal.writeln()`. The PTY stream already contains its own newlines and carriage returns. Adding `\n` per message would double-space all output. ### `disableStdin` vs `enableInput` prop xterm.js `disableStdin` prevents the terminal from capturing keyboard events at all. For game server consoles where the user should only observe (not interact), `enableInput={false}` sets this, keeping the terminal purely read-only. Dev container consoles pass `enableInput={true}`. --- ## Usage Examples ### Read-only game console ```tsx setConnectionStatus(s)} /> ``` ### Interactive dev container shell ```tsx const [sendFn, setSendFn] = useState<((data: string) => void) | null>(null); setSendFn(() => fn)} onStatus={(s) => setConnectionStatus(s)} /> // Send a command programmatically ``` --- ## Dependencies ```json "@xterm/xterm": "^5.x", "@xterm/addon-fit": "^0.10.x" ``` The CSS import (`@xterm/xterm/css/xterm.css`) is required for correct rendering. Without it, the terminal renders but loses scroll behavior and cursor styling. --- ## Related - Agent PTY implementation: `zlh-agent` — server process is forked with a PTY attached, raw output streams over the WebSocket - WebSocket proxy: API (`zlh-api`) proxies the WebSocket connection from frontend to agent — frontend never connects to the agent directly (DEC-008) - Console stability work: `zlh-grind/SESSION_LOG.md` — Feb 7-8 session covers single-writer pattern and PTY ownership fixes