Newer
Older
cortex-hub / frontend / src / components / NodeTerminal.js
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;