mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 15:01:26 +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 { 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,31 +135,30 @@ 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 (typeof event.data === "string") {
|
||||||
|
const frame = parseServerFrame(event.data);
|
||||||
|
if (!frame) {
|
||||||
|
setConnectionState("error");
|
||||||
|
setStatusMessage("Received invalid terminal control frame.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (frame.type === "ready") {
|
if (frame.type === "ready") {
|
||||||
setConnectionState("ready");
|
setConnectionState("ready");
|
||||||
setStatusMessage("Connected");
|
setStatusMessage("Connected");
|
||||||
syncSize();
|
syncSize();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
nextSession.onData((bytes) => {
|
|
||||||
if (cancelled || !terminal) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
terminal.write(bytes);
|
|
||||||
});
|
|
||||||
|
|
||||||
nextSession.onExit((frame) => {
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (frame.type === "exit") {
|
if (frame.type === "exit") {
|
||||||
setConnectionState("closed");
|
setConnectionState("closed");
|
||||||
setExitCode(frame.exitCode ?? null);
|
setExitCode(frame.exitCode ?? null);
|
||||||
|
|
@ -129,18 +166,33 @@ const GhosttyTerminal = ({
|
||||||
frame.exitCode == null ? "Process exited." : `Process exited with code ${frame.exitCode}.`
|
frame.exitCode == null ? "Process exited." : `Process exited with code ${frame.exitCode}.`
|
||||||
);
|
);
|
||||||
onExit?.();
|
onExit?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnectionState("error");
|
||||||
|
setStatusMessage(frame.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!terminal) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = await decodeBinaryFrame(event.data);
|
||||||
|
if (!cancelled) {
|
||||||
|
terminal.write(bytes);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue