From 705f9c04e3fad275020c5394c10a27cbf99213e6 Mon Sep 17 00:00:00 2001 From: jester Date: Sun, 22 Feb 2026 20:29:07 +0000 Subject: [PATCH] docs: add TerminalView component reference with architecture notes --- Frontend/TerminalView_Component.md | 228 +++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 Frontend/TerminalView_Component.md diff --git a/Frontend/TerminalView_Component.md b/Frontend/TerminalView_Component.md new file mode 100644 index 0000000..efb7d78 --- /dev/null +++ b/Frontend/TerminalView_Component.md @@ -0,0 +1,228 @@ +# 🖥️ 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