import React, { useEffect, useState, useRef } from 'react';
import { getNodeStreamUrl } from '../services/apiService';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
// Sub-component for each terminal window to encapsulate Xterm.js logic
const TerminalNodeItem = ({ nodeId, stats, onMount, onUnmount, nodeConfig, isAIProcessing, thoughtHistory = [] }) => {
const terminalRef = useRef(null);
const xtermRef = useRef(null);
const fitAddonRef = useRef(null);
const [isDetailsExpanded, setIsDetailsExpanded] = useState(false);
const sandbox = nodeConfig?.skill_config?.shell?.sandbox || {};
const mode = sandbox.mode || 'PASSIVE';
const isStrict = mode === 'STRICT';
// UI Feedback: boundary color & pulse logic
const borderClass = isStrict
? (isAIProcessing ? 'border-red-500/80 ring-1 ring-red-500/40 animate-[pulse-red_2s_infinite]' : 'border-red-900/50')
: (isAIProcessing ? 'border-blue-500/80 ring-1 ring-blue-500/40 animate-[pulse-blue_2s_infinite]' : 'border-blue-900/50');
const statusDotClass = isStrict ? 'bg-rose-500' : 'bg-blue-500';
useEffect(() => {
const xterm = new Terminal({
theme: {
background: '#030712',
foreground: '#e6edf3',
cursor: isAIProcessing ? '#388bfd' : '#22c55e',
selectionBackground: '#388bfd',
},
fontSize: 12,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
cursorBlink: isAIProcessing,
cursorStyle: 'block',
convertEol: true,
scrollback: 1000,
disableStdin: true,
scrollOnOutput: true,
});
const fitAddon = new FitAddon();
xterm.loadAddon(fitAddon);
xterm.open(terminalRef.current);
setTimeout(() => fitAddon.fit(), 10);
xtermRef.current = xterm;
fitAddonRef.current = fitAddon;
onMount(nodeId, xterm);
const observer = new ResizeObserver(() => fitAddon.fit());
if (terminalRef.current) observer.observe(terminalRef.current);
return () => {
observer.disconnect();
onUnmount(nodeId);
xterm.dispose();
};
}, [nodeId]);
const latestThought = thoughtHistory.length > 0 ? thoughtHistory[thoughtHistory.length - 1] : null;
return (
<div className={`flex-1 flex flex-col min-w-0 border-r last:border-r-0 transition-all duration-700 ${borderClass}`}>
<div className={`bg-gray-900/80 px-3 py-1.5 border-b border-gray-800 flex justify-between items-center text-[9px] font-bold group relative overflow-hidden`}>
<div className={`absolute inset-0 opacity-5 pointer-events-none bg-gradient-to-r ${isStrict ? 'from-rose-500' : 'from-blue-500'} to-transparent`}></div>
<div className="flex items-center gap-2 relative z-10">
<span className={`w-1.5 h-1.5 rounded-full ${statusDotClass} ${isAIProcessing ? 'animate-pulse' : ''}`}></span>
<span className="text-gray-200 tracking-wider truncate max-w-[80px]">{nodeId}</span>
<button
onClick={() => setIsDetailsExpanded(!isDetailsExpanded)}
className={`text-[8px] uppercase tracking-tighter px-1.5 py-0.5 rounded flex items-center gap-1 transition-colors hover:bg-white/10 ${isStrict ? 'bg-rose-950/50 text-rose-400 border border-rose-900/50' : 'bg-blue-950/50 text-blue-400 border border-blue-900/50'}`}
>
{mode}
<span className={`text-[7px] transform transition-transform duration-200 ${isDetailsExpanded ? 'rotate-180' : ''}`}>▼</span>
</button>
</div>
<div className="flex space-x-3 text-gray-400 font-mono relative z-10">
<span title="CPU Usage">C: <span className="text-emerald-400">{stats?.cpu_usage_percent?.toFixed(1) || '0.0'}%</span></span>
<span title="Memory Usage">M: <span className="text-emerald-400">{stats?.memory_usage_percent?.toFixed(1) || '0.0'}%</span></span>
</div>
</div>
<div className="flex-1 overflow-hidden bg-[#030712] relative p-1">
<div ref={terminalRef} className="w-full h-full" />
{/* Internal Reasoning Details Panel */}
<div className={`absolute left-0 right-0 bottom-0 bg-gray-950/95 backdrop-blur-2xl border-t border-white/10 transition-all duration-500 z-50 overflow-hidden flex flex-col shadow-[0_-10px_40px_rgba(0,0,0,0.5)] ${isDetailsExpanded ? 'h-3/5 opacity-100' : 'h-0 opacity-0 pointer-events-none'}`}>
<div className="px-3 py-2 bg-gray-900/80 flex justify-between items-center border-b border-white/5 sticky top-0 z-10">
<span className="text-[8px] uppercase tracking-[0.2em] font-black text-blue-400 flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse shadow-[0_0_8px_rgba(59,130,246,0.5)]"></div>
Autonomous Analysis Trace
</span>
<button onClick={() => setIsDetailsExpanded(false)} className="text-gray-500 hover:text-white transition-colors p-1">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3 scrollbar-thin scrollbar-thumb-gray-800 scroll-smooth">
{thoughtHistory.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-30 select-none">
<div className="text-[8px] uppercase tracking-widest font-bold mb-2">Idle Monitoring</div>
<div className="text-[10px] italic">Waiting for agentic activity...</div>
</div>
) : (
thoughtHistory.map((t, i) => (
<div key={i} className="flex gap-3 animate-in fade-in slide-in-from-bottom-2 duration-700 ease-out fill-mode-forwards">
<div className="text-[8px] font-mono select-none mt-1 opacity-50 tabular-nums">
{new Date(t.time * 1000).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
<div className={`text-[10px] leading-relaxed font-medium break-words overflow-wrap-anywhere flex-1 ${t.type === 'mesh_observation' ? 'text-amber-500 italic' : 'text-gray-200'}`}>
{t.type === 'mesh_observation' && '⚠️ '}
{t.thought}
</div>
</div>
))
)}
</div>
</div>
{/* Minimalist Live Analysis Notice (Non-intrusive when collapsed) */}
{isAIProcessing && latestThought && !isDetailsExpanded && (
<div className="absolute top-2 right-2 flex items-center gap-2 animate-in fade-in duration-700 pointer-events-none">
<div className="bg-blue-500/10 backdrop-blur-md border border-blue-500/20 rounded px-2 py-0.5 flex items-center gap-2 shadow-xl">
<span className="flex h-1.5 w-1.5 relative">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-blue-500"></span>
</span>
<span className="text-[8px] text-blue-400/90 font-bold tracking-tight truncate max-w-[120px]">{latestThought.thought}</span>
</div>
</div>
)}
{isAIProcessing && !latestThought && !isDetailsExpanded && (
<div className="absolute top-1 right-2 pointer-events-none opacity-40">
<span className="flex h-2 w-2">
<span className={`${isStrict ? 'bg-rose-400' : 'bg-blue-400'} animate-ping absolute inline-flex h-full w-full rounded-full opacity-75`}></span>
<span className={`relative inline-flex rounded-full h-2 w-2 ${isStrict ? 'bg-rose-500' : 'bg-blue-500'}`}></span>
</span>
</div>
)}
</div>
<style dangerouslySetInnerHTML={{
__html: `
@keyframes pulse-red {
0%, 100% { border-color: rgba(244, 63, 94, 0.4); box-shadow: 0 0 5px rgba(244, 63, 94, 0.1); }
50% { border-color: rgba(244, 63, 94, 0.8); box-shadow: 0 0 15px rgba(244, 63, 94, 0.3); }
}
@keyframes pulse-blue {
0%, 100% { border-color: rgba(59, 130, 246, 0.4); box-shadow: 0 0 5px rgba(59, 130, 246, 0.1); }
50% { border-color: rgba(59, 130, 246, 0.8); box-shadow: 0 0 15px rgba(59, 130, 246, 0.3); }
}
`}} />
</div>
);
};
const MultiNodeConsole = ({ attachedNodeIds, nodes, isAIProcessing, isExpanded, onToggleExpand }) => {
const [nodeStats, setNodeStats] = useState({}); // node_id -> stats object
const [nodeHistory, setNodeHistory] = useState({}); // node_id -> array of {time, thought}
const [connected, setConnected] = useState(false);
const wsRef = useRef(null);
const xtermsRef = useRef({});
useEffect(() => {
if (!attachedNodeIds || attachedNodeIds.length === 0) return;
let reconnectTimer;
let isClosing = false;
const connect = () => {
if (isClosing) return;
const ws = new WebSocket(getNodeStreamUrl());
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onmessage = (event) => {
if (isClosing) return;
try {
const msg = JSON.parse(event.data);
if (msg.node_id && !attachedNodeIds.includes(msg.node_id)) return;
if ((msg.event === 'mesh_heartbeat' || msg.event === 'heartbeat')) {
if (msg.data?.stats) setNodeStats(prev => ({ ...prev, [msg.node_id]: msg.data.stats }));
if (msg.data?.nodes) msg.data.nodes.forEach(n => setNodeStats(prev => ({ ...prev, [n.node_id]: n.stats })));
return;
}
// Handle Sub-Agent Thoughts & Mesh Observations
if (msg.event === 'subagent_thought' || msg.event === 'mesh_observation') {
setNodeHistory(prev => {
const nodeHistory = prev[msg.node_id] || [];
const content = msg.event === 'mesh_observation' ? msg.data.message : msg.data;
// Avoid exact duplicates back-to-back
if (nodeHistory.length > 0 && nodeHistory[nodeHistory.length - 1].thought === content) return prev;
const newEntry = { time: Date.now() / 1000, thought: content, type: msg.event };
return { ...prev, [msg.node_id]: [...nodeHistory, newEntry] };
});
return;
}
const xterm = xtermsRef.current[msg.node_id];
if (xterm) {
switch (msg.event) {
case 'task_assigned':
if (msg.data.command) {
if (msg.data.command.includes('__CORTEX_FIN_SH_')) break;
if (msg.data.command.startsWith('!RAW:')) {
xterm.write(`\x1b[38;5;36m${msg.data.command.slice(5)}\x1b[0m\r\n`);
} else {
xterm.write(`\x1b[38;5;33m\x1b[1m$ ${msg.data.command}\x1b[0m\r\n`);
}
// NEW: Clear thought history when a new bash command starts to maintain relevance
setNodeHistory(prev => ({ ...prev, [msg.node_id]: [] }));
}
break;
case 'task_stdout':
case 'skill_event':
let data = msg.event === 'skill_event' ? (msg.data?.data || msg.data?.terminal_out) : msg.data;
if (data && typeof data === 'string') {
const stealthData = data.replace(/.*__CORTEX_FIN_SH_.*[\r\n]*/g, '');
if (stealthData) xterm.write(stealthData);
} else if (data) xterm.write(data);
break;
case 'browser_event': xterm.write(`\x1b[90m${msg.data.type === 'console' ? '🖥️' : '🌐'} ${msg.data.text || msg.data.url}\x1b[0m\r\n`); break;
}
// Always scroll to bottom on new output
xterm.scrollToBottom();
}
} catch (e) { }
};
ws.onclose = () => {
if (!isClosing) {
setConnected(false);
reconnectTimer = setTimeout(connect, 3000);
}
};
};
connect();
return () => {
isClosing = true;
if (wsRef.current) wsRef.current.close();
clearTimeout(reconnectTimer);
};
}, [JSON.stringify(attachedNodeIds)]);
const handleMount = (nodeId, xterm) => { xtermsRef.current[nodeId] = xterm; };
const handleUnmount = (nodeId) => { delete xtermsRef.current[nodeId]; };
if (!attachedNodeIds || attachedNodeIds.length === 0) return null;
return (
<div className={`flex flex-col h-full bg-gray-950 text-gray-300 font-mono border-t border-gray-800 transition-colors duration-500`}>
<div className={`bg-amber-500/10 border-b border-amber-500/20 px-4 py-1.5 flex justify-between items-center overflow-hidden shrink-0 z-10`} id="ai-terminal-notice">
<span className="text-[10px] font-black uppercase tracking-tighter text-amber-500 flex items-center gap-2">
<svg className={`w-3.5 h-3.5 shrink-0 ${isAIProcessing ? 'animate-bounce' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{isAIProcessing ? 'AI Agent actively controlling node terminals...' : 'Terminal Surfaced to AI :: Context Synchronized'}
</span>
<span className="text-[9px] text-amber-600 font-bold uppercase tracking-widest opacity-60 hidden md:inline font-mono">
OBSERVATION_MODE
</span>
</div>
<div className="flex bg-gray-950 border-b border-gray-800 px-4 py-1.5 justify-between items-center shrink-0">
<span className="flex items-center text-[10px] font-black tracking-[0.2em] text-gray-500">
<span className={`w-1.5 h-1.5 rounded-full mr-2.5 ${connected ? 'bg-emerald-500' : 'bg-red-500'} ${isAIProcessing && connected ? 'animate-ping shadow-[0_0_8px_#10b981]' : ''}`}></span>
NODE_EXECUTION_SWARM
</span>
<div className="flex gap-4 items-center">
{onToggleExpand && (
<button
onClick={onToggleExpand}
className="p-1 text-gray-400 hover:text-white transition-colors bg-gray-900/50 hover:bg-gray-800 rounded border border-gray-700"
title={isExpanded ? "Restore Size" : "Maximize Console"}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{isExpanded ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M19 9l-7 7-7-7" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 15l7-7 7 7" />
)}
</svg>
</button>
)}
<span className="text-gray-600 uppercase tracking-widest text-[8px] font-bold">Attached: {attachedNodeIds.length}</span>
<span className="text-gray-500 uppercase tracking-widest text-[9px] font-bold py-0.5 px-2 rounded-full border border-gray-800 bg-gray-950/50">Live Stream</span>
</div>
</div>
<div className="flex flex-1 overflow-hidden divide-x divide-gray-800">
{attachedNodeIds.map(nodeId => (
<TerminalNodeItem
key={nodeId}
nodeId={nodeId}
stats={nodeStats[nodeId]}
onMount={handleMount}
onUnmount={handleUnmount}
nodeConfig={nodes?.find(n => n.node_id === nodeId)}
isAIProcessing={isAIProcessing}
thoughtHistory={nodeHistory[nodeId]}
/>
))}
</div>
</div>
);
};
export default MultiNodeConsole;