"use client"; import { useEffect, useRef, useState } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { cn } from "@/lib/cn"; // Import xterm CSS (must be imported in client component) import "xterm/css/xterm.css"; // Dynamically import xterm types for TypeScript import type { Terminal as TerminalType } from "xterm"; import type { FitAddon as FitAddonType } from "@xterm/addon-fit"; interface DirectTerminalProps { sessionId: string; startFullscreen?: boolean; /** Visual variant. "orchestrator" uses violet accent; "agent" (default) uses blue. */ variant?: "agent" | "orchestrator"; /** CSS height for the terminal container in normal (non-fullscreen) mode. * Defaults to "max(440px, calc(100vh - 440px))". */ height?: string; } /** * Direct xterm.js terminal with native WebSocket connection. * Implements Extended Device Attributes (XDA) handler to enable * tmux clipboard support (OSC 52) without requiring iTerm2 attachment. * * Based on DeepWiki analysis: * - tmux queries for XDA (CSI > q / XTVERSION) to detect terminal type * - When tmux sees "XTerm(" in response, it enables TTYC_MS (clipboard) * - xterm.js doesn't implement XDA by default, so we register custom handler */ export function DirectTerminal({ sessionId, startFullscreen = false, variant = "agent", height = "max(440px, calc(100vh - 440px))", }: DirectTerminalProps) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const terminalRef = useRef(null); const terminalInstance = useRef(null); const fitAddon = useRef(null); const ws = useRef(null); const [fullscreen, setFullscreen] = useState(startFullscreen); const [status, setStatus] = useState<"connecting" | "connected" | "error">("connecting"); const [error, setError] = useState(null); // Update URL when fullscreen changes useEffect(() => { const params = new URLSearchParams(searchParams.toString()); if (fullscreen) { params.set("fullscreen", "true"); } else { params.delete("fullscreen"); } const newUrl = params.toString() ? `${pathname}?${params.toString()}` : pathname; router.replace(newUrl, { scroll: false }); }, [fullscreen, pathname, router, searchParams]); useEffect(() => { if (!terminalRef.current) return; // Prevent retry loop on persistent errors if (error && status === "error") return; // Dynamically import xterm.js to avoid SSR issues let mounted = true; let cleanup: (() => void) | null = null; Promise.all([ import("xterm").then((mod) => mod.Terminal), import("@xterm/addon-fit").then((mod) => mod.FitAddon), import("@xterm/addon-web-links").then((mod) => mod.WebLinksAddon), ]) .then(([Terminal, FitAddon, WebLinksAddon]) => { if (!mounted || !terminalRef.current) return; // Cursor and selection color differ by variant: // agent = blue (#5b7ef8), orchestrator = violet (#a371f7) const cursorColor = variant === "orchestrator" ? "#a371f7" : "#5b7ef8"; const selectionColor = variant === "orchestrator" ? "rgba(163, 113, 247, 0.25)" : "rgba(91, 126, 248, 0.3)"; // Initialize xterm.js Terminal const terminal = new Terminal({ cursorBlink: true, fontSize: 13, fontFamily: '"IBM Plex Mono", "SF Mono", Menlo, Monaco, "Courier New", monospace', theme: { background: "#0a0a0f", foreground: "#d4d4d8", cursor: cursorColor, cursorAccent: "#0a0a0f", selectionBackground: selectionColor, // ANSI colors — slightly warmer than pure defaults black: "#1a1a24", red: "#ef4444", green: "#22c55e", yellow: "#f59e0b", blue: "#5b7ef8", magenta: "#a371f7", cyan: "#22d3ee", white: "#d4d4d8", brightBlack: "#50506a", brightRed: "#f87171", brightGreen: "#4ade80", brightYellow: "#fbbf24", brightBlue: "#7b9cfb", brightMagenta: "#c084fc", brightCyan: "#67e8f9", brightWhite: "#eeeef5", }, scrollback: 10000, allowProposedApi: true, fastScrollModifier: "alt", fastScrollSensitivity: 3, scrollSensitivity: 1, }); // Add FitAddon for responsive sizing const fit = new FitAddon(); terminal.loadAddon(fit); fitAddon.current = fit; // Add WebLinksAddon for clickable links const webLinks = new WebLinksAddon(); terminal.loadAddon(webLinks); // **CRITICAL FIX**: Register XDA (Extended Device Attributes) handler // This makes tmux recognize our terminal and enable clipboard support terminal.parser.registerCsiHandler( { prefix: ">", final: "q" }, // CSI > q is XTVERSION / XDA () => { // Respond with XTerm identification that tmux recognizes // tmux looks for "XTerm(" in the response (see tmux tty-keys.c) // Format: DCS > | XTerm(version) ST // DCS = \x1bP, ST = \x1b\\ terminal.write("\x1bP>|XTerm(370)\x1b\\"); console.log("[DirectTerminal] Sent XDA response for clipboard support"); return true; // Handled }, ); // Register OSC 52 handler for clipboard support // tmux sends OSC 52 with base64-encoded text when copying terminal.parser.registerOscHandler(52, (data) => { const parts = data.split(";"); if (parts.length < 2) return false; const b64 = parts[parts.length - 1]; try { // Decode base64 → binary string → Uint8Array → UTF-8 text // atob() alone only handles Latin-1; TextDecoder is needed for UTF-8 const binary = atob(b64); const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0)); const text = new TextDecoder().decode(bytes); navigator.clipboard?.writeText(text).catch(() => {}); } catch { // Ignore decode errors } return true; }); // Open terminal in DOM terminal.open(terminalRef.current); terminalInstance.current = terminal; // Fit terminal to container fit.fit(); // Connect WebSocket const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const hostname = window.location.hostname; const port = process.env.NEXT_PUBLIC_DIRECT_TERMINAL_PORT ?? "14801"; const wsUrl = `${protocol}//${hostname}:${port}/ws?session=${encodeURIComponent(sessionId)}`; console.log("[DirectTerminal] Connecting to:", wsUrl); const websocket = new WebSocket(wsUrl); ws.current = websocket; websocket.binaryType = "arraybuffer"; websocket.onopen = () => { console.log("[DirectTerminal] WebSocket connected"); setStatus("connected"); setError(null); // Send initial size websocket.send( JSON.stringify({ type: "resize", cols: terminal.cols, rows: terminal.rows, }), ); }; websocket.onmessage = (event) => { const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data); terminal.write(data); }; websocket.onerror = (event) => { console.error("[DirectTerminal] WebSocket error:", event); setStatus("error"); setError("WebSocket connection error"); }; websocket.onclose = (event) => { console.log("[DirectTerminal] WebSocket closed:", event.code, event.reason); if (status === "connected") { setStatus("error"); setError("Connection closed"); } }; // Terminal input → WebSocket const disposable = terminal.onData((data) => { if (websocket.readyState === WebSocket.OPEN) { websocket.send(data); } }); // Handle window resize const handleResize = () => { if (fit && websocket.readyState === WebSocket.OPEN) { fit.fit(); websocket.send( JSON.stringify({ type: "resize", cols: terminal.cols, rows: terminal.rows, }), ); } }; window.addEventListener("resize", handleResize); // Store cleanup function to be called from useEffect cleanup cleanup = () => { window.removeEventListener("resize", handleResize); disposable.dispose(); websocket.close(); terminal.dispose(); }; }) .catch((err) => { console.error("[DirectTerminal] Failed to load xterm.js:", err); setStatus("error"); setError("Failed to load terminal"); }); return () => { mounted = false; cleanup?.(); }; }, [sessionId, variant]); // Re-fit terminal when fullscreen changes useEffect(() => { const fit = fitAddon.current; const terminal = terminalInstance.current; const websocket = ws.current; const container = terminalRef.current; if (!fit || !terminal || !websocket || websocket.readyState !== WebSocket.OPEN || !container) { return; } let resizeAttempts = 0; const maxAttempts = 60; let cancelled = false; let rafId = 0; let lastHeight = -1; const resizeTerminal = () => { if (cancelled) return; resizeAttempts++; // Wait for the container height to stabilise (CSS transition finished) const currentHeight = container.getBoundingClientRect().height; const settled = lastHeight >= 0 && Math.abs(currentHeight - lastHeight) < 1; lastHeight = currentHeight; if (!settled && resizeAttempts < maxAttempts) { // Container is still transitioning, try again next frame rafId = requestAnimationFrame(resizeTerminal); return; } // Container is at target size, now resize terminal terminal.refresh(0, terminal.rows - 1); fit.fit(); terminal.refresh(0, terminal.rows - 1); // Send new size to server websocket.send( JSON.stringify({ type: "resize", cols: terminal.cols, rows: terminal.rows, }), ); }; // Start resize polling rafId = requestAnimationFrame(resizeTerminal); // Also try on transitionend const handleTransitionEnd = (e: TransitionEvent) => { if (cancelled) return; if (e.target === container.parentElement) { resizeAttempts = 0; lastHeight = -1; setTimeout(() => { if (!cancelled) rafId = requestAnimationFrame(resizeTerminal); }, 50); } }; const parent = container.parentElement; parent?.addEventListener("transitionend", handleTransitionEnd); // Backup timers in case RAF polling doesn't work const timer1 = setTimeout(() => { if (cancelled) return; resizeAttempts = 0; lastHeight = -1; resizeTerminal(); }, 300); const timer2 = setTimeout(() => { if (cancelled) return; resizeAttempts = 0; lastHeight = -1; resizeTerminal(); }, 600); return () => { cancelled = true; cancelAnimationFrame(rafId); parent?.removeEventListener("transitionend", handleTransitionEnd); clearTimeout(timer1); clearTimeout(timer2); }; }, [fullscreen]); const accentColor = variant === "orchestrator" ? "var(--color-accent-violet)" : "var(--color-accent)"; const statusDotClass = status === "connected" ? "bg-[var(--color-status-ready)]" : status === "error" ? "bg-[var(--color-status-error)]" : "bg-[var(--color-status-attention)] animate-[pulse_1.5s_ease-in-out_infinite]"; const statusText = status === "connected" ? "Connected" : status === "error" ? (error ?? "Error") : "Connecting…"; const statusTextColor = status === "connected" ? "text-[var(--color-status-ready)]" : status === "error" ? "text-[var(--color-status-error)]" : "text-[var(--color-text-tertiary)]"; return (
{/* Terminal chrome bar */}
{sessionId} {statusText} {/* XDA clipboard badge */} XDA
{/* Terminal area */}
); }