inspector: use websocket terminal API

This commit is contained in:
Nathan Flurry 2026-03-06 12:33:23 -08:00
parent 0171e33873
commit 9e2e57b469

View file

@ -3,6 +3,35 @@ import { FitAddon, Terminal, init } from "ghostty-web";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { SandboxAgent } from "sandbox-agent"; 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"; type ConnectionState = "connecting" | "ready" | "closed" | "error";
const terminalTheme = { const terminalTheme = {
@ -47,19 +76,21 @@ const GhosttyTerminal = ({
let cancelled = false; let cancelled = false;
let terminal: Terminal | null = null; let terminal: Terminal | null = null;
let fitAddon: FitAddon | null = null; let fitAddon: FitAddon | null = null;
let session: ReturnType<SandboxAgent["connectProcessTerminal"]> | null = null; let socket: WebSocket | null = null;
let resizeRaf = 0; let resizeRaf = 0;
let removeDataListener: { dispose(): void } | null = null; let removeDataListener: { dispose(): void } | null = null;
let removeResizeListener: { dispose(): void } | null = null; let removeResizeListener: { dispose(): void } | null = null;
const syncSize = () => { const syncSize = () => {
if (!terminal || !session) { if (!terminal || !socket || socket.readyState !== WebSocket.OPEN) {
return; return;
} }
session.resize({ const frame: ProcessTerminalClientFrame = {
type: "resize",
cols: terminal.cols, cols: terminal.cols,
rows: terminal.rows, rows: terminal.rows,
}); };
socket.send(JSON.stringify(frame));
}; };
const connect = async () => { const connect = async () => {
@ -87,7 +118,14 @@ const GhosttyTerminal = ({
terminal.focus(); terminal.focus();
removeDataListener = terminal.onData((data) => { 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(() => { removeResizeListener = terminal.onResize(() => {
@ -97,50 +135,64 @@ const GhosttyTerminal = ({
resizeRaf = window.requestAnimationFrame(syncSize); resizeRaf = window.requestAnimationFrame(syncSize);
}); });
const nextSession = client.connectProcessTerminal(processId); const nextSocket = client.connectProcessTerminalWebSocket(processId);
session = nextSession; nextSocket.binaryType = "arraybuffer";
socket = nextSocket;
nextSession.onReady((frame) => { nextSocket.addEventListener("message", async (event) => {
if (cancelled) { if (cancelled) {
return; return;
} }
if (frame.type === "ready") {
setConnectionState("ready");
setStatusMessage("Connected");
syncSize();
}
});
nextSession.onData((bytes) => { if (typeof event.data === "string") {
if (cancelled || !terminal) { 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; return;
} }
terminal.write(bytes);
});
nextSession.onExit((frame) => { if (!terminal) {
if (cancelled) {
return; return;
} }
if (frame.type === "exit") {
setConnectionState("closed"); const bytes = await decodeBinaryFrame(event.data);
setExitCode(frame.exitCode ?? null); if (!cancelled) {
setStatusMessage( terminal.write(bytes);
frame.exitCode == null ? "Process exited." : `Process exited with code ${frame.exitCode}.`
);
onExit?.();
} }
}); });
nextSession.onError((error) => { nextSocket.addEventListener("error", () => {
if (cancelled) { if (cancelled) {
return; return;
} }
setConnectionState("error"); setConnectionState("error");
setStatusMessage(error instanceof Error ? error.message : error.message); setStatusMessage("Terminal websocket connection failed.");
}); });
nextSession.onClose(() => { nextSocket.addEventListener("close", () => {
if (cancelled) { if (cancelled) {
return; return;
} }
@ -165,7 +217,11 @@ const GhosttyTerminal = ({
} }
removeDataListener?.dispose(); removeDataListener?.dispose();
removeResizeListener?.dispose(); removeResizeListener?.dispose();
session?.close(); if (socket?.readyState === WebSocket.OPEN) {
const frame: ProcessTerminalClientFrame = { type: "close" };
socket.send(JSON.stringify(frame));
}
socket?.close();
terminal?.dispose(); terminal?.dispose();
}; };
}, [client, onExit, processId]); }, [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<Uint8Array> {
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; export default GhosttyTerminal;