mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 06:04:43 +00:00
inspector: use websocket terminal API
This commit is contained in:
parent
0171e33873
commit
9e2e57b469
1 changed files with 141 additions and 31 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue