From 3f0352702e9d5f27900f2d9c3defeb67de0049cc Mon Sep 17 00:00:00 2001 From: ZN-ice Date: Wed, 20 May 2026 07:11:04 +0800 Subject: [PATCH 1/3] feat(ui): redesign sandbox frontend to minimalist macOS style Remove ControlPanel sidebar, StatusBar, and header. Replace with full-screen terminal and floating toolbar (screenshot button + connection status). Use Tokyo Night color scheme with SF Mono font, 100ms PTY polling for smooth streaming output, and glassmorphism toolbar with backdrop blur. Co-Authored-By: Claude Opus 4.7 --- sandbox-web/index.html | 11 + sandbox-web/src/components/ControlPanel.tsx | 228 ---------------- sandbox-web/src/components/StatusBar.tsx | 51 ---- sandbox-web/src/components/Terminal.tsx | 83 +++--- sandbox-web/src/index.css | 42 ++- sandbox-web/src/main.tsx | 288 ++++++++------------ sandbox-web/tailwind.config.js | 23 +- 7 files changed, 219 insertions(+), 507 deletions(-) delete mode 100644 sandbox-web/src/components/ControlPanel.tsx delete mode 100644 sandbox-web/src/components/StatusBar.tsx diff --git a/sandbox-web/index.html b/sandbox-web/index.html index c848742..7fcf91e 100644 --- a/sandbox-web/index.html +++ b/sandbox-web/index.html @@ -4,6 +4,17 @@ System Test Sandbox +
diff --git a/sandbox-web/src/components/ControlPanel.tsx b/sandbox-web/src/components/ControlPanel.tsx deleted file mode 100644 index e249581..0000000 --- a/sandbox-web/src/components/ControlPanel.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { useState } from "react"; - -interface ControlPanelProps { - onScreenshot: () => void; - onSpawnApp: (path: string) => void; - onSpawnCli: (command: string, args: string[]) => void; - onClick: (x: number, y: number, button: string) => void; - onTypeText: (text: string) => void; - onPressKey: (key: string, modifiers: string[]) => void; - screenshotLoading?: boolean; -} - -export default function ControlPanel({ - onScreenshot, - onSpawnApp, - onSpawnCli, - onClick, - onTypeText, - onPressKey, - screenshotLoading = false, -}: ControlPanelProps) { - const [appPath, setAppPath] = useState(""); - const [cliCommand, setCliCommand] = useState(""); - const [cliArgs, setCliArgs] = useState(""); - const [clickX, setClickX] = useState("100"); - const [clickY, setClickY] = useState("100"); - const [typeText, setTypeText] = useState(""); - const [keyName, setKeyName] = useState("Return"); - const [modifiers, setModifiers] = useState(""); - const [expanded, setExpanded] = useState(null); - - const toggle = (section: string) => { - setExpanded(expanded === section ? null : section); - }; - - return ( -
-
- Control Panel -
- -
- {/* Screenshot */} -
toggle("screenshot")} - > - -
- - {/* App Spawn */} -
toggle("spawnApp")} - > - setAppPath(e.target.value)} - placeholder="/Applications/Example.app" - className="w-full px-2 py-1 bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500 mb-2" - /> - -
- - {/* CLI Spawn */} -
toggle("spawnCli")} - > - setCliCommand(e.target.value)} - placeholder="Command (e.g., echo)" - className="w-full px-2 py-1 bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500 mb-2" - /> - setCliArgs(e.target.value)} - placeholder="Args (space separated)" - className="w-full px-2 py-1 bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500 mb-2" - /> - -
- - {/* Click */} -
toggle("click")} - > -
- setClickX(e.target.value)} - placeholder="X" - className="w-1/2 px-2 py-1 bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500" - /> - setClickY(e.target.value)} - placeholder="Y" - className="w-1/2 px-2 py-1 bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500" - /> -
- -
- - {/* Type Text */} -
toggle("typeText")} - > - setTypeText(e.target.value)} - placeholder="Text to type..." - className="w-full px-2 py-1 bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500 mb-2" - /> - -
- - {/* Key Press */} -
toggle("keyPress")} - > - setKeyName(e.target.value)} - placeholder="Key name (Return, Tab, Space)" - className="w-full px-2 py-1 bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500 mb-2" - /> - setModifiers(e.target.value)} - placeholder="Modifiers (cmd, shift, alt)" - className="w-full px-2 py-1 bg-gray-800 border border-gray-600 rounded text-gray-200 placeholder-gray-500 focus:outline-none focus:border-blue-500 mb-2" - /> - -
-
-
- ); -} - -function Section({ - title, - expanded, - onToggle, - children, -}: { - title: string; - expanded: boolean; - onToggle: () => void; - children: React.ReactNode; -}) { - return ( -
- - {expanded &&
{children}
} -
- ); -} diff --git a/sandbox-web/src/components/StatusBar.tsx b/sandbox-web/src/components/StatusBar.tsx deleted file mode 100644 index f42d936..0000000 --- a/sandbox-web/src/components/StatusBar.tsx +++ /dev/null @@ -1,51 +0,0 @@ -interface ProcessInfo { - pid: number; - name: string; - is_running: boolean; -} - -interface StatusBarProps { - processes: ProcessInfo[]; - screenshotCount: number; - serverStatus: "running" | "stopped" | "error"; - httpPort?: number; -} - -export default function StatusBar({ - processes, - screenshotCount, - serverStatus, - httpPort = 5801, -}: StatusBarProps) { - const runningCount = processes.filter((p) => p.is_running).length; - - const statusColor = { - running: "bg-green-500", - stopped: "bg-gray-500", - error: "bg-red-500", - }[serverStatus]; - - return ( -
-
-
- - - Server: {serverStatus} - {serverStatus === "running" && ` (:${httpPort})`} - -
- | - - Processes: {runningCount} running / {processes.length} tracked - -
- -
- Screenshots: {screenshotCount} - | - macOS Sandbox v0.1.0 -
-
- ); -} diff --git a/sandbox-web/src/components/Terminal.tsx b/sandbox-web/src/components/Terminal.tsx index 3e4505a..e8c6053 100644 --- a/sandbox-web/src/components/Terminal.tsx +++ b/sandbox-web/src/components/Terminal.tsx @@ -5,17 +5,38 @@ import * as api from "../api"; import "@xterm/xterm/css/xterm.css"; interface TerminalProps { - /** Callback when terminal receives input */ onInput?: (data: string) => void; - /** Whether the terminal is connected to a PTY */ - connected?: boolean; - /** The tracked PID of the active PTY process (null = none) */ activePid?: number | null; } +// Tokyo Night inspired color scheme — clean, readable, macOS-like +const TERM_THEME = { + background: "#1a1b26", + foreground: "#a9b1d6", + cursor: "#c0caf5", + cursorAccent: "#1a1b26", + selectionBackground: "rgba(122, 162, 247, 0.3)", + selectionForeground: "#c0caf5", + black: "#15161e", + red: "#f7768e", + green: "#9ece6a", + yellow: "#e0af68", + blue: "#7aa2f7", + magenta: "#bb9af7", + cyan: "#7dcfff", + white: "#a9b1d6", + brightBlack: "#414868", + brightRed: "#f7768e", + brightGreen: "#9ece6a", + brightYellow: "#e0af68", + brightBlue: "#7aa2f7", + brightMagenta: "#bb9af7", + brightCyan: "#7dcfff", + brightWhite: "#c0caf5", +}; + export default function SandboxTerminal({ onInput, - connected = false, activePid = null, }: TerminalProps) { const terminalRef = useRef(null); @@ -29,31 +50,18 @@ export default function SandboxTerminal({ const term = new Terminal({ cursorBlink: true, + cursorStyle: "bar", fontSize: 14, - fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', - theme: { - background: "#0d1117", - foreground: "#c9d1d9", - cursor: "#58a6ff", - selectionBackground: "#264f78", - black: "#484f58", - red: "#ff7b72", - green: "#3fb950", - yellow: "#d29922", - blue: "#58a6ff", - magenta: "#bc8cff", - cyan: "#39c5d6", - white: "#b1bac4", - brightBlack: "#6e7681", - brightRed: "#ffa198", - brightGreen: "#56d364", - brightYellow: "#e3b341", - brightBlue: "#79c0ff", - brightMagenta: "#d2a8ff", - brightCyan: "#56d4dd", - brightWhite: "#f0f6fc", - }, + lineHeight: 1.35, + fontFamily: + '"SF Mono", "Menlo", "Monaco", "Cascadia Code", "JetBrains Mono", monospace', + fontWeight: "400", + fontWeightBold: "600", + letterSpacing: 0, + scrollback: 10000, + theme: TERM_THEME, allowProposedApi: true, + drawBoldTextInBrightColors: true, }); const fitAddon = new FitAddon(); @@ -77,9 +85,8 @@ export default function SandboxTerminal({ }; }, []); // eslint-disable-line react-hooks/exhaustive-deps - // PTY output polling — runs while activePid is set + // PTY output polling useEffect(() => { - // Clear any existing poll if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; @@ -94,13 +101,12 @@ export default function SandboxTerminal({ xtermRef.current?.write(result.output); } } catch { - // Process may have exited; stop polling if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } } - }, 200); + }, 100); // 100ms polling for smooth streaming return () => { if (pollRef.current) { @@ -110,24 +116,15 @@ export default function SandboxTerminal({ }; }, [activePid]); - // Refit on window resize const containerRef = useCallback((node: HTMLDivElement | null) => { if (node) { - // Trigger fit after layout requestAnimationFrame(() => fitAddonRef.current?.fit()); } }, []); return ( -
-
- Terminal - -
-
+
+
); } diff --git a/sandbox-web/src/index.css b/sandbox-web/src/index.css index 72844b3..968503d 100644 --- a/sandbox-web/src/index.css +++ b/sandbox-web/src/index.css @@ -2,21 +2,51 @@ @tailwind components; @tailwind utilities; -/* xterm.js overrides for sandbox theme */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #root { + width: 100%; + height: 100%; + overflow: hidden; + background: #1a1b26; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* xterm.js — clean macOS terminal feel */ .xterm { - padding: 8px; + padding: 48px 16px 12px 16px; height: 100%; } .xterm-viewport::-webkit-scrollbar { - width: 8px; + width: 6px; } .xterm-viewport::-webkit-scrollbar-track { - background: #1a1a2e; + background: transparent; } .xterm-viewport::-webkit-scrollbar-thumb { - background: #4a4a6a; - border-radius: 4px; + background: rgba(255, 255, 255, 0.12); + border-radius: 3px; +} + +.xterm-viewport::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* Smooth cursor */ +.xterm-cursor-layer { + transition: opacity 0.1s; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } } diff --git a/sandbox-web/src/main.tsx b/sandbox-web/src/main.tsx index d664c2a..545a68a 100644 --- a/sandbox-web/src/main.tsx +++ b/sandbox-web/src/main.tsx @@ -1,41 +1,24 @@ import { useState, useCallback, useEffect, useRef } from "react"; import ReactDOM from "react-dom/client"; import SandboxTerminal from "./components/Terminal"; -import StatusBar from "./components/StatusBar"; -import ControlPanel from "./components/ControlPanel"; import * as api from "./api"; import "./index.css"; -interface ProcessInfo { - pid: number; - name: string; - is_running: boolean; -} - function App() { - const [processes, setProcesses] = useState([]); - const [screenshotCount, setScreenshotCount] = useState(0); - const [serverStatus] = useState<"running" | "stopped" | "error">("running"); - const [screenshotLoading, setScreenshotLoading] = useState(false); - const [screenshotUrl, setScreenshotUrl] = useState(null); const [activePid, setActivePid] = useState(null); - const [errorMsg, setErrorMsg] = useState(null); + const [connected, setConnected] = useState(false); + const [screenshotUrl, setScreenshotUrl] = useState(null); + const [screenshotLoading, setScreenshotLoading] = useState(false); + const [showPreview, setShowPreview] = useState(false); const hasConnectedRef = useRef(false); - const showError = useCallback((msg: string) => { - setErrorMsg(msg); - setTimeout(() => setErrorMsg(null), 4000); - }, []); - - // ── Auto-connect to spawned processes ────────────────── - // Polls for running processes and auto-connects to the first PTY + // Auto-connect to spawned processes useEffect(() => { const pollProcesses = async () => { try { const list = await api.listProcesses(); if (list.length > 0) { - setProcesses(list.map((p) => ({ pid: p.pid, name: p.name, is_running: p.is_running }))); - // Auto-connect to the first running process + setConnected(true); if (activePid === null && !hasConnectedRef.current) { const running = list.find((p) => p.is_running); if (running) { @@ -43,196 +26,145 @@ function App() { hasConnectedRef.current = true; } } + } else { + setConnected(false); } } catch { - // Server may not be ready yet + setConnected(false); } }; pollProcesses(); - const interval = setInterval(pollProcesses, 1000); + const interval = setInterval(pollProcesses, 2000); return () => clearInterval(interval); }, [activePid]); - // ── Terminal input → PTY ───────────────────────────── - + // Terminal input → PTY const handleTerminalInput = useCallback( (data: string) => { if (activePid !== null) { - api.ptyWrite(activePid, data).catch(() => { - // PTY write failures are expected when the process exits - }); + api.ptyWrite(activePid, data).catch(() => {}); } }, [activePid], ); - // ── Screenshot ─────────────────────────────────────── - + // Screenshot const handleScreenshot = useCallback(async () => { setScreenshotLoading(true); try { const url = await api.takeScreenshot(); setScreenshotUrl(url); - setScreenshotCount((c) => c + 1); - } catch (e) { - showError(`Screenshot failed: ${e}`); + setShowPreview(true); + } catch { + // silent } finally { setScreenshotLoading(false); } - }, [showError]); - - // ── Spawn App ──────────────────────────────────────── - - const handleSpawnApp = useCallback( - (path: string) => { - api - .spawnApp(path) - .then((info) => { - setProcesses((prev) => [ - ...prev, - { pid: info.pid, name: info.name, is_running: info.is_running }, - ]); - }) - .catch((e) => showError(`spawnApp failed: ${e}`)); - }, - [showError], - ); - - // ── Spawn CLI ──────────────────────────────────────── - - const handleSpawnCli = useCallback( - (command: string, args: string[]) => { - api - .spawnCli(command, args) - .then((info) => { - setProcesses((prev) => [ - ...prev, - { pid: info.pid, name: info.name, is_running: info.is_running }, - ]); - // Auto-connect terminal to this PTY - setActivePid(info.pid); - }) - .catch((e) => showError(`spawnCli failed: ${e}`)); - }, - [showError], - ); - - // ── Click ──────────────────────────────────────────── - - const handleClick = useCallback( - (x: number, y: number, button: string) => { - api - .click(x, y, button as "left" | "right" | "middle") - .catch((e) => showError(`Click failed: ${e}`)); - }, - [showError], - ); - - // ── Type Text ──────────────────────────────────────── - - const handleTypeText = useCallback( - (text: string) => { - api.typeText(text).catch((e) => showError(`Type failed: ${e}`)); - }, - [showError], - ); - - // ── Press Key ──────────────────────────────────────── + }, []); - const handlePressKey = useCallback( - (key: string, modifiers: string[]) => { - api.pressKey(key, modifiers).catch((e) => showError(`Key failed: ${e}`)); - }, - [showError], - ); + // Close preview + const closePreview = useCallback(() => { + setShowPreview(false); + if (screenshotUrl) { + URL.revokeObjectURL(screenshotUrl); + setScreenshotUrl(null); + } + }, [screenshotUrl]); return ( -
- {/* Main content area */} -
- {/* Header */} -
- - System Test Sandbox - - - macOS Desktop Automation - -
+
+ {/* Full-screen terminal */} + + + {/* Floating toolbar — top right, macOS style */} +
+
+ {/* Screenshot button */} + - {/* Error toast */} - {errorMsg && ( -
- {errorMsg} -
- )} + {/* Divider */} +
- {/* Content: Terminal + Screenshot / App view */} -
- {/* Terminal — left half */} -
- + -
- - {/* Screenshot preview / App view — right half */} -
- {screenshotUrl ? ( -
-
- - Latest Screenshot - - -
- Sandbox screenshot -
- ) : ( -
-
🖥
-

Screenshot Preview

-

- Click "Screenshot" to capture -

-
- )} + {connected ? "Connected" : "Waiting..."}
- - {/* Status bar */} -
- {/* Right sidebar — control panel */} -
- -
+ {/* Screenshot preview — floating panel */} + {showPreview && screenshotUrl && ( +
+
+ {/* Preview header */} +
+ Screenshot + +
+ {/* Preview image */} + Screenshot +
+
+ )}
); } diff --git a/sandbox-web/tailwind.config.js b/sandbox-web/tailwind.config.js index 93aa364..45b0102 100644 --- a/sandbox-web/tailwind.config.js +++ b/sandbox-web/tailwind.config.js @@ -2,7 +2,28 @@ export default { content: ["./index.html", "./src/**/*.{ts,tsx}"], theme: { - extend: {}, + extend: { + colors: { + term: { + bg: "#1a1b26", + fg: "#a9b1d6", + surface: "#24283b", + border: "#3b4261", + accent: "#7aa2f7", + muted: "#565f89", + }, + }, + fontFamily: { + mono: [ + '"SF Mono"', + '"Menlo"', + '"Monaco"', + '"Cascadia Code"', + '"JetBrains Mono"', + "monospace", + ], + }, + }, }, plugins: [], }; From deb925372fa694324965716a15f3c07a6ed932c7 Mon Sep 17 00:00:00 2001 From: ZN-ice Date: Wed, 20 May 2026 07:22:55 +0800 Subject: [PATCH 2/3] feat(process): execute CLI commands inside zsh shell Wrap spawn_cli to run commands via `zsh -c " "` instead of executing them directly on the PTY. This loads the user's shell environment (PATH, .zshrc, etc.) before running the command, matching the behavior of opening Terminal.app and typing the command manually. Co-Authored-By: Claude Opus 4.7 --- crates/sandbox-core/src/process/mod.rs | 35 ++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/sandbox-core/src/process/mod.rs b/crates/sandbox-core/src/process/mod.rs index 1a83232..27bc8ca 100644 --- a/crates/sandbox-core/src/process/mod.rs +++ b/crates/sandbox-core/src/process/mod.rs @@ -114,7 +114,11 @@ impl ProcessManager { )) } - /// Launch a CLI process with PTY support + /// Launch a CLI process with PTY support. + /// + /// The command is executed inside a zsh shell (`zsh -c " "`), + /// so the user's shell environment (PATH, .zshrc, etc.) is loaded first. + /// This matches the behavior of opening Terminal.app and typing the command. #[cfg(target_os = "macos")] pub fn spawn_cli(command: &str, args: &[String]) -> Result { let pty_system = native_pty_system(); @@ -127,8 +131,29 @@ impl ProcessManager { }) .map_err(|e| AppError::Process(format!("Failed to open PTY: {e}")))?; - let mut cmd = CommandBuilder::new(command); - cmd.args(args); + // Build the full shell command string, e.g. `claude -p '你是谁'` + let full_command = if args.is_empty() { + command.to_string() + } else { + let escaped: Vec = args + .iter() + .map(|a| { + if a.chars() + .any(|c| c.is_whitespace() || c == '\'' || c == '"') + { + format!("'{}'", a.replace('\'', "'\\''")) + } else { + a.clone() + } + }) + .collect(); + format!("{command} {}", escaped.join(" ")) + }; + + let mut cmd = CommandBuilder::new("zsh"); + cmd.arg("-c"); + cmd.arg(&full_command); + let child = pty_pair .slave .spawn_command(cmd) @@ -163,8 +188,8 @@ impl ProcessManager { ); info!( - "Spawned CLI: {} (tracked_id={}, os_pid={:?})", - command, tracked_id, child_pid + "Spawned CLI via shell: {} (tracked_id={}, os_pid={:?})", + full_command, tracked_id, child_pid ); Ok(ProcessInfo { From 37576ba19debb3d5349e30df1bbfbb03cd5674d3 Mon Sep 17 00:00:00 2001 From: ZN-Ice Date: Wed, 20 May 2026 12:59:13 +0800 Subject: [PATCH 3/3] Revert "feat(process): execute CLI commands inside zsh shell" This reverts commit deb925372fa694324965716a15f3c07a6ed932c7. --- crates/sandbox-core/src/process/mod.rs | 35 ++++---------------------- 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/crates/sandbox-core/src/process/mod.rs b/crates/sandbox-core/src/process/mod.rs index 27bc8ca..1a83232 100644 --- a/crates/sandbox-core/src/process/mod.rs +++ b/crates/sandbox-core/src/process/mod.rs @@ -114,11 +114,7 @@ impl ProcessManager { )) } - /// Launch a CLI process with PTY support. - /// - /// The command is executed inside a zsh shell (`zsh -c " "`), - /// so the user's shell environment (PATH, .zshrc, etc.) is loaded first. - /// This matches the behavior of opening Terminal.app and typing the command. + /// Launch a CLI process with PTY support #[cfg(target_os = "macos")] pub fn spawn_cli(command: &str, args: &[String]) -> Result { let pty_system = native_pty_system(); @@ -131,29 +127,8 @@ impl ProcessManager { }) .map_err(|e| AppError::Process(format!("Failed to open PTY: {e}")))?; - // Build the full shell command string, e.g. `claude -p '你是谁'` - let full_command = if args.is_empty() { - command.to_string() - } else { - let escaped: Vec = args - .iter() - .map(|a| { - if a.chars() - .any(|c| c.is_whitespace() || c == '\'' || c == '"') - { - format!("'{}'", a.replace('\'', "'\\''")) - } else { - a.clone() - } - }) - .collect(); - format!("{command} {}", escaped.join(" ")) - }; - - let mut cmd = CommandBuilder::new("zsh"); - cmd.arg("-c"); - cmd.arg(&full_command); - + let mut cmd = CommandBuilder::new(command); + cmd.args(args); let child = pty_pair .slave .spawn_command(cmd) @@ -188,8 +163,8 @@ impl ProcessManager { ); info!( - "Spawned CLI via shell: {} (tracked_id={}, os_pid={:?})", - full_command, tracked_id, child_pid + "Spawned CLI: {} (tracked_id={}, os_pid={:?})", + command, tracked_id, child_pid ); Ok(ProcessInfo {