import React, { useState, useEffect, useRef, useMemo } from 'react';
import { getNodeStreamUrl } from '../services/apiService';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
const NodeTerminal = ({ nodeId }) => {
const [isZoomed, setIsZoomed] = useState(false);
const [debugMode, setDebugMode] = useState(false);
const [latency, setLatency] = useState(null);
const terminalRef = useRef(null);
const wsRef = useRef(null);
const xtermRef = useRef(null);
const fitAddonRef = useRef(null);
const sessionId = useMemo(() => `terminal-${nodeId}-${Math.random().toString(36).substr(2, 9)}`, [nodeId]);
// 1. Core Terminal Engine Initialization
useEffect(() => {
const xterm = new Terminal({
cursorBlink: true,
theme: {
background: '#030712', // Matches Tailwind bg-gray-950
foreground: '#10b981', // Matches text-emerald-500 loosely
cursor: '#10b981',
cursorAccent: '#030712',
},
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
fontSize: 13,
rightClickSelectsWord: true,
scrollback: 1000,
convertEol: true,
scrollOnOutput: true,
});
const fitAddon = new FitAddon();
xterm.loadAddon(fitAddon);
xterm.open(terminalRef.current);
fitAddon.fit();
xtermRef.current = xterm;
fitAddonRef.current = fitAddon;
// Auto-refit terminal rows/cols when user resizes Chrome window
const observer = new ResizeObserver(() => fitAddon.fit());
if (terminalRef.current) {
observer.observe(terminalRef.current);
}
return () => {
observer.disconnect();
xterm.dispose();
};
}, []);
// 2. Headless WS Comm Link & Keystroke Forwarding
useEffect(() => {
const ws = new WebSocket(getNodeStreamUrl(nodeId));
wsRef.current = ws;
ws.onopen = () => {
xtermRef.current?.writeln('\x1b[32m\x1b[1m[CORTEX MESH] Stream Established — Persistent PTY Active\x1b[0m\r');
// Force initial resize sync
fitAddonRef.current?.fit();
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
// --- 1. Periodic Latency Monitor (Pong Response) ---
if (msg.event === 'pong' && msg.client_ts) {
const rtt = Date.now() - msg.client_ts;
setLatency(rtt);
}
// --- 2. Skill Events (Terminal stdout) ---
else if (msg.event === 'skill_event' && msg.data?.type === 'output') {
// Crucial: Just blindly pipe pure PTY ANSI stdout to Xterm directly! No formatting!
xtermRef.current?.write(msg.data.data);
}
else if (msg.event === 'task_error') {
const errStr = msg.data?.stderr || JSON.stringify(msg.data);
xtermRef.current?.writeln(`\r\n\x1b[31m[ERROR] ${errStr}\x1b[0m\r`);
}
else if (debugMode) {
// Debug events visible natively inline in the terminal text
if (msg.event === 'task_start') {
xtermRef.current?.writeln(`\r\n\x1b[33m[DEBUG] task_start id=${msg.task_id || '?'}\x1b[0m\r`);
} else if (msg.event === 'task_complete') {
xtermRef.current?.writeln(`\r\n\x1b[33m[DEBUG] task_complete status=${msg.data?.status ?? '?'} id=${msg.task_id || '?'}\x1b[0m\r`);
} else if (msg.event === 'snapshot') {
xtermRef.current?.writeln(`\x1b[33m[DEBUG] snapshot status=${msg.data?.status || '?'}\x1b[0m\r`);
}
}
} catch (e) {
// Ignore raw failures
}
};
ws.onerror = () => xtermRef.current?.writeln('\r\n\x1b[31m[WebSocket connection error]\x1b[0m\r');
ws.onclose = () => xtermRef.current?.writeln('\r\n\x1b[31m[WebSocket disconnected]\x1b[0m\r');
// BIND XTERM DATA TO SOCKET
const disposableData = xtermRef.current?.onData((data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
action: "dispatch",
command: JSON.stringify({ tty: data }),
session_id: sessionId
}));
}
});
const disposableResize = xtermRef.current?.onResize(({ cols, rows }) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
action: "dispatch",
command: JSON.stringify({ action: "resize", cols, rows }),
session_id: sessionId
}));
}
});
// --- PERIODIC LATENCY PING (Pulse) ---
const pingInterval = setInterval(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
action: "ping",
ts: Date.now()
}));
}
}, 3000);
return () => {
disposableData?.dispose();
disposableResize?.dispose();
clearInterval(pingInterval);
ws.close();
};
}, [nodeId, sessionId, debugMode]);
// Force XTerm internal Grid fit on zoom toggle
useEffect(() => {
const timeoutId = setTimeout(() => {
fitAddonRef.current?.fit();
}, 150);
return () => clearTimeout(timeoutId);
}, [isZoomed]);
const handleClear = () => {
xtermRef.current?.clear();
};
return (
<div className={`
bg-gray-950 text-gray-300 font-mono text-xs rounded-xl overflow-hidden shadow-2xl border border-gray-800 flex flex-col transition-all duration-300
${isZoomed ? 'fixed inset-4 z-[100] h-auto' : 'h-80 mt-4 relative'}
`}>
{isZoomed && <div className="fixed inset-0 bg-black/40 backdrop-blur-sm -z-10" onClick={() => setIsZoomed(false)}></div>}
{/* Title Bar */}
<div className="bg-gray-900/80 backdrop-blur px-4 py-2.5 flex justify-between items-center text-[10px] tracking-widest text-gray-400 uppercase font-bold border-b border-gray-800 flex-shrink-0">
<span className="flex items-center">
<span className="w-2 h-2 rounded-full bg-emerald-500 mr-3 animate-pulse shadow-sm shadow-emerald-500/50"></span>
<span className="text-gray-200">Interactive Console</span>
<span className="ml-3 text-gray-600 lowercase font-normal tracking-normal hidden sm:inline">— {nodeId}</span>
{latency !== null && (
<span className="ml-4 px-1.5 py-0.5 rounded bg-gray-950/80 text-[10px] text-emerald-400 font-bold tracking-widest border border-emerald-500/20 shadow-sm shadow-emerald-500/10">
{latency}ms
</span>
)}
</span>
<div className="flex items-center gap-3">
{/* Debug Mode Toggle */}
<button
onClick={() => setDebugMode(d => !d)}
title={debugMode ? 'Debug mode ON — click to disable' : 'Debug mode OFF — click to enable'}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md text-[9px] font-black uppercase tracking-widest transition-all border ${debugMode
? 'bg-amber-500/20 border-amber-500/50 text-amber-400 shadow shadow-amber-500/20'
: 'bg-gray-800 border-gray-700 text-gray-600 hover:text-gray-400 hover:border-gray-600'
}`}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M12 18h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2zM9.5 8h5M9.5 11h5M9.5 14h3" />
</svg>
{debugMode ? 'Debug ON' : 'Debug'}
</button>
{/* Clear */}
<button
onClick={handleClear}
title="Clear terminal"
className="text-gray-600 hover:text-gray-400 transition-colors p-1"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
{/* Zoom */}
<button
onClick={() => setIsZoomed(!isZoomed)}
title={isZoomed ? 'Exit fullscreen' : 'Fullscreen'}
className="text-gray-600 hover:text-white transition-colors p-1"
>
{isZoomed ? (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 4l-5-5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" /></svg>
)}
</button>
</div>
</div>
{/* XTerm Host Area */}
{/* Note: 'h-auto' combined with 'flex-1' guarantees XTerm occupies exactly the rest of the flex parent container */}
<div
className="flex-1 w-full pl-3 pr-2 py-3 overflow-hidden bg-[#030712] rounded-b-xl"
ref={terminalRef}
/>
</div>
);
};
export default NodeTerminal;