/** Route only validated xterm wheel reports to an alternate-screen dashboard PTY. */ const fs = require("node:fs"); const path = process.env.HERMES_CHAT_PAGE_PATH || "/opt/hermes/web/src/pages/ChatPage.tsx"; let source = fs.readFileSync(path, "utf8"); function replaceOnce(before, after, label) { const count = source.split(before).length - 1; if (count !== 1) { throw new Error(`${label} patch context changed: expected 1, found ${count}`); } source = source.replace(before, after); } replaceOnce( 'import { api } from "@/lib/api";\n', 'import { api } from "@/lib/api";\n' + 'import { isSgrWheelReport } from "@/lib/dashboard-terminal-input";\n', "dashboard wheel input import", ); replaceOnce( ` // Dashboard chat should scroll the browser-side transcript, not send // mouse-wheel protocol bytes through the PTY. term.attachCustomWheelEventHandler((ev) => { const delta = ev.deltaY; if (!delta) { return false; } const step = Math.max(1, Math.round(Math.abs(delta) / 50)); term.scrollLines(delta > 0 ? step : -step); ev.preventDefault(); ev.stopPropagation(); return false; }); `, "", "browser-local wheel handler", ); replaceOnce( ` // Browser-embedded chat runs the TUI in inline mode. Keep transcript // history in xterm.js so the browser wheel can scroll it directly. scrollback: 5000, `, ` // Alternate-screen PTYs keep their own visible history. Retain a // modest xterm scrollback only for terminal protocol compatibility. scrollback: 5000, `, "dashboard alternate-screen scrollback comment", ); const inputCommentStart = source.indexOf(" // Keystrokes → PTY."); const inputCommentEnd = source.indexOf( " // eslint-disable-next-line no-control-regex", inputCommentStart, ); if (inputCommentStart < 0 || inputCommentEnd < 0) { throw new Error("dashboard PTY input comment patch context changed"); } source = source.slice(0, inputCommentStart) + ` // Keystrokes and paste retain their existing PTY path. Mouse reports // are fail-closed below: only a bounded SGR wheel report can pass. ` + source.slice(inputCommentEnd); replaceOnce( String.raw` // 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])$/; onDataDisposable = term.onData((data) => { if (ws.readyState !== WebSocket.OPEN) return; if (SGR_MOUSE_RE.test(data)) { return; } ws.send(data); }); `, String.raw` onDataDisposable = term.onData((data) => { if (ws.readyState !== WebSocket.OPEN) return; if (data.startsWith("\x1b[<") && !isSgrWheelReport(data)) return; if (data.startsWith("\x1b[M")) return; ws.send(data); }); `, "SGR wheel relay", ); if (source.includes("attachCustomWheelEventHandler")) { throw new Error("dashboard wheel interception remained after patch"); } fs.writeFileSync(path, source);