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
+2
View File
@@ -370,6 +370,7 @@ osint_router = _load_optional_router("routers.osint")
scm_router = _load_optional_router("routers.scm")
entity_graph_router = _load_optional_router("routers.entity_graph")
intel_feeds_router = _load_optional_router("routers.intel_feeds")
agent_shell_router = _load_optional_router("routers.agent_shell")
# ---------------------------------------------------------------------------
@@ -3651,6 +3652,7 @@ app.include_router(osint_router)
app.include_router(scm_router)
app.include_router(entity_graph_router)
app.include_router(intel_feeds_router)
app.include_router(agent_shell_router)
from services.data_fetcher import update_all_data
+195
View File
@@ -0,0 +1,195 @@
"""Local-operator PTY WebSocket for the Mesh Chat agent shell."""
from __future__ import annotations
import asyncio
import fcntl
import hmac
import json
import logging
import os
import pty
import select
import signal
import struct
import sys
import termios
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, WebSocketDisconnect
from pydantic import BaseModel, Field
from auth import (
_current_admin_key,
_debug_mode_enabled,
_is_trusted_local_runtime_host,
require_local_operator,
)
from services.agent_shell_settings import (
get_agent_shell_settings,
set_agent_shell_working_directory,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["agent-shell"])
class AgentShellSettingsUpdate(BaseModel):
working_directory: str = Field(min_length=1)
def _set_winsize(fd: int, rows: int, cols: int) -> None:
winsize = struct.pack("HHHH", rows, cols, 0, 0)
fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize)
async def _authorize_agent_shell_ws(ws: WebSocket, admin_key_query: str = "") -> None:
host = (ws.client.host or "").lower() if ws.client else ""
if _is_trusted_local_runtime_host(host) or (_debug_mode_enabled() and host == "test"):
return
admin_key = _current_admin_key()
presented = str(admin_key_query or ws.headers.get("x-admin-key", "") or "").strip()
if admin_key and presented and hmac.compare_digest(presented.encode(), admin_key.encode()):
return
await ws.close(code=4403, reason="local operator access only")
raise WebSocketDisconnect()
def _resolve_shell_cwd(requested: str) -> str:
requested = str(requested or "").strip()
if requested:
resolved = os.path.abspath(os.path.expanduser(requested))
if os.path.isdir(resolved):
return resolved
return get_agent_shell_settings()["working_directory"]
def _default_shell() -> str:
if sys.platform == "win32":
return os.environ.get("COMSPEC", "cmd.exe")
return os.environ.get("SHELL", "/bin/bash")
async def _relay_pty(master_fd: int, proc: asyncio.subprocess.Process, ws: WebSocket) -> None:
loop = asyncio.get_running_loop()
while True:
if proc.returncode is not None:
break
try:
readable, _, _ = await loop.run_in_executor(
None, lambda: select.select([master_fd], [], [], 0.05)
)
except Exception:
break
if master_fd in readable:
try:
chunk = os.read(master_fd, 4096)
except OSError:
break
if not chunk:
break
await ws.send_bytes(chunk)
try:
message = await asyncio.wait_for(ws.receive(), timeout=0.05)
except asyncio.TimeoutError:
continue
if message.get("type") == "websocket.disconnect":
break
if message.get("type") != "websocket.receive":
continue
if message.get("bytes"):
os.write(master_fd, message["bytes"])
continue
text = message.get("text")
if not text:
continue
try:
payload = json.loads(text)
except json.JSONDecodeError:
os.write(master_fd, text.encode("utf-8", errors="replace"))
continue
if payload.get("type") == "resize":
rows = int(payload.get("rows") or 24)
cols = int(payload.get("cols") or 80)
_set_winsize(master_fd, max(rows, 2), max(cols, 2))
@router.get("/api/agent-shell/settings", dependencies=[Depends(require_local_operator)])
async def read_agent_shell_settings() -> dict[str, Any]:
return get_agent_shell_settings()
@router.put("/api/agent-shell/settings", dependencies=[Depends(require_local_operator)])
async def write_agent_shell_settings(body: AgentShellSettingsUpdate) -> dict[str, Any]:
try:
return set_agent_shell_working_directory(body.working_directory)
except ValueError as exc:
detail = str(exc)
if detail == "working_directory_not_found":
raise HTTPException(status_code=400, detail="Working directory does not exist") from exc
raise HTTPException(status_code=400, detail="Working directory is required") from exc
@router.websocket("/api/agent-shell/ws")
async def agent_shell_websocket(
ws: WebSocket,
cwd: str = Query(default=""),
cols: int = Query(default=80),
rows: int = Query(default=24),
admin_key: str = Query(default=""),
) -> None:
await ws.accept()
try:
await _authorize_agent_shell_ws(ws, admin_key)
except WebSocketDisconnect:
return
if sys.platform == "win32":
await ws.send_text(
json.dumps(
{
"type": "error",
"message": "Host PTY is not available on Windows backend builds yet. Use the ShadowBroker desktop app or run the backend in Docker/Linux for an embedded shell.",
}
)
)
await ws.close(code=1011)
return
shell_cwd = _resolve_shell_cwd(cwd)
shell = _default_shell()
master_fd, slave_fd = pty.openpty()
_set_winsize(master_fd, max(rows, 2), max(cols, 2))
env = os.environ.copy()
env.setdefault("TERM", "xterm-256color")
env.setdefault("COLORTERM", "truecolor")
proc = await asyncio.create_subprocess_exec(
shell,
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
cwd=shell_cwd,
env=env,
preexec_fn=os.setsid,
)
os.close(slave_fd)
try:
await _relay_pty(master_fd, proc, ws)
finally:
try:
os.close(master_fd)
except OSError:
pass
if proc.returncode is None:
try:
os.killpg(proc.pid, signal.SIGHUP)
except ProcessLookupError:
pass
try:
await asyncio.wait_for(proc.wait(), timeout=2.0)
except asyncio.TimeoutError:
proc.kill()
await proc.wait()
+48
View File
@@ -0,0 +1,48 @@
"""Operator settings for the embedded agent shell (working directory)."""
from __future__ import annotations
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_SETTINGS_FILE = Path(__file__).resolve().parent.parent / "data" / "agent_shell_settings.json"
_LOCK = threading.Lock()
def _default_working_directory() -> str:
return os.environ.get("AGENT_SHELL_DEFAULT_CWD") or os.environ.get("HOME") or "/app"
def get_agent_shell_settings() -> dict[str, Any]:
with _LOCK:
if not _SETTINGS_FILE.exists():
return {"working_directory": _default_working_directory()}
try:
payload = json.loads(_SETTINGS_FILE.read_text(encoding="utf-8"))
except Exception:
logger.warning("agent_shell_settings_unreadable")
return {"working_directory": _default_working_directory()}
cwd = str(payload.get("working_directory") or "").strip() or _default_working_directory()
return {"working_directory": cwd}
def set_agent_shell_working_directory(path: str) -> dict[str, Any]:
normalized = str(path or "").strip()
if not normalized:
raise ValueError("working_directory_required")
resolved = os.path.abspath(os.path.expanduser(normalized))
if not os.path.isdir(resolved):
raise ValueError("working_directory_not_found")
with _LOCK:
_SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
_SETTINGS_FILE.write_text(
json.dumps({"working_directory": resolved}, indent=2) + "\n",
encoding="utf-8",
)
return {"working_directory": resolved}
@@ -0,0 +1,14 @@
def test_agent_shell_settings_roundtrip(tmp_path, monkeypatch):
from services import agent_shell_settings
settings_path = tmp_path / "agent_shell_settings.json"
workdir = tmp_path / "workspace"
workdir.mkdir()
monkeypatch.setattr(agent_shell_settings, "_SETTINGS_FILE", settings_path)
assert agent_shell_settings.get_agent_shell_settings()["working_directory"]
saved = agent_shell_settings.set_agent_shell_working_directory(str(workdir))
assert saved["working_directory"] == str(workdir.resolve())
assert agent_shell_settings.get_agent_shell_settings()["working_directory"] == str(workdir.resolve())
+17 -1
View File
@@ -11,6 +11,8 @@
"@mapbox/point-geometry": "^1.1.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"framer-motion": "^12.38.0",
"hls.js": "^1.6.15",
"lucide-react": "^0.575.0",
@@ -3251,6 +3253,21 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -7374,7 +7391,6 @@
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"dev": true,
"funding": [
{
"type": "opencollective",
+2
View File
@@ -23,6 +23,8 @@
"@mapbox/point-geometry": "^1.1.0",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"framer-motion": "^12.38.0",
"hls.js": "^1.6.15",
"lucide-react": "^0.575.0",
@@ -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}` : ''}`;
}