From 9e2e57b4691042169e641dbf8251173fd645ec42 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Fri, 6 Mar 2026 12:33:23 -0800 Subject: [PATCH] inspector: use websocket terminal API --- .../components/processes/GhosttyTerminal.tsx | 172 ++++++++++++++---- 1 file changed, 141 insertions(+), 31 deletions(-) diff --git a/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx b/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx index 858b7c1..13ab86d 100644 --- a/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx +++ b/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx @@ -3,6 +3,35 @@ import { FitAddon, Terminal, init } from "ghostty-web"; import { useEffect, useRef, useState } from "react"; import type { SandboxAgent } from "sandbox-agent"; +type ProcessTerminalClientFrame = + | { + type: "input"; + data: string; + encoding?: string; + } + | { + type: "resize"; + cols: number; + rows: number; + } + | { + type: "close"; + }; + +type ProcessTerminalServerFrame = + | { + type: "ready"; + processId: string; + } + | { + type: "exit"; + exitCode?: number | null; + } + | { + type: "error"; + message: string; + }; + type ConnectionState = "connecting" | "ready" | "closed" | "error"; const terminalTheme = { @@ -47,19 +76,21 @@ const GhosttyTerminal = ({ let cancelled = false; let terminal: Terminal | null = null; let fitAddon: FitAddon | null = null; - let session: ReturnType | null = null; + let socket: WebSocket | null = null; let resizeRaf = 0; let removeDataListener: { dispose(): void } | null = null; let removeResizeListener: { dispose(): void } | null = null; const syncSize = () => { - if (!terminal || !session) { + if (!terminal || !socket || socket.readyState !== WebSocket.OPEN) { return; } - session.resize({ + const frame: ProcessTerminalClientFrame = { + type: "resize", cols: terminal.cols, rows: terminal.rows, - }); + }; + socket.send(JSON.stringify(frame)); }; const connect = async () => { @@ -87,7 +118,14 @@ const GhosttyTerminal = ({ terminal.focus(); removeDataListener = terminal.onData((data) => { - session?.sendInput(data); + if (!socket || socket.readyState !== WebSocket.OPEN) { + return; + } + const frame: ProcessTerminalClientFrame = { + type: "input", + data, + }; + socket.send(JSON.stringify(frame)); }); removeResizeListener = terminal.onResize(() => { @@ -97,50 +135,64 @@ const GhosttyTerminal = ({ resizeRaf = window.requestAnimationFrame(syncSize); }); - const nextSession = client.connectProcessTerminal(processId); - session = nextSession; + const nextSocket = client.connectProcessTerminalWebSocket(processId); + nextSocket.binaryType = "arraybuffer"; + socket = nextSocket; - nextSession.onReady((frame) => { + nextSocket.addEventListener("message", async (event) => { if (cancelled) { return; } - if (frame.type === "ready") { - setConnectionState("ready"); - setStatusMessage("Connected"); - syncSize(); - } - }); - nextSession.onData((bytes) => { - if (cancelled || !terminal) { + if (typeof event.data === "string") { + const frame = parseServerFrame(event.data); + if (!frame) { + setConnectionState("error"); + setStatusMessage("Received invalid terminal control frame."); + return; + } + + if (frame.type === "ready") { + setConnectionState("ready"); + setStatusMessage("Connected"); + syncSize(); + return; + } + + if (frame.type === "exit") { + setConnectionState("closed"); + setExitCode(frame.exitCode ?? null); + setStatusMessage( + frame.exitCode == null ? "Process exited." : `Process exited with code ${frame.exitCode}.` + ); + onExit?.(); + return; + } + + setConnectionState("error"); + setStatusMessage(frame.message); return; } - terminal.write(bytes); - }); - nextSession.onExit((frame) => { - if (cancelled) { + if (!terminal) { return; } - if (frame.type === "exit") { - setConnectionState("closed"); - setExitCode(frame.exitCode ?? null); - setStatusMessage( - frame.exitCode == null ? "Process exited." : `Process exited with code ${frame.exitCode}.` - ); - onExit?.(); + + const bytes = await decodeBinaryFrame(event.data); + if (!cancelled) { + terminal.write(bytes); } }); - nextSession.onError((error) => { + nextSocket.addEventListener("error", () => { if (cancelled) { return; } setConnectionState("error"); - setStatusMessage(error instanceof Error ? error.message : error.message); + setStatusMessage("Terminal websocket connection failed."); }); - nextSession.onClose(() => { + nextSocket.addEventListener("close", () => { if (cancelled) { return; } @@ -165,7 +217,11 @@ const GhosttyTerminal = ({ } removeDataListener?.dispose(); removeResizeListener?.dispose(); - session?.close(); + if (socket?.readyState === WebSocket.OPEN) { + const frame: ProcessTerminalClientFrame = { type: "close" }; + socket.send(JSON.stringify(frame)); + } + socket?.close(); terminal?.dispose(); }; }, [client, onExit, processId]); @@ -197,4 +253,58 @@ const GhosttyTerminal = ({ ); }; +function parseServerFrame(payload: string): ProcessTerminalServerFrame | null { + try { + const parsed = JSON.parse(payload) as unknown; + if (!parsed || typeof parsed !== "object" || !("type" in parsed)) { + return null; + } + + if ( + parsed.type === "ready" && + "processId" in parsed && + typeof parsed.processId === "string" + ) { + return parsed as ProcessTerminalServerFrame; + } + + if ( + parsed.type === "exit" && + (!("exitCode" in parsed) || + parsed.exitCode == null || + typeof parsed.exitCode === "number") + ) { + return parsed as ProcessTerminalServerFrame; + } + + if ( + parsed.type === "error" && + "message" in parsed && + typeof parsed.message === "string" + ) { + return parsed as ProcessTerminalServerFrame; + } + } catch { + return null; + } + + return null; +} + +async function decodeBinaryFrame(data: unknown): Promise { + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice(); + } + + if (typeof Blob !== "undefined" && data instanceof Blob) { + return new Uint8Array(await data.arrayBuffer()); + } + + throw new Error(`Unsupported terminal payload: ${String(data)}`); +} + export default GhosttyTerminal;