e344e961d6
- Add TerminalSession backend service for docker exec subprocess management
- Add TerminalManager for WebSocket session lifecycle management
- Create WebSocket endpoint at /ws/tool-instances/{id}/terminal
- Add session cookie authentication and instance ownership verification
- Install xterm.js with fit and web-links addons
- Create TerminalComponent with xterm.js integration
- Create TerminalPage with full-screen terminal view
- Add terminal route at /instances/:id/terminal
- Add terminal button to InstanceList for running instances
- Add terminal and arrow-left icons to icon registry
- Add comprehensive terminal CSS styles (dark theme, responsive)
Quality gates: typecheck ✓, lint ✓, build ✓, Python syntax ✓
159 lines
4.2 KiB
TypeScript
159 lines
4.2 KiB
TypeScript
import React, { useEffect, useRef, useState } from "react";
|
|
import { Terminal } from "xterm";
|
|
import { FitAddon } from "xterm-addon-fit";
|
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
|
import "xterm/css/xterm.css";
|
|
|
|
interface TerminalProps {
|
|
instanceId: string;
|
|
onClose?: () => void;
|
|
}
|
|
|
|
export const TerminalComponent: React.FC<TerminalProps> = ({ instanceId, onClose }) => {
|
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const [status, setStatus] = useState<"connecting" | "connected" | "disconnected" | "error">(
|
|
"connecting",
|
|
);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!terminalRef.current) return;
|
|
|
|
// Initialize terminal
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: 14,
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
theme: {
|
|
background: "#1e1e1e",
|
|
foreground: "#d4d4d4",
|
|
cursor: "#d4d4d4",
|
|
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",
|
|
},
|
|
});
|
|
|
|
const fitAddon = new FitAddon();
|
|
term.loadAddon(fitAddon);
|
|
term.loadAddon(new WebLinksAddon());
|
|
|
|
term.open(terminalRef.current);
|
|
fitAddon.fit();
|
|
|
|
// Build WebSocket URL
|
|
const apiUrl = import.meta.env.VITE_API_BASE_URL || "";
|
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsHost = apiUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
|
|
const wsUrl = `${wsProtocol}//${wsHost}/ws/tool-instances/${instanceId}/terminal`;
|
|
|
|
// Connect WebSocket
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
setStatus("connected");
|
|
setError(null);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
if (event.data instanceof Blob) {
|
|
event.data.arrayBuffer().then((buffer) => {
|
|
const data = new Uint8Array(buffer);
|
|
term.write(data);
|
|
});
|
|
} else if (typeof event.data === "string") {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === "status" && msg.status === "connected") {
|
|
setStatus("connected");
|
|
}
|
|
} catch {
|
|
term.write(event.data);
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onclose = (event) => {
|
|
setStatus("disconnected");
|
|
if (event.code !== 1000) {
|
|
setError(`Connection closed (code: ${event.code})`);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
setStatus("error");
|
|
setError("WebSocket error");
|
|
};
|
|
|
|
// Handle terminal input
|
|
term.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(data);
|
|
}
|
|
});
|
|
|
|
// Handle resize
|
|
const handleResize = () => {
|
|
fitAddon.fit();
|
|
const { cols, rows } = term;
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "resize",
|
|
cols,
|
|
rows,
|
|
}),
|
|
);
|
|
}
|
|
};
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
// Initial resize
|
|
setTimeout(handleResize, 100);
|
|
|
|
return () => {
|
|
window.removeEventListener("resize", handleResize);
|
|
ws.close();
|
|
term.dispose();
|
|
};
|
|
}, [instanceId]);
|
|
|
|
return (
|
|
<div className="terminal-wrapper">
|
|
<div className="terminal-header">
|
|
<div className="terminal-status">
|
|
<span
|
|
className={`status-dot ${status}`}
|
|
aria-label={`Terminal status: ${status}`}
|
|
/>
|
|
<span className="status-text">{status}</span>
|
|
</div>
|
|
{onClose && (
|
|
<button className="terminal-close" onClick={onClose} type="button">
|
|
Close
|
|
</button>
|
|
)}
|
|
</div>
|
|
{error && <div className="terminal-error">{error}</div>}
|
|
<div ref={terminalRef} className="terminal-container" />
|
|
</div>
|
|
);
|
|
};
|