Files
kandev/apps/web/components/task/passthrough-terminal.tsx
T

604 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import React, { useEffect, useRef, useCallback, useMemo, useState } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { AttachAddon } from "@xterm/addon-attach";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebglAddon } from "@xterm/addon-webgl";
import "@xterm/xterm/css/xterm.css";
import { useAppStore } from "@/components/state-provider";
import { useSession } from "@/hooks/domains/session/use-session";
import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl";
import { getBackendConfig } from "@/lib/config";
import { getTerminalTheme } from "@/lib/theme/terminal-theme";
type BaseProps = {
sessionId?: string | null;
};
type AgentTerminalProps = BaseProps & { mode: "agent"; label?: string };
type ShellTerminalProps = BaseProps & { mode: "shell"; terminalId: string; label?: string };
type PassthroughTerminalProps = AgentTerminalProps | ShellTerminalProps;
// Debug flag - set to true to see detailed logs
const DEBUG = false;
const log = (...args: unknown[]) => {
if (DEBUG) console.log("[PassthroughTerminal]", ...args);
};
// Minimum dimensions to prevent zero-size issues
const MIN_WIDTH = 100;
const MIN_HEIGHT = 100;
type TerminalInitOptions = {
terminalRef: React.RefObject<HTMLDivElement | null>;
xtermRef: React.MutableRefObject<Terminal | null>;
fitAddonRef: React.MutableRefObject<FitAddon | null>;
isInitializedRef: React.MutableRefObject<boolean>;
lastDimensionsRef: React.MutableRefObject<{ cols: number; rows: number }>;
resizeTimeoutRef: React.MutableRefObject<ReturnType<typeof setTimeout> | null>;
webglAddonRef: React.MutableRefObject<WebglAddon | null>;
fitAndResize: (force?: boolean) => void;
onReady: () => void;
};
function initTerminalInstance(
termContainer: HTMLDivElement,
refs: Pick<
TerminalInitOptions,
| "xtermRef"
| "fitAddonRef"
| "isInitializedRef"
| "lastDimensionsRef"
| "webglAddonRef"
| "resizeTimeoutRef"
>,
fitAndResize: (force?: boolean) => void,
) {
if (refs.isInitializedRef.current || refs.xtermRef.current) return undefined;
refs.isInitializedRef.current = true;
log("Creating terminal");
const terminal = new Terminal({
allowProposedApi: true,
cursorBlink: true,
disableStdin: false,
convertEol: false,
scrollOnUserInput: true,
scrollback: 5000,
fontSize: 13,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: getTerminalTheme(termContainer),
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
const unicode11Addon = new Unicode11Addon();
terminal.loadAddon(unicode11Addon);
terminal.unicode.activeVersion = "11";
log("Opening terminal in container");
terminal.open(termContainer);
try {
fitAddon.fit();
refs.lastDimensionsRef.current = { cols: terminal.cols, rows: terminal.rows };
log("Initial fit:", terminal.cols, "x", terminal.rows);
} catch (e) {
log("Initial fit failed:", e);
}
try {
const webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
log("WebGL context lost");
webglAddon.dispose();
refs.webglAddonRef.current = null;
});
terminal.loadAddon(webglAddon);
refs.webglAddonRef.current = webglAddon;
log("WebGL addon loaded");
} catch (e) {
log("WebGL failed, using canvas:", e);
}
refs.xtermRef.current = terminal;
refs.fitAddonRef.current = fitAddon;
// Expose buffer reader on the container for e2e tests (xterm renders to
// canvas so text isn't accessible in the DOM).
(termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer =
() => {
const buf = terminal.buffer.active;
const lines: string[] = [];
for (let i = 0; i <= buf.baseY + buf.cursorY; i++) {
lines.push(buf.getLine(i)?.translateToString(true) ?? "");
}
return lines.join("\n");
};
const handleResize = () => {
const rect = termContainer.getBoundingClientRect();
if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) {
log("Skipping resize - too small");
return;
}
if (refs.resizeTimeoutRef.current) clearTimeout(refs.resizeTimeoutRef.current);
refs.resizeTimeoutRef.current = setTimeout(() => {
fitAndResize();
}, 100);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(termContainer);
return () => {
log("Terminal cleanup");
if (refs.resizeTimeoutRef.current) clearTimeout(refs.resizeTimeoutRef.current);
resizeObserver.disconnect();
if (refs.webglAddonRef.current) {
refs.webglAddonRef.current.dispose();
refs.webglAddonRef.current = null;
}
terminal.dispose();
(termContainer as HTMLDivElement & { __xtermReadBuffer?: () => string }).__xtermReadBuffer =
undefined;
refs.xtermRef.current = null;
refs.fitAddonRef.current = null;
refs.isInitializedRef.current = false;
refs.lastDimensionsRef.current = { cols: 0, rows: 0 };
};
}
function useTerminalInit({
terminalRef,
xtermRef,
fitAddonRef,
isInitializedRef,
lastDimensionsRef,
resizeTimeoutRef,
webglAddonRef,
fitAndResize,
onReady,
}: TerminalInitOptions) {
const refs = {
xtermRef,
fitAddonRef,
isInitializedRef,
lastDimensionsRef,
resizeTimeoutRef,
webglAddonRef,
};
useEffect(() => {
log("Terminal init effect");
const container = terminalRef.current;
if (!container) {
log("No container ref");
return;
}
if (isInitializedRef.current) {
log("Already initialized");
return;
}
const tryInit = () => {
if (isInitializedRef.current) return true;
const rect = container.getBoundingClientRect();
log("Init check: dimensions", rect.width, "x", rect.height);
if (rect.width >= MIN_WIDTH && rect.height >= MIN_HEIGHT) {
initTerminalInstance(container, refs, fitAndResize);
onReady();
return true;
}
return false;
};
// If the container already has dimensions, init immediately.
if (tryInit()) {
// Already initialized — cleanup handled by initTerminalInstance's return
} else {
// Container is 0×0 (e.g. terminal is in a background dockview tab).
// Use a ResizeObserver to wait until the container becomes visible
// instead of polling with rAF and force-initializing at 0×0
// (which causes black backgrounds since CSS vars can't be read).
log("Container not visible, waiting via ResizeObserver");
const observer = new ResizeObserver(() => {
if (tryInit()) {
observer.disconnect();
}
});
observer.observe(container);
return () => {
observer.disconnect();
};
}
const resizeTimeout = resizeTimeoutRef;
const webgl = webglAddonRef;
const xterm = xtermRef;
const fitAddon = fitAddonRef;
return () => {
log("Effect cleanup");
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (webgl.current) {
webgl.current.dispose();
webgl.current = null;
}
if (xterm.current) {
xterm.current.dispose();
xterm.current = null;
}
fitAddon.current = null;
isInitializedRef.current = false;
lastDimensionsRef.current = { cols: 0, rows: 0 };
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fitAndResize]);
}
type WebSocketConnectionOptions = {
taskId: string | null;
sessionId: string | null | undefined;
canConnect: boolean;
isTerminalReady: boolean;
fitAndResize: (force?: boolean) => void;
wsBaseUrl: string;
mode: "agent" | "shell";
terminalId: string | undefined;
label?: string;
xtermRef: React.MutableRefObject<Terminal | null>;
fitAddonRef: React.MutableRefObject<FitAddon | null>;
wsRef: React.MutableRefObject<WebSocket | null>;
attachAddonRef: React.MutableRefObject<AttachAddon | null>;
};
function buildWsUrl(
wsBaseUrl: string,
sessionId: string,
mode: "agent" | "shell",
terminalId: string | undefined,
label?: string,
): string {
let wsUrl =
mode === "agent"
? `${wsBaseUrl}/terminal/${sessionId}?mode=agent`
: `${wsBaseUrl}/terminal/${sessionId}?mode=shell&terminalId=${encodeURIComponent(terminalId!)}`;
if (label) wsUrl += `&label=${encodeURIComponent(label)}`;
return wsUrl;
}
type ConnectWebSocketOptions = {
sessionId: string;
wsBaseUrl: string;
mode: "agent" | "shell";
terminalId: string | undefined;
label: string | undefined;
terminal: Terminal;
fitAndResize: (force?: boolean) => void;
wsRef: React.MutableRefObject<WebSocket | null>;
attachAddonRef: React.MutableRefObject<AttachAddon | null>;
isMountedCheck: () => boolean;
onTimeout: (id: ReturnType<typeof setTimeout>) => void;
};
function connectWebSocket({
sessionId,
wsBaseUrl,
mode,
terminalId,
label,
terminal,
fitAndResize,
wsRef,
attachAddonRef,
isMountedCheck,
onTimeout,
}: ConnectWebSocketOptions) {
if (attachAddonRef.current) {
attachAddonRef.current.dispose();
attachAddonRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
const wsUrl = buildWsUrl(wsBaseUrl, sessionId, mode, terminalId, label);
log("Connecting to", wsUrl, { mode, terminalId, label });
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
ws.onopen = () => {
if (!isMountedCheck()) {
ws.close();
return;
}
log("WebSocket connected");
const attachAddon = new AttachAddon(ws, { bidirectional: true });
terminal.loadAddon(attachAddon);
attachAddonRef.current = attachAddon;
// Send initial resize (forced) so the backend knows our terminal dimensions,
// then one deferred resize to catch layout settling + force a full redraw.
// The WebGL renderer can become stale when the container transitions through
// 0×0 (portal system detach/reattach), so we must explicitly refresh.
requestAnimationFrame(() => {
if (isMountedCheck() && ws.readyState === WebSocket.OPEN) {
fitAndResize(true);
}
});
const settleTimeout = setTimeout(() => {
if (!isMountedCheck()) return;
if (ws.readyState === WebSocket.OPEN) {
fitAndResize(true);
}
// Force full redraw — WebGL canvas may be stale after portal 0×0 transition
terminal.refresh(0, terminal.rows - 1);
}, 500);
onTimeout(settleTimeout);
};
ws.onclose = (event) => {
log("WebSocket closed:", event.code, event.reason);
if (attachAddonRef.current) {
attachAddonRef.current.dispose();
attachAddonRef.current = null;
}
};
ws.onerror = (error) => {
log("WebSocket error:", error);
};
}
function useWebSocketConnection({
taskId,
sessionId,
canConnect,
isTerminalReady,
fitAndResize,
wsBaseUrl,
mode,
terminalId,
label,
xtermRef,
fitAddonRef,
wsRef,
attachAddonRef,
}: WebSocketConnectionOptions) {
useEffect(() => {
log("WebSocket effect:", {
taskId,
sessionId,
mode,
terminalId,
canConnect,
isTerminalReady,
hasTerminal: !!xtermRef.current,
});
if (!taskId || !sessionId || !canConnect || !isTerminalReady) {
log("WebSocket effect: early return", { taskId, sessionId, canConnect, isTerminalReady });
return;
}
if (!xtermRef.current || !fitAddonRef.current) {
log("Terminal not ready for WebSocket (refs missing despite isTerminalReady)");
return;
}
const terminal = xtermRef.current;
let isMounted = true;
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
let settleTimeout: ReturnType<typeof setTimeout> | null = null;
connectTimeout = setTimeout(() => {
if (!isMounted) return;
connectWebSocket({
sessionId,
wsBaseUrl,
mode,
terminalId,
label,
terminal,
fitAndResize,
wsRef,
attachAddonRef,
isMountedCheck: () => isMounted,
onTimeout: (id) => {
settleTimeout = id;
},
});
}, 150);
return () => {
log("WebSocket cleanup");
isMounted = false;
if (connectTimeout) clearTimeout(connectTimeout);
if (settleTimeout) clearTimeout(settleTimeout);
if (attachAddonRef.current) {
attachAddonRef.current.dispose();
attachAddonRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [
taskId,
sessionId,
canConnect,
isTerminalReady,
fitAndResize,
wsBaseUrl,
mode,
terminalId,
label,
xtermRef,
fitAddonRef,
wsRef,
attachAddonRef,
]);
}
function buildResizeBuffer(cols: number, rows: number): Uint8Array {
const json = JSON.stringify({ cols, rows });
const encoder = new TextEncoder();
const jsonBytes = encoder.encode(json);
const buffer = new Uint8Array(1 + jsonBytes.length);
buffer[0] = 0x01;
buffer.set(jsonBytes, 1);
return buffer;
}
function useSendResize(wsRef: React.MutableRefObject<WebSocket | null>) {
return useCallback(
(cols: number, rows: number) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) {
log("sendResize: WebSocket not ready", ws?.readyState);
return;
}
if (cols <= 0 || rows <= 0) {
log("sendResize: invalid dimensions", cols, rows);
return;
}
log("sendResize:", cols, "x", rows);
ws.send(buildResizeBuffer(cols, rows));
},
[wsRef],
);
}
type FitAndResizeOptions = {
xtermRef: React.MutableRefObject<Terminal | null>;
fitAddonRef: React.MutableRefObject<FitAddon | null>;
terminalRef: React.RefObject<HTMLDivElement | null>;
lastDimensionsRef: React.MutableRefObject<{ cols: number; rows: number }>;
sendResize: (cols: number, rows: number) => void;
};
function useFitAndResize({
xtermRef,
fitAddonRef,
terminalRef,
lastDimensionsRef,
sendResize,
}: FitAndResizeOptions) {
return useCallback(
(force = false) => {
const terminal = xtermRef.current;
const fitAddon = fitAddonRef.current;
const container = terminalRef.current;
if (!terminal || !fitAddon || !container) {
log("fitAndResize: missing refs");
return;
}
const rect = container.getBoundingClientRect();
if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) {
log("fitAndResize: container too small, skipping");
return;
}
try {
fitAddon.fit();
log("fitAndResize: fit done", terminal.cols, "x", terminal.rows);
} catch (e) {
log("fitAndResize: fit failed", e);
return;
}
const { cols, rows } = terminal;
const last = lastDimensionsRef.current;
const changed = cols !== last.cols || rows !== last.rows;
const wasZero = last.cols === 0 && last.rows === 0;
if (force || changed) {
lastDimensionsRef.current = { cols, rows };
sendResize(cols, rows);
}
// Force full redraw when transitioning from uninitialized/zero dimensions —
// the WebGL canvas may be stale after the container was at 0×0 (portal moves).
if (wasZero || changed) {
terminal.refresh(0, terminal.rows - 1);
}
},
[xtermRef, fitAddonRef, terminalRef, lastDimensionsRef, sendResize],
);
}
/**
* PassthroughTerminal provides direct terminal interaction with an agent CLI.
*
* Design: Dedicated Binary WebSocket + AttachAddon
* - Uses a dedicated WebSocket connection to /terminal/:sessionId
* - Raw binary frames bypass JSON encoding/decoding latency
* - AttachAddon (official xterm.js addon) handles the bridging
* - Unicode11Addon enables proper unicode character support
* - Resize commands sent via binary protocol: [0x01][JSON {cols, rows}]
*/
export function PassthroughTerminal(props: PassthroughTerminalProps) {
const { sessionId: propSessionId, mode, label } = props;
const terminalId = mode === "shell" ? props.terminalId : undefined;
log("Render - props:", { propSessionId, mode, terminalId, label });
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const attachAddonRef = useRef<AttachAddon | null>(null);
const isInitializedRef = useRef(false);
const lastDimensionsRef = useRef({ cols: 0, rows: 0 });
const resizeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const webglAddonRef = useRef<WebglAddon | null>(null);
const storeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const sessionId = propSessionId ?? storeSessionId;
const { session, isActive } = useSession(sessionId);
useSessionAgentctl(sessionId);
const taskId = session?.task_id ?? null;
const canConnect = Boolean(sessionId && isActive);
const wsBaseUrl = useMemo(() => {
try {
const backendUrl = getBackendConfig().apiBaseUrl;
const url = new URL(backendUrl);
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
return `${protocol}//${url.host}`;
} catch {
return "ws://localhost:8080";
}
}, []);
const [isTerminalReady, setIsTerminalReady] = useState(false);
const onTerminalReady = useCallback(() => {
log("Terminal ready — will trigger WebSocket connection");
setIsTerminalReady(true);
}, []);
const sendResize = useSendResize(wsRef);
const fitAndResize = useFitAndResize({
xtermRef,
fitAddonRef,
terminalRef,
lastDimensionsRef,
sendResize,
});
useTerminalInit({
terminalRef,
xtermRef,
fitAddonRef,
isInitializedRef,
lastDimensionsRef,
resizeTimeoutRef,
webglAddonRef,
fitAndResize,
onReady: onTerminalReady,
});
useWebSocketConnection({
taskId,
sessionId,
canConnect,
isTerminalReady,
fitAndResize,
wsBaseUrl,
mode,
terminalId,
label,
xtermRef,
fitAddonRef,
wsRef,
attachAddonRef,
});
return (
<div
className="h-full w-full overflow-hidden p-2 bg-background"
style={{ minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT }}
>
<div ref={terminalRef} className="h-full w-full" />
</div>
);
}