/**
* AgentTerminal — Embeddable CLI terminal component
*
* Renders an xterm.js terminal connected to a PTY WebSocket server.
* When running inside a frame, renders nothing (the frame manages the terminal).
*
* Usage:
* import { AgentTerminal } from "@agent-native/core/terminal";
*
*/
import { useRef, useEffect, useState, type CSSProperties } from "react";
import { parseSubmitChatMessage } from "../agent-chat.js";
import { agentNativePath } from "../api-path.js";
import { getFrameOrigin, isTrustedFrameMessage } from "../frame.js";
export interface AgentTerminalProps {
/** CLI command to run. Default: 'builder' */
command?: string;
/** Additional CLI flags */
flags?: string;
/** Custom WebSocket URL (overrides auto-discovery) */
wsUrl?: string;
/** Hide when running inside frame. Default: true */
hideInFrame?: boolean;
/** Terminal theme overrides */
theme?: Record;
/** Font size. Default: 12 */
fontSize?: number;
/** CSS class for the container */
className?: string;
/** Inline styles for the container */
style?: CSSProperties;
/** Callback when connection state changes */
onConnectionChange?: (connected: boolean) => void;
/** Callback when agent running state changes */
onAgentRunningChange?: (running: boolean) => void;
}
// Inject xterm CSS once
let cssInjected = false;
function injectXtermCss() {
if (cssInjected || typeof document === "undefined") return;
cssInjected = true;
const style = document.createElement("style");
style.textContent = `
.xterm { position: relative; user-select: none; }
.xterm.focus, .xterm:focus { outline: none; }
.xterm .xterm-helpers { position: absolute; top: 0; z-index: 5; }
.xterm .xterm-helper-textarea {
padding: 0; border: 0; margin: 0;
position: absolute; opacity: 0; left: -9999em; top: 0;
width: 0; height: 0; z-index: -5;
white-space: nowrap; overflow: hidden; resize: none;
}
.xterm .composition-view { display: none; position: absolute; white-space: nowrap; z-index: 1; }
.xterm .composition-view.active { display: block; }
.xterm .xterm-viewport {
background-color: #000; overflow-y: scroll;
cursor: default; position: absolute; right: 0; left: 0; top: 0; bottom: 0;
}
.xterm .xterm-screen { position: relative; }
.xterm .xterm-screen canvas { position: absolute; left: 0; top: 0; }
.xterm .xterm-scroll-area { visibility: hidden; }
.xterm-char-measure-element {
display: inline-block; visibility: hidden; position: absolute; top: 0; left: -9999em;
line-height: normal;
}
.xterm.enable-mouse-events { cursor: default; }
.xterm.xterm-cursor-pointer, .xterm .xterm-cursor-pointer { cursor: pointer; }
.xterm.column-select.focus { cursor: crosshair; }
.xterm .xterm-accessibility:not(.debug),
.xterm .xterm-message { position: absolute; left: 0; top: 0; bottom: 0; right: 0; z-index: 10; color: transparent; pointer-events: none; }
.xterm .xterm-accessibility-tree:not(.debug) *::selection { color: transparent; }
.xterm .xterm-accessibility-tree { user-select: text; white-space: pre; }
.xterm .live-region { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.xterm .xterm-dim { opacity: 0.5; }
.xterm .xterm-underline-1 { text-decoration: underline; }
.xterm .xterm-underline-2 { text-decoration: double underline; }
.xterm .xterm-underline-3 { text-decoration: wavy underline; }
.xterm .xterm-underline-4 { text-decoration: dotted underline; }
.xterm .xterm-underline-5 { text-decoration: dashed underline; }
.xterm .xterm-overline { text-decoration: overline; }
.xterm .xterm-strikethrough { text-decoration: line-through; }
.xterm .xterm-screen .xterm-decoration-container .xterm-decoration { z-index: 6; position: absolute; }
.xterm .xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { z-index: 7; }
.xterm .xterm-decoration-overview-ruler { z-index: 8; position: absolute; top: 0; right: 0; pointer-events: none; }
.xterm .xterm-decoration-top { z-index: 2; position: relative; }
`;
document.head.appendChild(style);
}
const DEFAULT_THEME = {
background: "#111",
foreground: "#e0e0e0",
cursor: "#58a6ff",
selectionBackground: "#264f78",
black: "#484f58",
red: "#ff7b72",
green: "#3fb950",
yellow: "#d29922",
blue: "#58a6ff",
magenta: "#bc8cff",
cyan: "#39d353",
white: "#b1bac4",
};
interface TerminalInfo {
available: boolean;
wsPort?: number;
command?: string;
error?: string;
}
export function formatWebSocketHostname(hostname: string) {
return hostname.includes(":") && !hostname.startsWith("[")
? `[${hostname}]`
: hostname;
}
export function AgentTerminal({
command,
flags,
wsUrl: wsUrlProp,
hideInFrame = true,
theme,
fontSize = 12,
className,
style,
onConnectionChange,
onAgentRunningChange,
}: AgentTerminalProps) {
const termRef = useRef(null);
const [connected, setConnected] = useState(false);
const [error, setError] = useState(null);
const [inFrame, setInFrame] = useState(false);
// Check frame state after mount (postMessage is async)
useEffect(() => {
if (!hideInFrame) return;
// Check immediately and also after a short delay for the postMessage to arrive
const check = () => {
if (getFrameOrigin()) setInFrame(true);
};
check();
const timer = setTimeout(check, 500);
return () => clearTimeout(timer);
}, [hideInFrame]);
// Notify parent of connection changes
useEffect(() => {
onConnectionChange?.(connected);
}, [connected, onConnectionChange]);
// Main terminal setup
useEffect(() => {
if (typeof window === "undefined") return;
if (hideInFrame && inFrame) return;
const containerRaw = termRef.current;
if (!containerRaw) return;
// Non-null assertion: null branch exited above; closures lose the narrowing.
const container: HTMLDivElement = containerRaw;
let disposed = false;
let ws: WebSocket | null = null;
let cleanupMessageHandler: (() => void) | null = null;
async function init() {
// Dynamic imports for SSR safety
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all(
[
import("@xterm/xterm"),
import("@xterm/addon-fit"),
import("@xterm/addon-web-links"),
],
);
if (disposed || !container) return;
injectXtermCss();
const term = new Terminal({
cursorBlink: true,
fontSize,
fontFamily: "'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace",
theme: { ...DEFAULT_THEME, ...theme },
});
const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon((_event, uri) => {
window.open(uri, "_blank", "noopener");
});
term.loadAddon(fitAddon);
term.loadAddon(webLinksAddon);
term.open(container);
let fitPending = false;
function fitAndResize() {
if (fitPending) return;
fitPending = true;
requestAnimationFrame(() => {
fitPending = false;
if (
disposed ||
!container.isConnected ||
container.clientWidth <= 0 ||
container.clientHeight <= 0
) {
return;
}
try {
fitAddon.fit();
sendResize();
} catch {}
});
}
fitAndResize();
const initialFitTimers = [
setTimeout(fitAndResize, 50),
setTimeout(fitAndResize, 250),
];
const handleVisibilityOrFocus = () => fitAndResize();
window.addEventListener("focus", handleVisibilityOrFocus);
document.addEventListener("visibilitychange", handleVisibilityOrFocus);
// Resize observer for auto-fitting
const resizeObserver = new ResizeObserver(() => {
fitAndResize();
});
resizeObserver.observe(container);
let terminalDisposed = false;
function disposeTerminal() {
if (terminalDisposed) return;
terminalDisposed = true;
initialFitTimers.forEach(clearTimeout);
window.removeEventListener("focus", handleVisibilityOrFocus);
document.removeEventListener(
"visibilitychange",
handleVisibilityOrFocus,
);
resizeObserver.disconnect();
term.dispose();
}
// Discover WebSocket URL
let wsUrl = wsUrlProp;
let resolvedCommand = command;
if (!wsUrl) {
try {
const res = await fetch(
agentNativePath("/_agent-native/agent-terminal-info"),
);
const info: TerminalInfo = await res.json();
if (!info.available) {
setError(info.error || "Agent terminal not available");
disposeTerminal();
return;
}
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const host = formatWebSocketHostname(location.hostname);
wsUrl = `${protocol}//${host}:${info.wsPort}/ws`;
if (!resolvedCommand && info.command) {
resolvedCommand = info.command;
}
} catch (err) {
setError("Failed to discover terminal server");
disposeTerminal();
return;
}
}
// Build WebSocket URL with query params
const qs = new URLSearchParams();
if (resolvedCommand) qs.set("command", resolvedCommand);
if (flags) qs.set("flags", flags);
const qsStr = qs.toString();
const fullWsUrl = qsStr ? `${wsUrl}?${qsStr}` : wsUrl;
term.write(
`\x1b[2m[terminal] Starting ${resolvedCommand || "CLI"}...\x1b[0m\r\n`,
);
// Connect WebSocket
let agentRunning = false;
let idleTimer: ReturnType | null = null;
let connectionId = 0;
function sendResize() {
if (ws && ws.readyState === WebSocket.OPEN && term) {
ws.send(
JSON.stringify({
type: "resize",
cols: term.cols,
rows: term.rows,
}),
);
}
}
function notifyAgentRunning(running: boolean) {
onAgentRunningChange?.(running);
window.dispatchEvent(
new CustomEvent("agentNative.chatRunning", {
detail: { isRunning: running },
}),
);
}
function connect(url: string) {
const thisId = ++connectionId;
if (ws) {
ws.close();
ws = null;
}
const socket = new WebSocket(url);
socket.binaryType = "arraybuffer";
ws = socket;
socket.onopen = () => {
setConnected(true);
setError(null);
socket.send(
JSON.stringify({
type: "resize",
cols: term.cols,
rows: term.rows,
}),
);
};
socket.onmessage = (event) => {
const data =
event.data instanceof ArrayBuffer
? new TextDecoder().decode(event.data)
: event.data;
// Check for setup-status JSON messages
try {
const msg = JSON.parse(data);
if (msg.type === "setup-status") {
if (msg.status === "not-found" || msg.status === "failed") {
setError(msg.message);
// Bump connectionId to suppress reconnect on close
connectionId++;
}
return;
}
} catch {
// Not JSON — regular terminal output
}
setError(null);
term.write(data);
// Idle detection — prompt or cursor visible means agent stopped
if (data.includes("❯") || data.includes("\x1b[?25h")) {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (agentRunning) {
agentRunning = false;
notifyAgentRunning(false);
}
}, 600);
} else if (agentRunning) {
if (idleTimer) clearTimeout(idleTimer);
}
};
socket.onclose = () => {
setConnected(false);
if (connectionId === thisId && !disposed) {
term.write(
"\r\n\x1b[31m[terminal] Connection closed. Reconnecting in 3s...\x1b[0m\r\n",
);
setTimeout(() => {
if (connectionId === thisId && !disposed) {
connect(url);
}
}, 3000);
}
};
socket.onerror = () => socket.close();
}
// Terminal input → WebSocket
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Chat bridge integration — listen for sendToAgentChat messages
const messageHandler = (event: MessageEvent) => {
if (!isTrustedFrameMessage(event)) return;
const parsed = parseSubmitChatMessage(event);
if (parsed && ws && ws.readyState === WebSocket.OPEN) {
ws.send(parsed.message + "\r");
agentRunning = true;
notifyAgentRunning(true);
}
};
window.addEventListener("message", messageHandler);
cleanupMessageHandler = () =>
window.removeEventListener("message", messageHandler);
connect(fullWsUrl);
// Store cleanup references
return () => {
disposed = true;
connectionId++;
if (idleTimer) clearTimeout(idleTimer);
disposeTerminal();
if (ws) {
ws.close();
ws = null;
}
};
}
let cleanup: (() => void) | undefined;
init().then((fn) => {
cleanup = fn;
});
return () => {
disposed = true;
cleanup?.();
cleanupMessageHandler?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hideInFrame, inFrame, command, flags, wsUrlProp]);
if (hideInFrame && inFrame) {
return null;
}
const terminalBackground = theme?.background ?? DEFAULT_THEME.background;
const mergedStyle = {
...style,
background: terminalBackground,
backgroundColor: terminalBackground,
};
return (
);
}