From 9cca6e3e8765309d2ae58df3af2eb81cd2745cbf Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sat, 7 Mar 2026 16:50:24 -0800 Subject: [PATCH] Add reusable React terminal component --- CLAUDE.md | 1 + docs/docs.json | 1 + docs/inspector.mdx | 3 +- docs/processes.mdx | 2 +- docs/react-components.mdx | 103 +++++++++++ docs/sdk-overview.mdx | 7 +- frontend/packages/inspector/index.html | 52 ------ frontend/packages/inspector/package.json | 8 +- .../src/components/debug/ProcessesTab.tsx | 5 +- pnpm-lock.yaml | 38 ++-- sdks/react/package.json | 40 +++++ .../react/src/ProcessTerminal.tsx | 167 +++++++++++++----- sdks/react/src/index.ts | 6 + sdks/react/tsconfig.json | 17 ++ sdks/react/tsup.config.ts | 10 ++ 15 files changed, 338 insertions(+), 122 deletions(-) create mode 100644 docs/react-components.mdx create mode 100644 sdks/react/package.json rename frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx => sdks/react/src/ProcessTerminal.tsx (51%) create mode 100644 sdks/react/src/index.ts create mode 100644 sdks/react/tsconfig.json create mode 100644 sdks/react/tsup.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4ceb0f6..866a3f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,6 +109,7 @@ - `docs/cli.mdx` - `docs/quickstart.mdx` - `docs/sdk-overview.mdx` + - `docs/react-components.mdx` - `docs/session-persistence.mdx` - `docs/deploy/local.mdx` - `docs/deploy/cloudflare.mdx` diff --git a/docs/docs.json b/docs/docs.json index b4d1a82..2d57276 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -51,6 +51,7 @@ "pages": [ "quickstart", "sdk-overview", + "react-components", { "group": "Deploy", "icon": "server", diff --git a/docs/inspector.mdx b/docs/inspector.mdx index cf0c28c..06318b2 100644 --- a/docs/inspector.mdx +++ b/docs/inspector.mdx @@ -48,5 +48,4 @@ console.log(url); The Inspector includes an embedded Ghostty-based terminal for interactive tty processes. The UI uses the SDK's high-level `connectProcessTerminal(...)` -wrapper rather than wiring raw websocket frames directly. The implementation is -available in the [Inspector's GhosttyTerminal component](https://github.com/rivet-dev/sandbox-agent/blob/396043a5b33ff2099e4d09c2a4fd816a34358558/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx). +wrapper via the shared `@sandbox-agent/react` `ProcessTerminal` component. diff --git a/docs/processes.mdx b/docs/processes.mdx index 3a89ee7..45c246c 100644 --- a/docs/processes.mdx +++ b/docs/processes.mdx @@ -230,7 +230,7 @@ Since the browser WebSocket API cannot send custom headers, the endpoint accepts ### Browser terminal emulators -The terminal session works with any browser terminal emulator like ghostty-web or xterm.js. For a complete example using ghostty-web, see the [Inspector's GhosttyTerminal component](https://github.com/rivet-dev/sandbox-agent/blob/396043a5b33ff2099e4d09c2a4fd816a34358558/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx). +The terminal session works with any browser terminal emulator like ghostty-web or xterm.js. For a drop-in React terminal, see [React Components](/react-components). ## Configuration diff --git a/docs/react-components.mdx b/docs/react-components.mdx new file mode 100644 index 0000000..e7298d0 --- /dev/null +++ b/docs/react-components.mdx @@ -0,0 +1,103 @@ +--- +title: "React Components" +description: "Drop-in React components for Sandbox Agent frontends." +icon: "react" +--- + +`@sandbox-agent/react` exposes small React components built on top of the `sandbox-agent` SDK. + +## Install + +```bash +npm install @sandbox-agent/react@0.2.x +``` + +## Full example + +This example connects to a running Sandbox Agent server, starts a tty shell, renders `ProcessTerminal`, and cleans up the process when the component unmounts. + +```tsx TerminalPane.tsx expandable highlight={5,32-36,71} +"use client"; + +import { useEffect, useState } from "react"; +import { SandboxAgent } from "sandbox-agent"; +import { ProcessTerminal } from "@sandbox-agent/react"; + +export default function TerminalPane() { + const [client, setClient] = useState(null); + const [processId, setProcessId] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + let sdk: SandboxAgent | null = null; + let createdProcessId: string | null = null; + + const cleanup = async () => { + if (!sdk || !createdProcessId) { + return; + } + + await sdk.killProcess(createdProcessId, { waitMs: 1_000 }).catch(() => {}); + await sdk.deleteProcess(createdProcessId).catch(() => {}); + }; + + const start = async () => { + try { + sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + }); + + const process = await sdk.createProcess({ + command: "sh", + interactive: true, + tty: true, + }); + + if (cancelled) { + createdProcessId = process.id; + await cleanup(); + await sdk.dispose(); + return; + } + + createdProcessId = process.id; + setClient(sdk); + setProcessId(process.id); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to start terminal."; + setError(message); + } + }; + + void start(); + + return () => { + cancelled = true; + void cleanup(); + void sdk?.dispose(); + }; + }, []); + + if (error) { + return
{error}
; + } + + if (!client || !processId) { + return
Starting terminal...
; + } + + return ; +} +``` + +## Component + +`ProcessTerminal` attaches to a running tty process. + +- `client`: a `SandboxAgent` client +- `processId`: the process to attach to +- `height`, `style`, `terminalStyle`: optional layout overrides +- `onExit`, `onError`: optional lifecycle callbacks + +See [Processes](/processes) for the lower-level terminal APIs. diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx index ffb1a6b..627e440 100644 --- a/docs/sdk-overview.mdx +++ b/docs/sdk-overview.mdx @@ -29,6 +29,12 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class. npm install @sandbox-agent/persist-indexeddb@0.2.x @sandbox-agent/persist-sqlite@0.2.x @sandbox-agent/persist-postgres@0.2.x ``` +## Optional React components + +```bash +npm install @sandbox-agent/react@0.2.x +``` + ## Create a client ```ts @@ -206,4 +212,3 @@ Parameters: - `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls - `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait - `signal` (optional): aborts the startup `/v1/health` wait used by `connect()` - diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html index fe1bc2d..aeec796 100644 --- a/frontend/packages/inspector/index.html +++ b/frontend/packages/inspector/index.html @@ -2965,58 +2965,6 @@ font-size: 11px; } - /* Terminal (shared) */ - .process-terminal-shell { - margin-top: 4px; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 10px; - overflow: hidden; - background: rgba(0, 0, 0, 0.3); - } - - .process-terminal-meta { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 8px 12px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(0, 0, 0, 0.2); - font-size: 11px; - color: var(--text-secondary); - } - - .process-terminal-status { - display: inline-flex; - align-items: center; - gap: 6px; - color: var(--muted); - } - - .process-terminal-status.ready { - color: var(--success); - } - - .process-terminal-status.error { - color: var(--danger); - } - - .process-terminal-status.closed { - color: var(--warning); - } - - .process-terminal-host { - min-height: 320px; - max-height: 480px; - overflow: hidden; - padding: 10px; - } - - .process-terminal-host > div { - width: 100%; - height: 100%; - } - .process-terminal-empty { margin-top: 4px; padding: 10px 12px; diff --git a/frontend/packages/inspector/package.json b/frontend/packages/inspector/package.json index b472d63..9671ecb 100644 --- a/frontend/packages/inspector/package.json +++ b/frontend/packages/inspector/package.json @@ -6,12 +6,13 @@ "type": "module", "scripts": { "dev": "vite", - "build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && vite build", + "build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vite build", "preview": "vite preview", - "typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && tsc --noEmit", - "test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && vitest run" + "typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && tsc --noEmit", + "test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && pnpm --filter @sandbox-agent/react build && vitest run" }, "devDependencies": { + "@sandbox-agent/react": "workspace:*", "sandbox-agent": "workspace:*", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -23,7 +24,6 @@ }, "dependencies": { "@sandbox-agent/persist-indexeddb": "workspace:*", - "ghostty-web": "^0.4.0", "lucide-react": "^0.469.0", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/frontend/packages/inspector/src/components/debug/ProcessesTab.tsx b/frontend/packages/inspector/src/components/debug/ProcessesTab.tsx index 0d11844..6a571cf 100644 --- a/frontend/packages/inspector/src/components/debug/ProcessesTab.tsx +++ b/frontend/packages/inspector/src/components/debug/ProcessesTab.tsx @@ -1,8 +1,8 @@ import { ChevronDown, ChevronRight, Loader2, Play, RefreshCw, Skull, SquareTerminal, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { ProcessTerminal } from "@sandbox-agent/react"; import { SandboxAgentError } from "sandbox-agent"; import type { ProcessInfo, SandboxAgent } from "sandbox-agent"; -import GhosttyTerminal from "../processes/GhosttyTerminal"; const extractErrorMessage = (error: unknown, fallback: string): string => { if (error instanceof SandboxAgentError && error.problem?.detail) return error.problem.detail; @@ -390,9 +390,10 @@ const ProcessesTab = ({ {/* Terminal */} {terminalOpen && canOpenTerminal(selectedProcess) ? ( - ) : canOpenTerminal(selectedProcess) ? ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73f90f5..45938ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -424,9 +424,6 @@ importers: '@sandbox-agent/persist-indexeddb': specifier: workspace:* version: link:../../../sdks/persist-indexeddb - ghostty-web: - specifier: ^0.4.0 - version: 0.4.0 lucide-react: specifier: ^0.469.0 version: 0.469.0(react@18.3.1) @@ -437,6 +434,9 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) devDependencies: + '@sandbox-agent/react': + specifier: workspace:* + version: link:../../../sdks/react '@types/react': specifier: ^18.3.3 version: 18.3.27 @@ -771,6 +771,28 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + sdks/react: + dependencies: + ghostty-web: + specifier: ^0.4.0 + version: 0.4.0 + devDependencies: + '@types/react': + specifier: ^18.3.3 + version: 18.3.27 + react: + specifier: ^18.3.1 + version: 18.3.1 + sandbox-agent: + specifier: workspace:* + version: link:../typescript + tsup: + specifier: ^8.0.0 + version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.7.0 + version: 5.9.3 + sdks/typescript: dependencies: '@sandbox-agent/cli-shared': @@ -8026,14 +8048,6 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@22.19.7))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 5.4.21(@types/node@22.19.7) - '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.3.5))': dependencies: '@vitest/spy': 3.2.4 @@ -11093,7 +11107,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@22.19.7)) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.3.5)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/sdks/react/package.json b/sdks/react/package.json new file mode 100644 index 0000000..60ec4b6 --- /dev/null +++ b/sdks/react/package.json @@ -0,0 +1,40 @@ +{ + "name": "@sandbox-agent/react", + "version": "0.2.2", + "description": "React components for Sandbox Agent frontend integrations", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/rivet-dev/sandbox-agent" + }, + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "react": "^18.3.1", + "sandbox-agent": "^0.2.2" + }, + "dependencies": { + "ghostty-web": "^0.4.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "react": "^18.3.1", + "sandbox-agent": "workspace:*", + "tsup": "^8.0.0", + "typescript": "^5.7.0" + } +} diff --git a/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx b/sdks/react/src/ProcessTerminal.tsx similarity index 51% rename from frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx rename to sdks/react/src/ProcessTerminal.tsx index 858b7c1..9d3d9b3 100644 --- a/frontend/packages/inspector/src/components/processes/GhosttyTerminal.tsx +++ b/sdks/react/src/ProcessTerminal.tsx @@ -1,10 +1,25 @@ -import { AlertCircle, Loader2, PlugZap, SquareTerminal } from "lucide-react"; -import { FitAddon, Terminal, init } from "ghostty-web"; +"use client"; + +import type { FitAddon as GhosttyFitAddon, Terminal as GhosttyTerminal } from "ghostty-web"; +import type { CSSProperties } from "react"; import { useEffect, useRef, useState } from "react"; -import type { SandboxAgent } from "sandbox-agent"; +import type { SandboxAgent, TerminalErrorStatus, TerminalExitStatus } from "sandbox-agent"; type ConnectionState = "connecting" | "ready" | "closed" | "error"; +export type ProcessTerminalClient = Pick; + +export interface ProcessTerminalProps { + client: ProcessTerminalClient; + processId: string; + className?: string; + style?: CSSProperties; + terminalStyle?: CSSProperties; + height?: number | string; + onExit?: (status: TerminalExitStatus) => void; + onError?: (error: TerminalErrorStatus | Error) => void; +} + const terminalTheme = { background: "#09090b", foreground: "#f4f4f5", @@ -29,15 +44,62 @@ const terminalTheme = { brightWhite: "#fafafa", }; -const GhosttyTerminal = ({ +const shellStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + overflow: "hidden", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: 10, + background: "rgba(0, 0, 0, 0.3)", +}; + +const statusBarStyle: CSSProperties = { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + padding: "8px 12px", + borderBottom: "1px solid rgba(255, 255, 255, 0.08)", + background: "rgba(0, 0, 0, 0.2)", + color: "rgba(244, 244, 245, 0.86)", + fontSize: 11, + lineHeight: 1.4, +}; + +const hostBaseStyle: CSSProperties = { + minHeight: 320, + padding: 10, + overflow: "hidden", +}; + +const exitCodeStyle: CSSProperties = { + fontFamily: "ui-monospace, SFMono-Regular, SF Mono, Menlo, monospace", + opacity: 0.72, +}; + +const getStatusColor = (state: ConnectionState): string => { + switch (state) { + case "ready": + return "#4ade80"; + case "error": + return "#fb7185"; + case "closed": + return "#fbbf24"; + default: + return "rgba(244, 244, 245, 0.72)"; + } +}; + +export const ProcessTerminal = ({ client, processId, + className, + style, + terminalStyle, + height = 360, onExit, -}: { - client: SandboxAgent; - processId: string; - onExit?: () => void; -}) => { + onError, +}: ProcessTerminalProps) => { const hostRef = useRef(null); const [connectionState, setConnectionState] = useState("connecting"); const [statusMessage, setStatusMessage] = useState("Connecting to PTY..."); @@ -45,17 +107,22 @@ const GhosttyTerminal = ({ useEffect(() => { let cancelled = false; - let terminal: Terminal | null = null; - let fitAddon: FitAddon | null = null; - let session: ReturnType | null = null; + let terminal: GhosttyTerminal | null = null; + let fitAddon: GhosttyFitAddon | null = null; + let session: ReturnType | null = null; let resizeRaf = 0; let removeDataListener: { dispose(): void } | null = null; let removeResizeListener: { dispose(): void } | null = null; + setConnectionState("connecting"); + setStatusMessage("Connecting to PTY..."); + setExitCode(null); + const syncSize = () => { if (!terminal || !session) { return; } + session.resize({ cols: terminal.cols, rows: terminal.rows, @@ -64,12 +131,14 @@ const GhosttyTerminal = ({ const connect = async () => { try { - await init(); + const ghostty = await import("ghostty-web"); + await ghostty.init(); + if (cancelled || !hostRef.current) { return; } - terminal = new Terminal({ + terminal = new ghostty.Terminal({ allowTransparency: true, cursorBlink: true, cursorStyle: "block", @@ -78,9 +147,14 @@ const GhosttyTerminal = ({ smoothScrollDuration: 90, theme: terminalTheme, }); - fitAddon = new FitAddon(); + fitAddon = new ghostty.FitAddon(); terminal.open(hostRef.current); + const terminalRoot = hostRef.current.firstElementChild; + if (terminalRoot instanceof HTMLElement) { + terminalRoot.style.width = "100%"; + terminalRoot.style.height = "100%"; + } terminal.loadAddon(fitAddon); fitAddon.fit(); fitAddon.observeResize(); @@ -101,14 +175,13 @@ const GhosttyTerminal = ({ session = nextSession; nextSession.onReady((frame) => { - if (cancelled) { + if (cancelled || frame.type !== "ready") { return; } - if (frame.type === "ready") { - setConnectionState("ready"); - setStatusMessage("Connected"); - syncSize(); - } + + setConnectionState("ready"); + setStatusMessage("Connected"); + syncSize(); }); nextSession.onData((bytes) => { @@ -119,31 +192,33 @@ const GhosttyTerminal = ({ }); nextSession.onExit((frame) => { - if (cancelled) { + if (cancelled || frame.type !== "exit") { return; } - if (frame.type === "exit") { - setConnectionState("closed"); - setExitCode(frame.exitCode ?? null); - setStatusMessage( - frame.exitCode == null ? "Process exited." : `Process exited with code ${frame.exitCode}.` - ); - onExit?.(); - } + + setConnectionState("closed"); + setExitCode(frame.exitCode ?? null); + setStatusMessage( + frame.exitCode == null ? "Process exited." : `Process exited with code ${frame.exitCode}.` + ); + onExit?.(frame); }); nextSession.onError((error) => { if (cancelled) { return; } + setConnectionState("error"); setStatusMessage(error instanceof Error ? error.message : error.message); + onError?.(error); }); nextSession.onClose(() => { if (cancelled) { return; } + setConnectionState((current) => (current === "error" ? current : "closed")); setStatusMessage((current) => (current === "Connected" ? "Terminal disconnected." : current)); }); @@ -151,8 +226,11 @@ const GhosttyTerminal = ({ if (cancelled) { return; } + + const nextError = error instanceof Error ? error : new Error("Failed to initialize terminal."); setConnectionState("error"); - setStatusMessage(error instanceof Error ? error.message : "Failed to initialize Ghostty terminal."); + setStatusMessage(nextError.message); + onError?.(nextError); } }; @@ -168,27 +246,22 @@ const GhosttyTerminal = ({ session?.close(); terminal?.dispose(); }; - }, [client, onExit, processId]); + }, [client, onError, onExit, processId]); return ( -
-
-
- - Ghostty PTY -
-
- {connectionState === "connecting" ? : null} - {connectionState === "ready" ? : null} - {connectionState === "error" ? : null} - {statusMessage} - {exitCode != null ? exit={exitCode} : null} -
+
+
+ {statusMessage} + {exitCode != null ? exit={exitCode} : null}
{ hostRef.current?.querySelector("textarea")?.focus(); }} @@ -196,5 +269,3 @@ const GhosttyTerminal = ({
); }; - -export default GhosttyTerminal; diff --git a/sdks/react/src/index.ts b/sdks/react/src/index.ts new file mode 100644 index 0000000..5af657d --- /dev/null +++ b/sdks/react/src/index.ts @@ -0,0 +1,6 @@ +export { ProcessTerminal } from "./ProcessTerminal.tsx"; + +export type { + ProcessTerminalClient, + ProcessTerminalProps, +} from "./ProcessTerminal.tsx"; diff --git a/sdks/react/tsconfig.json b/sdks/react/tsconfig.json new file mode 100644 index 0000000..1ee4e6b --- /dev/null +++ b/sdks/react/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/sdks/react/tsup.config.ts b/sdks/react/tsup.config.ts new file mode 100644 index 0000000..70a8b7e --- /dev/null +++ b/sdks/react/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + target: "es2022", +});