Replace mock Agent Shell overlay with inline xterm PTY and dock/expand UX.

Uses a local-operator WebSocket bash session, keeps the map interactive, and SNAP docks the shell back into Mesh Chat instead of a floating blurred panel.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
BigBodyCobain
2026-06-10 11:30:50 -06:00
parent 0afb85e241
commit d1e1be4016
10 changed files with 503 additions and 249 deletions
@@ -127,7 +127,7 @@ describe('MeshChat behavior - policy wiring', () => {
it('MeshChat Agent Shell tab keeps legacy dm-add guidance in MeshTerminal', () => {
const index = readSource('../../components/MeshChat/index.tsx');
expect(index).toContain('AgentShellPanel');
expect(index).toContain('AGENT SHELL');
expect(index).toContain('SHELL');
expect(index).not.toContain('handleAddContact().catch(() =>');
const terminal = readSource('../../components/MeshTerminal.tsx');
expect(terminal).toContain('dm add');
@@ -1,281 +1,232 @@
'use client';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { GripHorizontal, Terminal } from 'lucide-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 STORAGE_KEY = 'sb_agent_shell_dims';
const SHELL_FONT_PX = 14;
const MIN_SHELL_WIDTH = 300;
const MIN_SHELL_HEIGHT = 220;
const STRETCH_WIDTH_RATIO = 2.15;
const STRETCH_MIN_WIDTH = 520;
type ShellSize = { w: number; h: number };
function readStoredSize(): ShellSize | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as ShellSize;
if (
typeof parsed?.w === 'number' &&
typeof parsed?.h === 'number' &&
parsed.w >= MIN_SHELL_WIDTH &&
parsed.h >= MIN_SHELL_HEIGHT
) {
return parsed;
}
} catch {
/* ignore */
}
return null;
}
function writeStoredSize(size: ShellSize) {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(size));
} catch {
/* ignore */
}
}
function clampSize(size: ShellSize, anchorLeft: number): ShellSize {
const maxW = Math.max(MIN_SHELL_WIDTH, window.innerWidth - anchorLeft - 12);
const maxH = Math.max(MIN_SHELL_HEIGHT, window.innerHeight - 12);
return {
w: Math.min(Math.max(size.w, MIN_SHELL_WIDTH), maxW),
h: Math.min(Math.max(size.h, MIN_SHELL_HEIGHT), maxH),
};
}
function defaultStretchedSize(anchor: DOMRect): ShellSize {
const stretchedW = Math.max(anchor.width * STRETCH_WIDTH_RATIO, STRETCH_MIN_WIDTH);
return clampSize({ w: stretchedW, h: anchor.height }, anchor.left);
}
const CWD_STORAGE_KEY = 'sb_agent_shell_cwd';
type Props = {
anchorRef: React.RefObject<HTMLElement | null>;
active: boolean;
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
};
export default function AgentShellPanel({ anchorRef, active }: Props) {
const [mounted, setMounted] = useState(false);
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
const [size, setSize] = useState<ShellSize>({ w: STRETCH_MIN_WIDTH, h: 360 });
const [pos, setPos] = useState({ x: 0, y: 0 });
const [userResized, setUserResized] = useState(Boolean(readStoredSize()));
const resizeRef = useRef<{
edge: 'e' | 's' | 'se';
startX: number;
startY: number;
origW: number;
origH: number;
} | null>(null);
function readStoredCwd(): string {
if (typeof window === 'undefined') return '';
try {
return window.localStorage.getItem(CWD_STORAGE_KEY) || '';
} catch {
return '';
}
}
useEffect(() => {
setMounted(true);
export default function AgentShellPanel({ active, expanded, onExpandedChange }: Props) {
const hostRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const [status, setStatus] = useState<'idle' | 'connecting' | 'open' | 'error'>('idle');
const [statusDetail, setStatusDetail] = useState('');
const [cwd, setCwd] = useState('');
const disconnect = useCallback(() => {
wsRef.current?.close();
wsRef.current = null;
termRef.current?.dispose();
termRef.current = null;
fitRef.current = null;
setStatus('idle');
}, []);
const measureAnchor = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setAnchorRect(rect);
setPos({ x: rect.left, y: rect.top });
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,
}),
);
}
}, []);
if (!userResized) {
setSize(defaultStretchedSize(rect));
const connect = useCallback(() => {
if (!active || !hostRef.current) return;
disconnect();
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 = () => {
setStatus((prev) => (prev === 'error' ? prev : 'idle'));
term.writeln('\r\n\x1b[90m[session closed]\x1b[0m');
};
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
});
}, [active, disconnect]);
useEffect(() => {
if (!active) {
disconnect();
return;
}
const stored = readStoredSize();
if (stored) {
setSize(clampSize(stored, rect.left));
} else {
setSize(defaultStretchedSize(rect));
}
}, [anchorRef, userResized]);
connect();
return () => disconnect();
}, [active, connect, disconnect]);
useEffect(() => {
if (!active) return;
measureAnchor();
const el = anchorRef.current;
if (!el) return;
const observer = new ResizeObserver(() => measureAnchor());
observer.observe(el);
const onWindowChange = () => measureAnchor();
window.addEventListener('resize', onWindowChange);
window.addEventListener('scroll', onWindowChange, true);
return () => {
observer.disconnect();
window.removeEventListener('resize', onWindowChange);
window.removeEventListener('scroll', onWindowChange, true);
};
}, [active, anchorRef, measureAnchor]);
const timer = window.setTimeout(() => fitTerminal(), expanded ? 220 : 0);
return () => window.clearTimeout(timer);
}, [active, expanded, fitTerminal]);
useEffect(() => {
if (!active || userResized) return;
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const base = { w: rect.width, h: rect.height };
setSize(base);
setPos({ x: rect.left, y: rect.top });
if (!active) return;
const onResize = () => fitTerminal();
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, [active, fitTerminal]);
const frame = window.requestAnimationFrame(() => {
setSize(defaultStretchedSize(rect));
});
return () => window.cancelAnimationFrame(frame);
}, [active, anchorRef, userResized]);
const beginResize = (edge: 'e' | 's' | 'se') => (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
resizeRef.current = {
edge,
startX: event.clientX,
startY: event.clientY,
origW: size.w,
origH: size.h,
};
const onMove = (ev: MouseEvent) => {
if (!resizeRef.current) return;
const dx = ev.clientX - resizeRef.current.startX;
const dy = ev.clientY - resizeRef.current.startY;
const { edge: ed, origW, origH } = resizeRef.current;
const anchorLeft = anchorRef.current?.getBoundingClientRect().left ?? pos.x;
const next: ShellSize = {
w: ed === 's' ? origW : origW + dx,
h: ed === 'e' ? origH : origH + dy,
};
setUserResized(true);
setSize(clampSize(next, anchorLeft));
};
const onUp = () => {
resizeRef.current = null;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
setSize((current) => {
writeStoredSize(current);
return current;
});
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
const snapToStretchedDefault = () => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setUserResized(false);
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
/* ignore */
}
setPos({ x: rect.left, y: rect.top });
setSize(defaultStretchedSize(rect));
};
if (!mounted || !active || !anchorRect) {
if (!active) {
return (
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-4 py-6 text-center border-l-2 border-cyan-800/20">
<Terminal size={18} className="text-cyan-400 mb-2" />
<div className="text-sm font-mono tracking-[0.2em] text-cyan-300">AGENT SHELL</div>
<Terminal size={16} className="text-cyan-400 mb-2" />
<div className="text-sm font-mono tracking-[0.2em] text-cyan-300">LOCAL SHELL</div>
<div className="mt-2 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
Expand Mesh Chat to open the local agent shell.
Expand Mesh Chat to open your operator shell.
</div>
</div>
);
}
const shell = (
<div
className="pointer-events-auto z-[250] flex flex-col border border-cyan-800/50 bg-[#05080c]/96 shadow-[0_18px_60px_rgba(0,0,0,0.55),0_0_0_1px_rgba(34,211,238,0.08)] backdrop-blur-sm"
style={{
position: 'fixed',
left: pos.x,
top: pos.y,
width: size.w,
height: size.h,
transition: userResized ? undefined : 'width 180ms ease-out',
}}
>
<div className="flex items-center justify-between gap-2 border-b border-cyan-900/40 px-3 py-2 shrink-0 select-none">
<div className="flex min-w-0 items-center gap-2">
<Terminal size={13} className="text-cyan-400 shrink-0" />
<span className="text-[13px] font-mono tracking-[0.18em] text-cyan-300">AGENT SHELL</span>
<span className="hidden sm:inline text-[12px] font-mono text-slate-500 truncate">
local CLI · user cwd
</span>
return (
<div className="flex-1 min-h-0 flex flex-col border-l-2 border-cyan-800/25 bg-[#04070b]">
<div className="flex items-center justify-between gap-2 border-b border-cyan-900/40 px-2 py-1.5 shrink-0">
<div className="min-w-0 text-[12px] font-mono tracking-[0.14em] text-cyan-300/90 truncate">
{cwd ? cwd : 'operator shell'}
</div>
<div className="flex items-center gap-1 shrink-0">
{!expanded ? (
<button
type="button"
onClick={() => onExpandedChange(true)}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
>
EXPAND
</button>
) : (
<button
type="button"
onClick={() => onExpandedChange(false)}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-cyan-300 border border-cyan-800/40 hover:bg-cyan-950/30"
>
SNAP
</button>
)}
<button
type="button"
onClick={snapToStretchedDefault}
className="px-2 py-1 text-[12px] font-mono tracking-[0.14em] text-cyan-300/80 border border-cyan-800/40 hover:bg-cyan-950/30 transition-colors"
title="Reset size to default stretch from Mesh Chat panel"
onClick={connect}
className="px-2 py-0.5 text-[11px] font-mono tracking-[0.12em] text-slate-400 border border-slate-700/40 hover:bg-white/5"
>
SNAP
RECONNECT
</button>
<GripHorizontal size={14} className="text-cyan-600/60" />
</div>
</div>
<div
className="flex-1 min-h-0 overflow-auto styled-scrollbar px-3 py-2 font-mono text-cyan-100/90"
style={{ fontSize: SHELL_FONT_PX, lineHeight: 1.55 }}
>
<div className="text-slate-400">ShadowBroker agent shell (PTY wiring next)</div>
<div className="text-slate-500 mt-1">Working directory: set your own path in Settings.</div>
<div className="mt-3 text-emerald-300/90">$ openclaw</div>
<div className="text-slate-500">$ codex</div>
<div className="text-slate-500">$ gemini</div>
<div className="mt-3 text-cyan-300/80 animate-pulse"></div>
</div>
{status === 'error' && statusDetail && (
<div className="px-2 py-1 text-[12px] font-mono text-amber-300/90 border-b border-amber-900/30 bg-amber-950/10 shrink-0">
{statusDetail}
</div>
)}
<div className="border-t border-cyan-900/30 px-3 py-1.5 text-[12px] font-mono text-slate-500 shrink-0">
Drag right/bottom edges to resize · {Math.round(size.w)}×{Math.round(size.h)}px · {SHELL_FONT_PX}px font
</div>
<div ref={hostRef} className="flex-1 min-h-[220px] px-1 py-1 overflow-hidden" />
<div
className="absolute top-2 bottom-2 right-0 w-1.5 cursor-e-resize"
onMouseDown={beginResize('e')}
aria-hidden
/>
<div
className="absolute left-2 right-2 bottom-0 h-1.5 cursor-s-resize"
onMouseDown={beginResize('s')}
aria-hidden
/>
<div
className="absolute right-0 bottom-0 h-3 w-3 cursor-se-resize"
onMouseDown={beginResize('se')}
aria-hidden
/>
<div className="border-t border-cyan-900/30 px-2 py-1 text-[11px] font-mono text-slate-500 shrink-0">
{status === 'connecting'
? 'Connecting…'
: status === 'open'
? `${expanded ? 'Expanded' : 'Docked'} · ${SHELL_FONT_PX}px · map stays interactive`
: 'Local operator shell'}
</div>
</div>
);
return (
<>
<div className="flex-1 min-h-0 flex flex-col items-center justify-center px-3 py-4 text-center border-l-2 border-cyan-800/20">
<div className="text-[12px] font-mono tracking-[0.16em] text-cyan-500/80">SHELL ACTIVE</div>
<div className="mt-1 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
Panel stretched from Mesh Chat. Drag edges on the shell to resize.
</div>
</div>
{createPortal(shell, document.body)}
</>
);
}
+23 -10
View File
@@ -1,6 +1,6 @@
'use client';
import React, { useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import AgentShellPanel from './AgentShellPanel';
import { motion, AnimatePresence } from 'framer-motion';
import {
@@ -13,6 +13,7 @@ import {
Radio,
Shield,
Terminal,
SquareTerminal,
UserPlus,
Lock,
Check,
@@ -91,6 +92,7 @@ function describeGateCompatReason(reason: string, gateId: string): string {
const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
const panelBoxRef = useRef<HTMLDivElement>(null);
const [shellExpanded, setShellExpanded] = useState(false);
const ctrl = useMeshChatController(props);
const {
// UI state
@@ -319,6 +321,12 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
recentPrivateFallbackReason,
onSettingsClick,
} = ctrl;
useEffect(() => {
if (activeTab !== 'dms') {
setShellExpanded(false);
}
}, [activeTab]);
const selectedContactTrustSummary = selectedContactInfo
? getContactTrustSummary(selectedContactInfo)
: null;
@@ -401,7 +409,11 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
{/* Single unified box — matches Data Layers panel skin */}
<div
ref={panelBoxRef}
className={`bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 flex flex-col relative overflow-hidden`}
className={`bg-[#0a0a0a]/90 backdrop-blur-sm border border-cyan-900/40 flex flex-col relative overflow-hidden transition-[width,min-width] duration-200 ${
activeTab === 'dms' && shellExpanded
? 'z-[210] min-w-[min(760px,calc(100vw-3rem))] w-[min(760px,calc(100vw-3rem))]'
: ''
}`}
style={{ boxShadow: '0 0 15px rgba(8,145,178,0.06), inset 0 0 20px rgba(0,0,0,0.4)', ...(expanded ? { flex: '1 1 0', minHeight: 0 } : {}) }}
>
{/* HEADER */}
@@ -438,8 +450,8 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
{ key: 'meshtastic' as Tab, label: 'MESH', icon: <Radio size={10} />, badge: 0 },
{
key: 'dms' as Tab,
label: 'AGENT SHELL',
icon: <Terminal size={10} />,
label: 'SHELL',
icon: <SquareTerminal size={10} />,
badge: 0,
},
].map((tab) => (
@@ -480,21 +492,21 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</div>
)}
{activeTab !== 'meshtastic' && wormholeEnabled && !wormholeReadyState && (
{activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && !wormholeReadyState && (
<div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0">
Wormhole secure mode is enabled but the local agent is not ready. Dead Drop is
blocked until Wormhole is running.
</div>
)}
{activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && (
{activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && (
<div className="px-3 py-2 text-sm font-mono text-yellow-400/80 border-b border-yellow-900/20 bg-yellow-950/10 leading-[1.65] shrink-0">
Wormhole secure mode is active. Experimental private-lane operations are routed
through the local agent and current secure transport paths.
</div>
)}
{activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && !wormholeRnsReady && (
{activeTab !== 'dms' && activeTab !== 'meshtastic' && wormholeEnabled && wormholeReadyState && !wormholeRnsReady && (
<div className="px-3 py-2 text-sm font-mono text-amber-300/90 border-b border-amber-900/30 bg-amber-950/20 leading-[1.65] shrink-0">
TRANSITIONAL PRIVATE LANE. Wormhole is up and gate chat is available on the
transitional lane. Reticulum is still warming Dead Drop / DM requires the
@@ -502,7 +514,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
</div>
)}
{activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && (
{activeTab !== 'dms' && activeTab !== 'meshtastic' && anonymousModeEnabled && !anonymousModeReady && (
<div className="px-3 py-2 text-sm font-mono text-red-400/90 border-b border-red-900/30 bg-red-950/20 leading-[1.65] shrink-0">
Anonymous mode is active, but hidden transport is not ready. Dead Drop is blocked
until Wormhole is running over Tor, I2P, or Mixnet.
@@ -545,8 +557,9 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
<div className="flex-1 overflow-hidden flex flex-col min-h-0">
{activeTab === 'dms' && (
<AgentShellPanel
anchorRef={panelBoxRef}
active={expanded && activeTab === 'dms'}
expanded={shellExpanded}
onExpandedChange={setShellExpanded}
/>
)}
{dashboardRestrictedTab && (
@@ -1502,7 +1515,7 @@ const MeshChat = React.memo(function MeshChat(props: MeshChatProps) {
SHELL
</span>
<div className="px-3 py-2.5 text-[13px] font-mono text-[var(--text-secondary)] leading-relaxed">
Local bash/cmd launches here (desktop). Set your own working directory in Settings.
Real local shell over PTY. EXPAND widens this panel; SNAP docks it back into Mesh Chat.
</div>
</div>
) : dashboardRestrictedTab ? (
+13
View File
@@ -0,0 +1,13 @@
export function resolveAgentShellWsUrl(cwd?: string): string {
if (typeof window === 'undefined') return '';
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const host = window.location.hostname || '127.0.0.1';
const port =
process.env.NEXT_PUBLIC_BACKEND_PORT ||
(window.location.port === '3000' ? '8000' : window.location.port || '8000');
const params = new URLSearchParams();
const trimmed = String(cwd || '').trim();
if (trimmed) params.set('cwd', trimmed);
const query = params.toString();
return `${protocol}://${host}:${port}/api/agent-shell/ws${query ? `?${query}` : ''}`;
}