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 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<SandboxAgent["connectProcessTerminal"]> | 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<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;