/** * ChatPage — embeds `hermes --tui` inside the dashboard. * *
(dashboard chrome) . * └─
(rounded, dark bg, padded — the "terminal window" . * look that gives the page a distinct visual identity) . * └─ @xterm/xterm Terminal (WebGL renderer, Unicode 11 widths) . * │ onData keystrokes → WebSocket → PTY master . * │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` . * │ write(data) PTY output bytes → VT100 parser . * ▼ . * WebSocket /api/pty?token= . * ▼ . * FastAPI pty_ws (hermes_cli/web_server.py) . * ▼ . * POSIX PTY → `node ui-tui/dist/entry.js` → tui_gateway + AIAgent . */ import { FitAddon } from "@xterm/addon-fit"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { Typography } from "@nous-research/ui"; import { cn } from "@/lib/utils"; import { Copy, PanelRight, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useSearchParams } from "react-router-dom"; import { ChatSidebar } from "@/components/ChatSidebar"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; function buildWsUrl( token: string, resume: string | null, channel: string, ): string { const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; const qs = new URLSearchParams({ token, channel }); if (resume) qs.set("resume", resume); return `${proto}//${window.location.host}/api/pty?${qs.toString()}`; } // Channel id ties this chat tab's PTY child (publisher) to its sidebar // (subscriber). Generated once per mount so a tab refresh starts a fresh // channel — the previous PTY child terminates with the old WS, and its // channel auto-evicts when no subscribers remain. function generateChannelId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); } return `chat-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`; } // Colors for the terminal body. Matches the dashboard's dark teal canvas // with cream foreground — we intentionally don't pick monokai or a loud // theme, because the TUI's skin engine already paints the content; the // terminal chrome just needs to sit quietly inside the dashboard. const TERMINAL_THEME = { background: "#0d2626", foreground: "#f0e6d2", cursor: "#f0e6d2", cursorAccent: "#0d2626", selectionBackground: "#f0e6d244", }; /** * CSS width for xterm font tiers. * * Prefer the terminal host's `clientWidth` — Chrome DevTools device mode often * keeps `window.innerWidth` at the full desktop value while the *drawn* layout * is phone-sized, which made us pick desktop font sizes (~14px) and look huge. */ function terminalTierWidthPx(host: HTMLElement | null): number { if (typeof window === "undefined") return 1280; const fromHost = host?.clientWidth ?? 0; if (fromHost > 2) return Math.round(fromHost); const doc = document.documentElement?.clientWidth ?? 0; const vv = window.visualViewport; const inner = window.innerWidth; const vvw = vv?.width ?? inner; const layout = Math.min(inner, vvw, doc > 0 ? doc : inner); return Math.max(1, Math.round(layout)); } function terminalFontSizeForWidth(layoutWidthPx: number): number { if (layoutWidthPx < 300) return 7; if (layoutWidthPx < 360) return 8; if (layoutWidthPx < 420) return 9; if (layoutWidthPx < 520) return 10; if (layoutWidthPx < 720) return 11; if (layoutWidthPx < 1024) return 12; return 14; } function terminalLineHeightForWidth(layoutWidthPx: number): number { return layoutWidthPx < 1024 ? 1.02 : 1.15; } export default function ChatPage() { const hostRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const wsRef = useRef(null); const [searchParams] = useSearchParams(); // Lazy-init: the missing-token check happens at construction so the effect // body doesn't have to setState (React 19's set-state-in-effect rule). const [banner, setBanner] = useState(() => typeof window !== "undefined" && !window.__HERMES_SESSION_TOKEN__ ? "Session token unavailable. Open this page through `hermes dashboard`, not directly." : null, ); const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); const copyResetRef = useRef | null>(null); const [mobilePanelOpen, setMobilePanelOpen] = useState(false); const { setEnd } = usePageHeader(); const { t } = useI18n(); const closeMobilePanel = useCallback(() => setMobilePanelOpen(false), []); const modelToolsLabel = useMemo( () => `${t.app.modelToolsSheetTitle} ${t.app.modelToolsSheetSubtitle}`, [t.app.modelToolsSheetSubtitle, t.app.modelToolsSheetTitle], ); const [portalRoot] = useState(() => typeof document !== "undefined" ? document.body : null, ); const [narrow, setNarrow] = useState(() => typeof window !== "undefined" ? window.matchMedia("(max-width: 1023px)").matches : false, ); const resumeRef = useRef(searchParams.get("resume")); const channel = useMemo(() => generateChannelId(), []); useEffect(() => { const mql = window.matchMedia("(max-width: 1023px)"); const sync = () => setNarrow(mql.matches); sync(); mql.addEventListener("change", sync); return () => mql.removeEventListener("change", sync); }, []); useEffect(() => { if (!mobilePanelOpen) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") closeMobilePanel(); }; document.addEventListener("keydown", onKey); const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; }; }, [mobilePanelOpen, closeMobilePanel]); useEffect(() => { const mql = window.matchMedia("(min-width: 1024px)"); const onChange = (e: MediaQueryListEvent) => { if (e.matches) setMobilePanelOpen(false); }; mql.addEventListener("change", onChange); return () => mql.removeEventListener("change", onChange); }, []); useEffect(() => { if (!narrow) { setEnd(null); return; } setEnd( , ); return () => setEnd(null); }, [narrow, mobilePanelOpen, modelToolsLabel, setEnd]); const handleCopyLast = () => { const ws = wsRef.current; if (!ws || ws.readyState !== WebSocket.OPEN) return; // Send the slash as a burst, wait long enough for Ink's tokenizer to // emit a keypress event for each character (not coalesce them into a // paste), then send Return as its own event. The timing here is // empirical — 100ms is safely past Node's default stdin coalescing // window and well inside UI responsiveness. ws.send("/copy"); setTimeout(() => { const s = wsRef.current; if (s && s.readyState === WebSocket.OPEN) s.send("\r"); }, 100); setCopyState("copied"); if (copyResetRef.current) clearTimeout(copyResetRef.current); copyResetRef.current = setTimeout(() => setCopyState("idle"), 1500); termRef.current?.focus(); }; useEffect(() => { const host = hostRef.current; if (!host) return; const token = window.__HERMES_SESSION_TOKEN__; // Banner already initialised above; just bail before wiring xterm/WS. if (!token) { return; } const tierW0 = terminalTierWidthPx(host); const term = new Terminal({ allowProposedApi: true, cursorBlink: true, fontFamily: "'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace", fontSize: terminalFontSizeForWidth(tierW0), lineHeight: terminalLineHeightForWidth(tierW0), letterSpacing: 0, fontWeight: "400", fontWeightBold: "700", macOptionIsMeta: true, scrollback: 0, theme: TERMINAL_THEME, }); termRef.current = term; // --- Clipboard integration --------------------------------------- // // Three independent paths all route to the system clipboard: // // 1. **Selection → Ctrl+C (or Cmd+C on macOS).** Ink's own handler // in useInputHandlers.ts turns Ctrl+C into a copy when the // terminal has a selection, then emits an OSC 52 escape. Our // OSC 52 handler below decodes that escape and writes to the // browser clipboard — so the flow works just like it does in // `hermes --tui`. // // 2. **Ctrl/Cmd+Shift+C.** Belt-and-suspenders shortcut that // operates directly on xterm's selection, useful if the TUI // ever stops listening (e.g. overlays / pickers) or if the user // has selected with the mouse outside of Ink's selection model. // // 3. **Ctrl/Cmd+Shift+V.** Reads the system clipboard and feeds // it to the terminal as keyboard input. xterm's paste() wraps // it with bracketed-paste if the host has that mode enabled. // // OSC 52 reads (terminal asking to read the clipboard) are not // supported — that would let any content the TUI renders exfiltrate // the user's clipboard. term.parser.registerOscHandler(52, (data) => { // Format: ";" const semi = data.indexOf(";"); if (semi < 0) return false; const payload = data.slice(semi + 1); if (payload === "?" || payload === "") return false; // read/clear — ignore try { // atob returns a binary string (one byte per char); we need UTF-8 // decode so multi-byte codepoints (≥, →, emoji, CJK) round-trip // correctly. Without this step, the three UTF-8 bytes of `≥` // would land in the clipboard as the three separate Latin-1 // characters `≥`. const binary = atob(payload); const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); const text = new TextDecoder("utf-8").decode(bytes); navigator.clipboard.writeText(text).catch(() => {}); } catch { // Malformed base64 — silently drop. } return true; }); const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform); term.attachCustomKeyEventHandler((ev) => { if (ev.type !== "keydown") return true; const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey; if (copyModifier && ev.key.toLowerCase() === "c") { const sel = term.getSelection(); if (sel) { navigator.clipboard.writeText(sel).catch(() => {}); ev.preventDefault(); return false; } } if (pasteModifier && ev.key.toLowerCase() === "v") { navigator.clipboard .readText() .then((text) => { if (text) term.paste(text); }) .catch(() => {}); ev.preventDefault(); return false; } return true; }); const fit = new FitAddon(); fitRef.current = fit; term.loadAddon(fit); const unicode11 = new Unicode11Addon(); term.loadAddon(unicode11); term.unicode.activeVersion = "11"; term.loadAddon(new WebLinksAddon()); term.open(host); // WebGL draws from a texture atlas sized with device pixels. On phones and // in DevTools device mode that often produces *visually* much larger cells // than `fontSize` suggests — users see "huge" text even at 7–9px settings. // The canvas/DOM renderer tracks `fontSize` faithfully; use it for narrow // hosts. Wide layouts still get WebGL for crisp box-drawing. const useWebgl = terminalTierWidthPx(host) >= 768; if (useWebgl) { try { const webgl = new WebglAddon(); webgl.onContextLoss(() => webgl.dispose()); term.loadAddon(webgl); } catch (err) { console.warn( "[hermes-chat] WebGL renderer unavailable; falling back to default", err, ); } } // Initial fit + resize observer. fit.fit() reads the container's // current bounding box and resizes the terminal grid to match. // // The subtle bit: the dashboard has CSS transitions on the container // (backdrop fade-in, rounded corners settling as fonts load). If we // call fit() at mount time, the bounding box we measure is often 1-2 // cell widths off from the final size. ResizeObserver *does* fire // when the container settles, but if the pixel delta happens to be // smaller than one cell's width, fit() computes the same integer // (cols, rows) as before and doesn't emit onResize — so the PTY // never learns the final size. Users see truncated long lines until // they resize the browser window. // // We force one extra fit + explicit RESIZE send after two animation // frames. rAF→rAF guarantees one layout commit between the two // callbacks, giving CSS transitions and font metrics time to finalize // before we take the authoritative measurement. let hostSyncRaf = 0; const scheduleHostSync = () => { if (hostSyncRaf) return; hostSyncRaf = requestAnimationFrame(() => { hostSyncRaf = 0; syncTerminalMetrics(); }); }; let metricsDebounce: ReturnType | null = null; const syncTerminalMetrics = () => { const w = terminalTierWidthPx(host); const nextSize = terminalFontSizeForWidth(w); const nextLh = terminalLineHeightForWidth(w); const fontChanged = term.options.fontSize !== nextSize || term.options.lineHeight !== nextLh; if (fontChanged) { term.options.fontSize = nextSize; term.options.lineHeight = nextLh; } try { fit.fit(); } catch { return; } if (fontChanged && term.rows > 0) { try { term.refresh(0, term.rows - 1); } catch { /* ignore */ } } if ( fontChanged && wsRef.current && wsRef.current.readyState === WebSocket.OPEN ) { wsRef.current.send(`\x1b[RESIZE:${term.cols};${term.rows}]`); } }; const scheduleSyncTerminalMetrics = () => { if (metricsDebounce) clearTimeout(metricsDebounce); metricsDebounce = setTimeout(() => { metricsDebounce = null; syncTerminalMetrics(); }, 60); }; const ro = new ResizeObserver(() => scheduleHostSync()); ro.observe(host); window.addEventListener("resize", scheduleSyncTerminalMetrics); window.visualViewport?.addEventListener("resize", scheduleSyncTerminalMetrics); window.visualViewport?.addEventListener("scroll", scheduleSyncTerminalMetrics); scheduleHostSync(); requestAnimationFrame(() => scheduleHostSync()); // Double-rAF authoritative fit. On the second frame the layout has // committed at least once since mount; fit.fit() then reads the // stable container size. We always send a RESIZE escape afterwards // (even if fit's cols/rows didn't change, so the PTY has the same // dims registered as our JS state — prevents a drift where Ink // thinks the terminal is one col bigger than what's on screen). let settleRaf1 = 0; let settleRaf2 = 0; settleRaf1 = requestAnimationFrame(() => { settleRaf1 = 0; settleRaf2 = requestAnimationFrame(() => { settleRaf2 = 0; syncTerminalMetrics(); }); }); // WebSocket const url = buildWsUrl(token, resumeRef.current, channel); const ws = new WebSocket(url); ws.binaryType = "arraybuffer"; wsRef.current = ws; // Suppress banner/terminal side-effects when cleanup() calls `ws.close()` // (React StrictMode remount, route change) so we never write to a // disposed xterm or setState on an unmounted tree. let unmounting = false; ws.onopen = () => { setBanner(null); // Send the initial RESIZE immediately so Ink has *a* size to lay // out against on its first paint. The double-rAF block above will // follow up with the authoritative measurement — at worst Ink // reflows once after the PTY boots, which is imperceptible. ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`); }; ws.onmessage = (ev) => { if (typeof ev.data === "string") { term.write(ev.data); } else { term.write(new Uint8Array(ev.data as ArrayBuffer)); } }; ws.onclose = (ev) => { wsRef.current = null; if (unmounting) { return; } if (ev.code === 4401) { setBanner("Auth failed. Reload the page to refresh the session token."); return; } if (ev.code === 4403) { setBanner("Chat is only reachable from localhost."); return; } if (ev.code === 1011) { // Server already wrote an ANSI error frame. return; } term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n"); }; // Keystrokes + mouse events → PTY, with cell-level dedup for motion. // // Ink enables `\x1b[?1003h` (any-motion tracking), which asks the // terminal to report every mouse-move as an SGR mouse event even with // no button held. xterm.js happily emits one report per pixel of // mouse motion; without deduping, a casual mouse-over floods Ink with // hundreds of redraw-triggering reports and the UI goes laggy // (scrolling stutters, clicks land on stale positions by the time // Ink finishes processing the motion backlog). // // We keep track of the last cell we reported a motion for. Press, // release, and wheel events always pass through; motion events only // pass through if the cell changed. Parsing is cheap — SGR reports // are short literal strings. // eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/; let lastMotionCell = { col: -1, row: -1 }; let lastMotionCb = -1; const onDataDisposable = term.onData((data) => { if (ws.readyState !== WebSocket.OPEN) return; const m = SGR_MOUSE_RE.exec(data); if (m) { const cb = parseInt(m[1], 10); const col = parseInt(m[2], 10); const row = parseInt(m[3], 10); const released = m[4] === "m"; // Motion events have bit 0x20 (32) set in the button code. // Wheel events have bit 0x40 (64); always forward wheel. const isMotion = (cb & 0x20) !== 0 && (cb & 0x40) === 0; const isWheel = (cb & 0x40) !== 0; if (isMotion && !isWheel && !released) { if ( col === lastMotionCell.col && row === lastMotionCell.row && cb === lastMotionCb ) { return; // same cell + same button state; skip redundant report } lastMotionCell = { col, row }; lastMotionCb = cb; } else { // Non-motion event (press, release, wheel) — reset dedup state // so the next motion after this always reports. lastMotionCell = { col: -1, row: -1 }; lastMotionCb = -1; } } ws.send(data); }); const onResizeDisposable = term.onResize(({ cols, rows }) => { if (ws.readyState === WebSocket.OPEN) { ws.send(`\x1b[RESIZE:${cols};${rows}]`); } }); term.focus(); return () => { unmounting = true; onDataDisposable.dispose(); onResizeDisposable.dispose(); if (metricsDebounce) clearTimeout(metricsDebounce); window.removeEventListener("resize", scheduleSyncTerminalMetrics); window.visualViewport?.removeEventListener( "resize", scheduleSyncTerminalMetrics, ); window.visualViewport?.removeEventListener( "scroll", scheduleSyncTerminalMetrics, ); ro.disconnect(); if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf); if (settleRaf1) cancelAnimationFrame(settleRaf1); if (settleRaf2) cancelAnimationFrame(settleRaf2); ws.close(); wsRef.current = null; term.dispose(); termRef.current = null; fitRef.current = null; if (copyResetRef.current) { clearTimeout(copyResetRef.current); copyResetRef.current = null; } }; }, [channel]); // Layout: // outer flex column — sits inside the dashboard's content area // row split — terminal pane (flex-1) + sidebar (fixed width, lg+) // terminal wrapper — rounded, dark, padded — the "terminal window" // floating copy button — bottom-right corner, transparent with a // subtle border; stays out of the way until hovered. Sends // `/copy\n` to Ink, which emits OSC 52 → our clipboard handler. // sidebar — ChatSidebar opens its own JSON-RPC sidecar; renders // model badge, tool-call list, model picker. Best-effort: if the // sidecar fails to connect the terminal pane keeps working. // // `normal-case` opts out of the dashboard's global `uppercase` rule on // the root `
` in App.tsx — terminal output must preserve case. // // Mobile model/tools sheet is portaled to `document.body` so it stacks // above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main // dashboard column uses `relative z-2`, which traps `position:fixed` // descendants below those layers (see Toast.tsx). const mobileModelToolsPortal = narrow && portalRoot && createPortal( <> {mobilePanelOpen && (
, portalRoot, ); return (
{mobileModelToolsPortal} {banner && (
{banner}
)}
{!narrow && ( )}
); } declare global { interface Window { __HERMES_SESSION_TOKEN__?: string; } }