'use client'; import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Terminal } from 'lucide-react'; import { Terminal as XTerm } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import '@xterm/xterm/css/xterm.css'; import { resolveAgentShellWsUrl } from '@/lib/agentShellWs'; const SHELL_FONT_PX = 14; const CWD_STORAGE_KEY = 'sb_agent_shell_cwd'; const INTRO_ACK_KEY = 'sb_agent_shell_intro_ack'; type Props = { active: boolean; expanded: boolean; onExpandedChange: (expanded: boolean) => void; }; function readStoredCwd(): string { if (typeof window === 'undefined') return ''; try { return window.localStorage.getItem(CWD_STORAGE_KEY) || ''; } catch { return ''; } } function readIntroAcknowledged(): boolean { if (typeof window === 'undefined') return false; try { return window.localStorage.getItem(INTRO_ACK_KEY) === '1'; } catch { return false; } } function ShellIntro({ onAcknowledge }: { onAcknowledge: () => void }) { return (
OPERATOR SHELL

Connect your own agent CLIs here — OpenClaw, Codex, Gemini, or whatever you run locally.

The session opens in your Shadowbroker workspace by default. Use it for repo scripts, mesh tools, or any terminal workflow you already rely on.

); } export default function AgentShellPanel({ active, expanded, onExpandedChange }: Props) { const hostRef = useRef(null); const termRef = useRef(null); const fitRef = useRef(null); const wsRef = useRef(null); const [introAcknowledged, setIntroAcknowledged] = useState(false); const [status, setStatus] = useState<'idle' | 'connecting' | 'open' | 'error'>('idle'); const [statusDetail, setStatusDetail] = useState(''); const [cwd, setCwd] = useState(''); const shellReady = active && introAcknowledged; useEffect(() => { setIntroAcknowledged(readIntroAcknowledged()); }, [active]); const acknowledgeIntro = useCallback(() => { try { window.localStorage.setItem(INTRO_ACK_KEY, '1'); } catch { // still allow in-session access if storage is blocked } setIntroAcknowledged(true); }, []); const disconnect = useCallback(() => { wsRef.current?.close(); wsRef.current = null; termRef.current?.dispose(); termRef.current = null; fitRef.current = null; setStatus('idle'); }, []); const fitTerminal = useCallback(() => { const fit = fitRef.current; const term = termRef.current; const ws = wsRef.current; if (!fit || !term) return; fit.fit(); if (ws?.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows, }), ); } }, []); const connect = useCallback(() => { if (!hostRef.current) return; if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } if (termRef.current) { termRef.current.dispose(); termRef.current = null; fitRef.current = null; } const term = new XTerm({ fontFamily: 'var(--font-roboto-mono), ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: SHELL_FONT_PX, lineHeight: 1.35, cursorBlink: true, theme: { background: '#04070b', foreground: '#d9f7ff', cursor: '#22d3ee', selectionBackground: '#0e7490', }, scrollback: 5000, }); const fit = new FitAddon(); term.loadAddon(fit); term.open(hostRef.current); fit.fit(); termRef.current = term; fitRef.current = fit; const storedCwd = readStoredCwd(); setCwd(storedCwd); setStatus('connecting'); setStatusDetail(''); const ws = new WebSocket(resolveAgentShellWsUrl(storedCwd)); ws.binaryType = 'arraybuffer'; wsRef.current = ws; ws.onopen = () => { setStatus('open'); fit.fit(); ws.send( JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows, }), ); term.focus(); }; ws.onmessage = (event) => { if (typeof event.data === 'string') { try { const payload = JSON.parse(event.data) as { type?: string; message?: string }; if (payload.type === 'error') { setStatus('error'); setStatusDetail(payload.message || 'Shell unavailable'); term.writeln(`\r\n\x1b[31m${payload.message || 'Shell unavailable'}\x1b[0m`); return; } } catch { term.write(event.data); return; } } if (event.data instanceof ArrayBuffer) { term.write(new Uint8Array(event.data)); } }; ws.onerror = () => { setStatus('error'); setStatusDetail('Could not connect to the local agent shell endpoint.'); term.writeln('\r\n\x1b[31mCould not connect to the local agent shell endpoint.\x1b[0m'); }; ws.onclose = (event) => { if (event.code === 4403) { setStatus('error'); setStatusDetail('Local operator access only — reload the dashboard on localhost and retry.'); term.writeln('\r\n\x1b[31mShell blocked: local operator access only.\x1b[0m'); return; } setStatus((prev) => (prev === 'error' ? prev : 'idle')); if (event.code !== 1000) { term.writeln(`\r\n\x1b[90m[session closed: ${event.code}]\x1b[0m`); } }; term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(new TextEncoder().encode(data)); } }); }, []); useLayoutEffect(() => { if (!shellReady) { disconnect(); return; } let cancelled = false; const start = () => { if (cancelled || !hostRef.current) return; if (!wsRef.current || wsRef.current.readyState === WebSocket.CLOSED) { connect(); } else { fitTerminal(); } }; const frame = requestAnimationFrame(() => requestAnimationFrame(start)); return () => { cancelled = true; cancelAnimationFrame(frame); }; }, [shellReady, connect, disconnect, fitTerminal]); useEffect(() => { if (!shellReady) return; const host = hostRef.current; if (!host) return; const ro = new ResizeObserver(() => fitTerminal()); ro.observe(host); const timer = window.setTimeout(() => fitTerminal(), expanded ? 240 : 32); return () => { ro.disconnect(); window.clearTimeout(timer); }; }, [shellReady, expanded, fitTerminal]); useEffect(() => { if (!shellReady) return; const onResize = () => fitTerminal(); window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, [shellReady, fitTerminal]); if (!active) { return (
LOCAL SHELL
Expand Meshtastic Chat to open your operator shell.
); } if (!introAcknowledged) { return ; } return (
{cwd ? cwd : 'operator shell'}
{!expanded ? ( ) : ( )}
{status === 'error' && statusDetail && (
{statusDetail}
)} {status === 'connecting' && (
Connecting…
)}
); }