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: [], };