Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/agentos/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/agentos/src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
6 changes: 6 additions & 0 deletions packages/agentos/src/inspector-tabs/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/agentos/src/inspector-tabs/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const TABS: Record<string, () => 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({
Expand Down
203 changes: 203 additions & 0 deletions packages/agentos/src/inspector-tabs/tabs/terminal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(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 <div className="h-full w-full" ref={containerRef} />;
}

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<string | null>(null);
const [error, setError] = useState<string | null>(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 <AgentOsEmpty>Terminal failed to start: {error}</AgentOsEmpty>;
}
if (!conn || !shellId) {
return <AgentOsEmpty>Starting terminal…</AgentOsEmpty>;
}

return (
<div className="h-full min-h-0 bg-[#0b0e14] p-2">
<TerminalPane
shellId={shellId}
onInput={(text) => 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?.();
};
}}
/>
</div>
);
}
10 changes: 8 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading