Files
kandev/apps/web/components/task/passthrough-terminal.tsx
T
2026-02-17 12:32:02 +00:00

442 lines
14 KiB
TypeScript

'use client';
import { useEffect, useRef, useCallback, useMemo } 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/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;
/**
* 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;
// Debug: log props on every render
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);
// Get sessionId from store if not provided as prop
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);
// Compute WebSocket base URL from backend config
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';
}
}, []);
// Send resize command via binary protocol
const sendResize = 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);
// Binary protocol: first byte 0x01 = resize command, followed by JSON
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; // Resize command byte
buffer.set(jsonBytes, 1);
ws.send(buffer);
}, []);
// Fit terminal and send resize if dimensions changed
const fitAndResize = 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();
log('fitAndResize: container', rect.width, 'x', rect.height);
// Skip if container is too small
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;
// Only send resize if dimensions changed or force is true
if (force || cols !== last.cols || rows !== last.rows) {
lastDimensionsRef.current = { cols, rows };
sendResize(cols, rows);
}
}, [sendResize]);
// Initialize terminal - runs once per mount
useEffect(() => {
log('Terminal init effect');
const container = terminalRef.current;
if (!container) {
log('No container ref');
return;
}
if (isInitializedRef.current) {
log('Already initialized');
return;
}
// Wait for container to have valid dimensions
const initWhenReady = () => {
const rect = container.getBoundingClientRect();
log('Init check: dimensions', rect.width, 'x', rect.height);
if (rect.width >= MIN_WIDTH && rect.height >= MIN_HEIGHT) {
initializeTerminal();
return true;
}
return false;
};
// Try immediately, then retry with RAF
if (!initWhenReady()) {
let retryCount = 0;
const maxRetries = 30;
const retry = () => {
if (isInitializedRef.current) return;
retryCount++;
if (initWhenReady() || retryCount >= maxRetries) {
if (retryCount >= maxRetries) {
log('Max retries, forcing init');
initializeTerminal();
}
return;
}
requestAnimationFrame(retry);
};
requestAnimationFrame(retry);
}
function initializeTerminal() {
if (isInitializedRef.current || xtermRef.current || !container) {
return;
}
isInitializedRef.current = true;
// Capture container reference for closures
const termContainer = container;
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);
// Load Unicode11 addon
const unicode11Addon = new Unicode11Addon();
terminal.loadAddon(unicode11Addon);
terminal.unicode.activeVersion = '11';
log('Opening terminal in container');
terminal.open(termContainer);
// Initial fit
try {
fitAddon.fit();
lastDimensionsRef.current = { cols: terminal.cols, rows: terminal.rows };
log('Initial fit:', terminal.cols, 'x', terminal.rows);
} catch (e) {
log('Initial fit failed:', e);
}
// Load WebGL addon
try {
const webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
log('WebGL context lost');
webglAddon.dispose();
webglAddonRef.current = null;
});
terminal.loadAddon(webglAddon);
webglAddonRef.current = webglAddon;
log('WebGL addon loaded');
} catch (e) {
log('WebGL failed, using canvas:', e);
}
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
// No need for transparent background hacks — WebGL uses the theme bg directly
// Debounced resize handler
const handleResize = () => {
const rect = termContainer.getBoundingClientRect();
log('ResizeObserver:', rect.width, 'x', rect.height);
// Skip zero/tiny dimensions
if (rect.width < MIN_WIDTH || rect.height < MIN_HEIGHT) {
log('Skipping resize - too small');
return;
}
// Clear pending resize
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
// Debounce the actual fit/resize
resizeTimeoutRef.current = setTimeout(() => {
fitAndResize();
}, 100);
};
const resizeObserver = new ResizeObserver(handleResize);
resizeObserver.observe(termContainer);
// Store cleanup
return () => {
log('Terminal cleanup');
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeObserver.disconnect();
if (webglAddonRef.current) {
webglAddonRef.current.dispose();
webglAddonRef.current = null;
}
terminal.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
isInitializedRef.current = false;
lastDimensionsRef.current = { cols: 0, rows: 0 };
};
}
return () => {
log('Effect cleanup');
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
if (webglAddonRef.current) {
webglAddonRef.current.dispose();
webglAddonRef.current = null;
}
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
}
fitAddonRef.current = null;
isInitializedRef.current = false;
lastDimensionsRef.current = { cols: 0, rows: 0 };
};
}, [fitAndResize]);
// Connect WebSocket when ready
useEffect(() => {
log('WebSocket effect:', { taskId, sessionId, mode, terminalId, canConnect, hasTerminal: !!xtermRef.current });
if (!taskId || !sessionId || !canConnect) {
log('WebSocket effect: early return - missing requirements', { taskId, sessionId, canConnect });
return;
}
if (!xtermRef.current || !fitAddonRef.current) {
log('Terminal not ready for WebSocket');
return;
}
const terminal = xtermRef.current;
let isMounted = true;
let connectTimeout: ReturnType<typeof setTimeout> | null = null;
let refreshInterval: ReturnType<typeof setInterval> | null = null;
// Delay connection to handle React Strict Mode
connectTimeout = setTimeout(() => {
if (!isMounted) return;
// Close existing connection
if (attachAddonRef.current) {
attachAddonRef.current.dispose();
attachAddonRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
// Build WebSocket URL based on mode
let wsUrl: string;
if (mode === 'agent') {
wsUrl = `${wsBaseUrl}/terminal/${sessionId}?mode=agent`;
} else {
wsUrl = `${wsBaseUrl}/terminal/${sessionId}?mode=shell&terminalId=${encodeURIComponent(terminalId!)}`;
}
if (label) {
wsUrl += `&label=${encodeURIComponent(label)}`;
}
log('Connecting to', wsUrl, { mode, terminalId, label });
const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => {
if (!isMounted) {
ws.close();
return;
}
log('WebSocket connected');
// Attach to terminal
const attachAddon = new AttachAddon(ws, { bidirectional: true });
terminal.loadAddon(attachAddon);
attachAddonRef.current = attachAddon;
// Send initial resize to trigger PTY and get content
// Send multiple times with delay to ensure PTY redraws
const triggerRefresh = () => {
if (isMounted && ws.readyState === WebSocket.OPEN) {
fitAndResize(true);
}
};
// Immediate refresh
requestAnimationFrame(triggerRefresh);
// Follow-up refreshes to ensure content appears
setTimeout(triggerRefresh, 100);
setTimeout(triggerRefresh, 300);
setTimeout(triggerRefresh, 500);
// Also set up periodic refresh check for the first few seconds
let refreshCount = 0;
refreshInterval = setInterval(() => {
refreshCount++;
if (refreshCount > 5 || !isMounted) {
if (refreshInterval) clearInterval(refreshInterval);
return;
}
triggerRefresh();
}, 1000);
};
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);
};
}, 150);
return () => {
log('WebSocket cleanup');
isMounted = false;
if (connectTimeout) clearTimeout(connectTimeout);
if (refreshInterval) clearInterval(refreshInterval);
if (attachAddonRef.current) {
attachAddonRef.current.dispose();
attachAddonRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
};
}, [taskId, sessionId, canConnect, fitAndResize, wsBaseUrl, mode, terminalId, label]);
return (
<div
ref={terminalRef}
className="h-full w-full overflow-hidden p-1"
style={{ minWidth: MIN_WIDTH, minHeight: MIN_HEIGHT }}
/>
);
}