diff --git a/packages/agentos/package.json b/packages/agentos/package.json index 029ff5b044..85c063a742 100644 --- a/packages/agentos/package.json +++ b/packages/agentos/package.json @@ -77,6 +77,8 @@ "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-scroll-area": "^1.2.2", "@tanstack/react-query": "^5.87.1", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "@types/node": "^22.19.15", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", diff --git a/packages/agentos/src/actor.ts b/packages/agentos/src/actor.ts index e54b908c51..2e29ccead8 100644 --- a/packages/agentos/src/actor.ts +++ b/packages/agentos/src/actor.ts @@ -493,6 +493,7 @@ const INSPECTOR_TABS_ASSET_DIR = join( // dashboard shows only the agent-os tabs. const AGENTOS_INSPECTOR_CONFIG = { tabs: [ + { id: "terminal", label: "Terminal", source: INSPECTOR_TABS_ASSET_DIR, icon: "terminal" }, { id: "transcript", label: "Transcript", source: INSPECTOR_TABS_ASSET_DIR, icon: "comments" }, { id: "filesystem", label: "Filesystem", source: INSPECTOR_TABS_ASSET_DIR, icon: "folder-tree" }, { id: "processes", label: "Processes", source: INSPECTOR_TABS_ASSET_DIR, icon: "microchip" }, diff --git a/packages/agentos/src/inspector-tabs/lib/types.ts b/packages/agentos/src/inspector-tabs/lib/types.ts index d4442f29ae..eaf4cd8274 100644 --- a/packages/agentos/src/inspector-tabs/lib/types.ts +++ b/packages/agentos/src/inspector-tabs/lib/types.ts @@ -101,6 +101,12 @@ export interface SessionEventPayload { sessionId?: string; event: JsonRpcNotification; } +/** Live `shellData` / `shellStderr` broadcast payload (the terminal stream). + * `data` is a Uint8Array, but the JSON wire form may be `["$Uint8Array", b64]`. */ +export interface ShellDataPayload { + shellId: string; + data: Uint8Array; +} /** Raw `getSessionEvents` row — the full persisted event, not just the bare * notification, so we keep `seq` (ordering/keys) and `createdAt` (timestamps). */ export interface PersistedSessionEvent { diff --git a/packages/agentos/src/inspector-tabs/main.tsx b/packages/agentos/src/inspector-tabs/main.tsx index 4a18026e8f..7ad19c06c1 100644 --- a/packages/agentos/src/inspector-tabs/main.tsx +++ b/packages/agentos/src/inspector-tabs/main.tsx @@ -21,6 +21,8 @@ const TABS: Record Promise<{ default: ComponentType<{ actorId: str import("./tabs/mounts").then((m) => ({ default: m.MountsTabConnected })), transcript: () => import("./tabs/transcript").then((m) => ({ default: m.TranscriptTabConnected })), + terminal: () => + import("./tabs/terminal").then((m) => ({ default: m.TerminalTabConnected })), }; const queryClient = new QueryClient({ diff --git a/packages/agentos/src/inspector-tabs/tabs/terminal.tsx b/packages/agentos/src/inspector-tabs/tabs/terminal.tsx new file mode 100644 index 0000000000..fb31b047da --- /dev/null +++ b/packages/agentos/src/inspector-tabs/tabs/terminal.tsx @@ -0,0 +1,203 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { useEffect, useRef, useState } from "react"; +import { AgentOsEmpty } from "../common"; +import { useAgentOsActor } from "../lib/rivet"; +import type { ShellDataPayload } from "../lib/types"; +import React from "react"; + +// The `shellData`/`shellStderr` payloads carry `data` as a Uint8Array, but the +// JSON wire encoding may hand it back as `["$Uint8Array", base64]`. +function toBytes(data: unknown): Uint8Array { + if (data instanceof Uint8Array) return data; + if (Array.isArray(data) && data[0] === "$Uint8Array" && typeof data[1] === "string") { + const bin = atob(data[1]); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; + } + if (Array.isArray(data)) return Uint8Array.from(data as number[]); + return new Uint8Array(); +} + +// xterm pane with local line-editing (echo + backspace + Ctrl-C), mirroring the +// browser-terminal example. `onInput` sends a completed line to the shell; +// `subscribe` streams shell output; a `suppress` buffer swallows the shell's +// echo of our own just-sent input so it isn't printed twice. +function TerminalPane({ + shellId, + onInput, + onResize, + subscribe, +}: { + shellId: string; + onInput: (text: string) => void; + onResize: (cols: number, rows: number) => void; + subscribe: (onData: (bytes: Uint8Array) => void) => () => void; +}) { + const containerRef = useRef(null); + const onInputRef = useRef(onInput); + const onResizeRef = useRef(onResize); + const subscribeRef = useRef(subscribe); + onInputRef.current = onInput; + onResizeRef.current = onResize; + subscribeRef.current = subscribe; + + // biome-ignore lint/correctness/useExhaustiveDependencies: one-time xterm setup keyed by shellId. + useEffect(() => { + const term = new Terminal({ + convertEol: true, + cursorBlink: true, + fontFamily: + 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace', + fontSize: 13, + theme: { background: "#0b0e14", foreground: "#c7d0e0" }, + scrollback: 5000, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + if (containerRef.current) term.open(containerRef.current); + try { + fit.fit(); + } catch {} + term.focus(); + + let line = ""; + const encoder = new TextEncoder(); + const suppress: number[] = []; + term.onData((data) => { + for (const ch of data) { + const code = ch.codePointAt(0) ?? 0; + if (ch === "\r" || ch === "\n") { + term.write("\r\n"); + const cmd = line; + line = ""; + for (const b of encoder.encode(`${cmd}\n`)) suppress.push(b); + onInputRef.current(`${cmd}\n`); + } else if (code === 0x7f || ch === "\b") { + if (line.length > 0) { + line = line.slice(0, -1); + term.write("\b \b"); + } + } else if (code === 0x03) { + line = ""; + onInputRef.current(String.fromCharCode(3)); + } else if (code >= 0x20) { + line += ch; + term.write(ch); + } + } + }); + + const writeOutput = (bytes: Uint8Array) => { + if (suppress.length === 0) { + term.write(bytes); + return; + } + const out: number[] = []; + for (let i = 0; i < bytes.length; i++) { + const b = bytes[i]; + if (suppress.length > 0) { + if (b === suppress[0]) { + suppress.shift(); + continue; + } + if (b === 0x0d) continue; + suppress.length = 0; + } + out.push(b); + } + if (out.length > 0) term.write(new Uint8Array(out)); + }; + + term.onResize(({ cols, rows }) => onResizeRef.current(cols, rows)); + onResizeRef.current(term.cols, term.rows); + const unsubscribe = subscribeRef.current(writeOutput); + + const refit = () => { + try { + fit.fit(); + } catch {} + }; + window.addEventListener("resize", refit); + return () => { + window.removeEventListener("resize", refit); + unsubscribe(); + term.dispose(); + }; + }, [shellId]); + + return
; +} + +export function TerminalTabConnected(_props: { actorId: string }) { + const actor = useAgentOsActor(); + // biome-ignore lint/suspicious/noExplicitAny: untyped actor connection (shell actions + events) + const conn = actor.connection as any; + const [shellId, setShellId] = useState(null); + const [error, setError] = useState(null); + + // Open a shell once the connection is live; close it on unmount. + useEffect(() => { + if (!conn || shellId) return; + let cancelled = false; + conn + // Default `sh` launches brush interactively, which requests the + // `reedline` input backend; the shipped wasm build omits it and the + // shell dies with "requested input backend type not supported". This + // pane does its own local line-editing (echo/backspace/Ctrl-C, and it + // only sends completed lines), so the `minimal` backend is both the fix + // and the correct match — reedline would double-handle input. + .openShell({ + command: "sh", + args: ["-i", "--input-backend", "minimal"], + cols: 80, + rows: 24, + }) + .then((r: { shellId: string }) => { + if (!cancelled) setShellId(r.shellId); + }) + .catch((e: unknown) => { + if (!cancelled) setError(String((e as Error)?.message ?? e)); + }); + return () => { + cancelled = true; + }; + }, [conn, shellId]); + + useEffect(() => { + return () => { + if (shellId && conn) conn.closeShell(shellId).catch(() => {}); + }; + }, [shellId, conn]); + + if (error) { + return Terminal failed to start: {error}; + } + if (!conn || !shellId) { + return Starting terminal…; + } + + return ( +
+ conn.writeShell(shellId, text)} + onResize={(cols, rows) => conn.resizeShell(shellId, cols, rows)} + subscribe={(onData) => { + const off1 = conn.on("shellData", (p: ShellDataPayload) => { + if (p?.shellId === shellId) onData(toBytes(p.data)); + }); + const off2 = conn.on("shellStderr", (p: ShellDataPayload) => { + if (p?.shellId === shellId) onData(toBytes(p.data)); + }); + return () => { + off1?.(); + off2?.(); + }; + }} + /> +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 858619bb03..aef60b3872 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2277,6 +2277,12 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.4 version: 4.7.0(vite@5.4.21(@types/node@22.19.15)) + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 autoprefixer: specifier: ^10.4.20 version: 10.5.0(postcss@8.5.8) @@ -9099,8 +9105,8 @@ snapshots: '@mistralai/mistralai@1.14.1(bufferutil@4.1.0)': dependencies: ws: 8.20.0(bufferutil@4.1.0) - zod: 4.3.6 - zod-to-json-schema: 3.25.2(zod@4.3.6) + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) transitivePeerDependencies: - bufferutil - utf-8-validate