mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 15:02:39 +00:00
feat: add PTY/terminal session support to Process Manager API
Add Docker-style terminal support with -t (TTY) and -i (interactive) flags: Backend (Rust): - Add portable-pty dependency for PTY allocation on Unix - Extend StartProcessRequest with tty, interactive, and terminalSize options - Add PTY process spawning with TERM=xterm-256color - Add WebSocket endpoint for bidirectional terminal I/O - Add terminal resize endpoint (POST /process/:id/resize) - Add terminal input endpoint (POST /process/:id/input) - Support base64-encoded binary input - Process info now includes tty, interactive, and terminalSize fields - Terminal output is logged to combined.log for persistence Frontend (Inspector UI): - Add @xterm/xterm and addons for terminal rendering - Create Terminal component with xterm.js integration - Add tabbed view (Terminal/Logs) for PTY processes - Terminal auto-connects via WebSocket when process is expanded - Support terminal resize with ResizeObserver - Show PTY badge on processes with TTY enabled - Graceful handling of process exit and disconnection API: - GET /v1/process/:id/terminal - WebSocket for terminal I/O - POST /v1/process/:id/resize - Resize terminal (cols, rows) - POST /v1/process/:id/input - Write data to terminal WebSocket protocol: - type: 'data' - Terminal output (server -> client) - type: 'input' - Terminal input (client -> server) - type: 'resize' - Resize request (client -> server) - type: 'exit' - Process exited (server -> client) - type: 'error' - Error message (server -> client)
This commit is contained in:
parent
db0268b88f
commit
ac0a22cd07
11 changed files with 1541 additions and 109 deletions
|
|
@ -21,6 +21,9 @@
|
|||
"dependencies": {
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-web-links": "^0.11.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Play, Square, Skull, Trash2, RefreshCw, ChevronDown, ChevronRight, Terminal } from "lucide-react";
|
||||
import { Play, Square, Skull, Trash2, RefreshCw, ChevronDown, ChevronRight, Terminal as TerminalIcon, Monitor, FileText } from "lucide-react";
|
||||
import { Terminal } from "../terminal";
|
||||
|
||||
export interface TerminalSize {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface ProcessInfo {
|
||||
id: string;
|
||||
|
|
@ -15,6 +21,12 @@ export interface ProcessInfo {
|
|||
startedAt: number;
|
||||
stoppedAt?: number | null;
|
||||
cwd?: string | null;
|
||||
/** Whether this process has a PTY allocated (terminal mode) */
|
||||
tty?: boolean;
|
||||
/** Whether stdin is kept open for interactive input */
|
||||
interactive?: boolean;
|
||||
/** Current terminal size (if tty is true) */
|
||||
terminalSize?: TerminalSize | null;
|
||||
}
|
||||
|
||||
export interface ProcessListResponse {
|
||||
|
|
@ -69,6 +81,32 @@ const StatusBadge = ({ status, exitCode }: { status: string; exitCode?: number |
|
|||
);
|
||||
};
|
||||
|
||||
const TtyBadge = ({ tty, interactive }: { tty?: boolean; interactive?: boolean }) => {
|
||||
if (!tty) return null;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
background: "var(--color-info)",
|
||||
color: "white",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "10px",
|
||||
fontWeight: 500,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 4
|
||||
}}
|
||||
title={`TTY${interactive ? " + Interactive" : ""}`}
|
||||
>
|
||||
<Monitor style={{ width: 10, height: 10 }} />
|
||||
PTY
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
type ViewMode = "logs" | "terminal";
|
||||
|
||||
const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
|
||||
const [processes, setProcesses] = useState<ProcessInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -78,6 +116,7 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
|
|||
const [logsLoading, setLogsLoading] = useState<Record<string, boolean>>({});
|
||||
const [stripTimestamps, setStripTimestamps] = useState(false);
|
||||
const [logStream, setLogStream] = useState<"combined" | "stdout" | "stderr">("combined");
|
||||
const [viewMode, setViewMode] = useState<Record<string, ViewMode>>({});
|
||||
const refreshTimerRef = useRef<number | null>(null);
|
||||
|
||||
const fetchWithAuth = useCallback(async (url: string, options: RequestInit = {}) => {
|
||||
|
|
@ -170,14 +209,26 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
|
|||
}
|
||||
}, [baseUrl, fetchWithAuth, fetchProcesses, expandedId]);
|
||||
|
||||
const toggleExpand = useCallback((id: string) => {
|
||||
const toggleExpand = useCallback((id: string, process: ProcessInfo) => {
|
||||
if (expandedId === id) {
|
||||
setExpandedId(null);
|
||||
} else {
|
||||
setExpandedId(id);
|
||||
fetchLogs(id);
|
||||
// Default to terminal view for TTY processes, logs for regular processes
|
||||
const defaultMode = process.tty && process.status === "running" ? "terminal" : "logs";
|
||||
setViewMode(prev => ({ ...prev, [id]: prev[id] || defaultMode }));
|
||||
if (!process.tty || viewMode[id] === "logs") {
|
||||
fetchLogs(id);
|
||||
}
|
||||
}
|
||||
}, [expandedId, fetchLogs]);
|
||||
}, [expandedId, fetchLogs, viewMode]);
|
||||
|
||||
const getWsUrl = useCallback((id: string) => {
|
||||
// Convert HTTP URL to WebSocket URL
|
||||
const wsProtocol = baseUrl.startsWith("https") ? "wss" : "ws";
|
||||
const wsBaseUrl = baseUrl.replace(/^https?:/, wsProtocol + ":");
|
||||
return `${wsBaseUrl}/v1/process/${id}/terminal`;
|
||||
}, [baseUrl]);
|
||||
|
||||
// Initial fetch and auto-refresh
|
||||
useEffect(() => {
|
||||
|
|
@ -195,17 +246,18 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
|
|||
|
||||
// Refresh logs when options change
|
||||
useEffect(() => {
|
||||
if (expandedId) {
|
||||
if (expandedId && viewMode[expandedId] === "logs") {
|
||||
fetchLogs(expandedId);
|
||||
}
|
||||
}, [stripTimestamps, logStream]);
|
||||
}, [stripTimestamps, logStream, expandedId, viewMode, fetchLogs]);
|
||||
|
||||
const runningCount = processes.filter(p => p.status === "running").length;
|
||||
const ttyCount = processes.filter(p => p.tty).length;
|
||||
|
||||
return (
|
||||
<div className="processes-tab">
|
||||
<div className="processes-header" style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
||||
<Terminal style={{ width: 16, height: 16 }} />
|
||||
<TerminalIcon style={{ width: 16, height: 16 }} />
|
||||
<span style={{ fontWeight: 600 }}>Processes</span>
|
||||
{runningCount > 0 && (
|
||||
<span className="running-badge" style={{
|
||||
|
|
@ -218,6 +270,17 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
|
|||
{runningCount} running
|
||||
</span>
|
||||
)}
|
||||
{ttyCount > 0 && (
|
||||
<span style={{
|
||||
background: "var(--color-info)",
|
||||
color: "white",
|
||||
padding: "2px 6px",
|
||||
borderRadius: "10px",
|
||||
fontSize: "11px"
|
||||
}}>
|
||||
{ttyCount} PTY
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
className="button ghost small"
|
||||
|
|
@ -237,9 +300,12 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
|
|||
|
||||
{processes.length === 0 && !loading && (
|
||||
<div className="empty-state" style={{ textAlign: "center", padding: "24px 16px", color: "var(--color-muted)" }}>
|
||||
<Terminal style={{ width: 32, height: 32, marginBottom: 8, opacity: 0.5 }} />
|
||||
<TerminalIcon style={{ width: 32, height: 32, marginBottom: 8, opacity: 0.5 }} />
|
||||
<p>No processes found</p>
|
||||
<p style={{ fontSize: 12 }}>Start a process using the API</p>
|
||||
<p style={{ fontSize: 11, marginTop: 8 }}>
|
||||
Use <code style={{ background: "var(--bg-code)", padding: "2px 4px", borderRadius: 3 }}>tty: true</code> for interactive terminal sessions
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -264,7 +330,7 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
|
|||
background: expandedId === process.id ? "var(--bg-secondary)" : "transparent",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
onClick={() => toggleExpand(process.id)}
|
||||
onClick={() => toggleExpand(process.id, process)}
|
||||
>
|
||||
{expandedId === process.id ? (
|
||||
<ChevronDown style={{ width: 14, height: 14, flexShrink: 0 }} />
|
||||
|
|
@ -277,9 +343,11 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
|
|||
<code style={{ fontSize: 12, fontWeight: 500 }}>
|
||||
{process.command} {process.args.join(" ")}
|
||||
</code>
|
||||
<TtyBadge tty={process.tty} interactive={process.interactive} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--color-muted)", marginTop: 2 }}>
|
||||
ID: {process.id} • Started: {formatTimestamp(process.startedAt)} • Duration: {formatDuration(process.startedAt, process.stoppedAt)}
|
||||
{process.terminalSize && ` • ${process.terminalSize.cols}x${process.terminalSize.rows}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -317,55 +385,141 @@ const ProcessesTab = ({ baseUrl, token }: ProcessesTabProps) => {
|
|||
</div>
|
||||
|
||||
{expandedId === process.id && (
|
||||
<div className="process-logs" style={{ borderTop: "1px solid var(--border-color)" }}>
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-tertiary)",
|
||||
fontSize: 12
|
||||
}}>
|
||||
<select
|
||||
value={logStream}
|
||||
onChange={e => setLogStream(e.target.value as typeof logStream)}
|
||||
style={{ fontSize: 11, padding: "2px 4px" }}
|
||||
>
|
||||
<option value="combined">Combined</option>
|
||||
<option value="stdout">stdout</option>
|
||||
<option value="stderr">stderr</option>
|
||||
</select>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripTimestamps}
|
||||
onChange={e => setStripTimestamps(e.target.checked)}
|
||||
<div className="process-detail" style={{ borderTop: "1px solid var(--border-color)" }}>
|
||||
{/* View mode tabs for TTY processes */}
|
||||
{process.tty && (
|
||||
<div style={{
|
||||
display: "flex",
|
||||
borderBottom: "1px solid var(--border-color)",
|
||||
background: "var(--bg-tertiary)",
|
||||
}}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode(prev => ({ ...prev, [process.id]: "terminal" }));
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
background: viewMode[process.id] === "terminal" ? "var(--bg-secondary)" : "transparent",
|
||||
borderBottom: viewMode[process.id] === "terminal" ? "2px solid var(--color-primary)" : "2px solid transparent",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: viewMode[process.id] === "terminal" ? "var(--color-primary)" : "var(--color-muted)",
|
||||
}}
|
||||
>
|
||||
<Monitor style={{ width: 14, height: 14 }} />
|
||||
Terminal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setViewMode(prev => ({ ...prev, [process.id]: "logs" }));
|
||||
fetchLogs(process.id);
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
background: viewMode[process.id] === "logs" ? "var(--bg-secondary)" : "transparent",
|
||||
borderBottom: viewMode[process.id] === "logs" ? "2px solid var(--color-primary)" : "2px solid transparent",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: viewMode[process.id] === "logs" ? "var(--color-primary)" : "var(--color-muted)",
|
||||
}}
|
||||
>
|
||||
<FileText style={{ width: 14, height: 14 }} />
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal view */}
|
||||
{process.tty && viewMode[process.id] === "terminal" && process.status === "running" && (
|
||||
<div style={{ height: 400 }}>
|
||||
<Terminal
|
||||
wsUrl={getWsUrl(process.id)}
|
||||
active={expandedId === process.id}
|
||||
cols={process.terminalSize?.cols || 80}
|
||||
rows={process.terminalSize?.rows || 24}
|
||||
/>
|
||||
Strip timestamps
|
||||
</label>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => fetchLogs(process.id)}
|
||||
disabled={logsLoading[process.id]}
|
||||
>
|
||||
<RefreshCw style={{ width: 12, height: 12 }} className={logsLoading[process.id] ? "spinning" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
maxHeight: 300,
|
||||
overflow: "auto",
|
||||
background: "var(--bg-code)",
|
||||
color: "var(--color-code)",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all"
|
||||
}}>
|
||||
{logsLoading[process.id] ? "Loading..." : (logs[process.id] || "(no logs)")}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Terminal placeholder when process is not running */}
|
||||
{process.tty && viewMode[process.id] === "terminal" && process.status !== "running" && (
|
||||
<div style={{
|
||||
height: 200,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#1a1a1a",
|
||||
color: "var(--color-muted)",
|
||||
flexDirection: "column",
|
||||
gap: 8,
|
||||
}}>
|
||||
<Monitor style={{ width: 32, height: 32, opacity: 0.5 }} />
|
||||
<span>Process is not running</span>
|
||||
<span style={{ fontSize: 11 }}>Terminal connection unavailable</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs view */}
|
||||
{(!process.tty || viewMode[process.id] === "logs") && (
|
||||
<div className="process-logs">
|
||||
<div style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-tertiary)",
|
||||
fontSize: 12
|
||||
}}>
|
||||
<select
|
||||
value={logStream}
|
||||
onChange={e => setLogStream(e.target.value as typeof logStream)}
|
||||
style={{ fontSize: 11, padding: "2px 4px" }}
|
||||
>
|
||||
<option value="combined">Combined</option>
|
||||
<option value="stdout">stdout</option>
|
||||
<option value="stderr">stderr</option>
|
||||
</select>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stripTimestamps}
|
||||
onChange={e => setStripTimestamps(e.target.checked)}
|
||||
/>
|
||||
Strip timestamps
|
||||
</label>
|
||||
<button
|
||||
className="button ghost small"
|
||||
onClick={() => fetchLogs(process.id)}
|
||||
disabled={logsLoading[process.id]}
|
||||
>
|
||||
<RefreshCw style={{ width: 12, height: 12 }} className={logsLoading[process.id] ? "spinning" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<pre style={{
|
||||
margin: 0,
|
||||
padding: 12,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
maxHeight: 300,
|
||||
overflow: "auto",
|
||||
background: "var(--bg-code)",
|
||||
color: "var(--color-code)",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-all"
|
||||
}}>
|
||||
{logsLoading[process.id] ? "Loading..." : (logs[process.id] || "(no logs)")}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
269
frontend/packages/inspector/src/components/terminal/Terminal.tsx
Normal file
269
frontend/packages/inspector/src/components/terminal/Terminal.tsx
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
export interface TerminalProps {
|
||||
/** WebSocket URL for terminal connection */
|
||||
wsUrl: string;
|
||||
/** Whether the terminal is currently active/focused */
|
||||
active?: boolean;
|
||||
/** Callback when the terminal is closed */
|
||||
onClose?: () => void;
|
||||
/** Callback when the terminal connection status changes */
|
||||
onConnectionChange?: (connected: boolean) => void;
|
||||
/** Initial number of columns */
|
||||
cols?: number;
|
||||
/** Initial number of rows */
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
interface TerminalMessage {
|
||||
type: "data" | "input" | "resize" | "exit" | "error";
|
||||
data?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
code?: number | null;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
const Terminal = ({
|
||||
wsUrl,
|
||||
active = true,
|
||||
onClose,
|
||||
onConnectionChange,
|
||||
cols = 80,
|
||||
rows = 24,
|
||||
}: TerminalProps) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Initialize terminal
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: "#1a1a1a",
|
||||
foreground: "#d4d4d4",
|
||||
cursor: "#d4d4d4",
|
||||
cursorAccent: "#1a1a1a",
|
||||
selectionBackground: "#264f78",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
brightBlack: "#666666",
|
||||
brightRed: "#f14c4c",
|
||||
brightGreen: "#23d18b",
|
||||
brightYellow: "#f5f543",
|
||||
brightBlue: "#3b8eea",
|
||||
brightMagenta: "#d670d6",
|
||||
brightCyan: "#29b8db",
|
||||
brightWhite: "#e5e5e5",
|
||||
},
|
||||
cols,
|
||||
rows,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
term.loadAddon(fitAddon);
|
||||
term.loadAddon(webLinksAddon);
|
||||
term.open(terminalRef.current);
|
||||
|
||||
// Fit terminal to container
|
||||
setTimeout(() => fitAddon.fit(), 0);
|
||||
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// Handle window resize
|
||||
const handleResize = () => {
|
||||
if (fitAddonRef.current && xtermRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
// Send resize to server
|
||||
const { cols, rows } = xtermRef.current;
|
||||
sendResize(cols, rows);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
term.dispose();
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [cols, rows]);
|
||||
|
||||
// Send resize message
|
||||
const sendResize = useCallback((cols: number, rows: number) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const msg: TerminalMessage = { type: "resize", cols, rows };
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Connect WebSocket
|
||||
useEffect(() => {
|
||||
if (!wsUrl || !xtermRef.current) return;
|
||||
|
||||
setError(null);
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
onConnectionChange?.(true);
|
||||
xtermRef.current?.writeln("\x1b[32m● Connected to terminal\x1b[0m\r\n");
|
||||
|
||||
// Send initial resize
|
||||
if (fitAddonRef.current && xtermRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
const { cols, rows } = xtermRef.current;
|
||||
sendResize(cols, rows);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg: TerminalMessage = JSON.parse(event.data);
|
||||
|
||||
switch (msg.type) {
|
||||
case "data":
|
||||
if (msg.data) {
|
||||
xtermRef.current?.write(msg.data);
|
||||
}
|
||||
break;
|
||||
case "exit":
|
||||
xtermRef.current?.writeln(`\r\n\x1b[33m● Process exited with code ${msg.code ?? "unknown"}\x1b[0m`);
|
||||
onClose?.();
|
||||
break;
|
||||
case "error":
|
||||
setError(msg.message || "Unknown error");
|
||||
xtermRef.current?.writeln(`\r\n\x1b[31m● Error: ${msg.message}\x1b[0m`);
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
// Handle binary data
|
||||
if (event.data instanceof Blob) {
|
||||
event.data.text().then((text: string) => {
|
||||
xtermRef.current?.write(text);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
setError("WebSocket connection error");
|
||||
setConnected(false);
|
||||
onConnectionChange?.(false);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
onConnectionChange?.(false);
|
||||
xtermRef.current?.writeln("\r\n\x1b[31m● Disconnected from terminal\x1b[0m");
|
||||
};
|
||||
|
||||
// Handle terminal input
|
||||
const onData = xtermRef.current.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const msg: TerminalMessage = { type: "input", data };
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
onData.dispose();
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
};
|
||||
}, [wsUrl, onClose, onConnectionChange, sendResize]);
|
||||
|
||||
// Handle container resize with ResizeObserver
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (fitAddonRef.current && xtermRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
const { cols, rows } = xtermRef.current;
|
||||
sendResize(cols, rows);
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [sendResize]);
|
||||
|
||||
// Focus terminal when active
|
||||
useEffect(() => {
|
||||
if (active && xtermRef.current) {
|
||||
xtermRef.current.focus();
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div className="terminal-container" style={{ height: "100%", position: "relative" }}>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: "var(--color-error)",
|
||||
color: "white",
|
||||
padding: "4px 8px",
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
style={{
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
background: "#1a1a1a",
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 4,
|
||||
right: 8,
|
||||
fontSize: 10,
|
||||
color: connected ? "var(--color-success)" : "var(--color-muted)",
|
||||
}}
|
||||
>
|
||||
{connected ? "● Connected" : "○ Disconnected"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Terminal;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Terminal } from "./Terminal";
|
||||
export type { TerminalProps } from "./Terminal";
|
||||
Loading…
Add table
Add a link
Reference in a new issue