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:
Nathan Flurry 2026-01-30 13:12:49 -08:00
parent db0268b88f
commit ac0a22cd07
11 changed files with 1541 additions and 109 deletions

View file

@ -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"
}
}

View file

@ -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>

View 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;

View file

@ -0,0 +1,2 @@
export { default as Terminal } from "./Terminal";
export type { TerminalProps } from "./Terminal";