f065e2b8c0
- Change .terminal-wrapper.mobile from height:100% to flex:1 for proper flex behavior - Add explicit width/height to xterm-viewport and xterm-screen to prevent overflow - Add delayed fit() at 4s to resize after mobile header auto-hides - Remove min-height:100% which caused overflow issues
455 lines
13 KiB
TypeScript
455 lines
13 KiB
TypeScript
import React, { useEffect, useRef, useState, useCallback } from "react";
|
|
import { Terminal } from "xterm";
|
|
import { FitAddon } from "xterm-addon-fit";
|
|
import { WebLinksAddon } from "xterm-addon-web-links";
|
|
import "xterm/css/xterm.css";
|
|
|
|
import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys";
|
|
|
|
interface TerminalProps {
|
|
instanceId: string;
|
|
onClose?: () => void;
|
|
isMobile?: boolean;
|
|
activeModifier?: ModifierKey | null;
|
|
onModifierChange?: (modifier: ModifierKey | null) => void;
|
|
onTerminalReady?: (
|
|
sendData: (data: string) => void,
|
|
connectionStatus: "connecting" | "connected" | "disconnected" | "error",
|
|
focusInput: () => void,
|
|
changeFontSize: (delta: number) => void
|
|
) => void;
|
|
}
|
|
|
|
const FONT_SIZE_KEY = "terminal-font-size";
|
|
const MIN_FONT_SIZE = 10;
|
|
const MAX_FONT_SIZE = 24;
|
|
const RECONNECT_ATTEMPTS = 3;
|
|
const RECONNECT_DELAY_BASE = 1000;
|
|
|
|
export const TerminalComponent: React.FC<TerminalProps> = ({
|
|
instanceId,
|
|
onClose,
|
|
isMobile = false,
|
|
activeModifier,
|
|
onModifierChange,
|
|
onTerminalReady,
|
|
}) => {
|
|
const terminalRef = useRef<HTMLDivElement>(null);
|
|
const hiddenInputRef = useRef<HTMLInputElement>(null);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const termRef = useRef<Terminal | null>(null);
|
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
const reconnectAttemptsRef = useRef(0);
|
|
const onTerminalReadyRef = useRef(onTerminalReady);
|
|
onTerminalReadyRef.current = onTerminalReady;
|
|
const [status, setStatus] = useState<
|
|
"connecting" | "connected" | "disconnected" | "error"
|
|
>("connecting");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const activeModifierRef = useRef(activeModifier);
|
|
activeModifierRef.current = activeModifier;
|
|
const [fontSize, setFontSize] = useState(() => {
|
|
if (typeof window === "undefined") return isMobile ? 16 : 14;
|
|
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
|
return stored ? parseInt(stored, 10) : isMobile ? 16 : 14;
|
|
});
|
|
|
|
const calculateFontSize = useCallback(() => {
|
|
if (!isMobile) return fontSize;
|
|
const vw = window.innerWidth;
|
|
const calculated = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, vw / 25));
|
|
return Math.round(calculated);
|
|
}, [isMobile, fontSize]);
|
|
|
|
const connectWebSocket = useCallback(() => {
|
|
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`;
|
|
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
setStatus("connected");
|
|
setError(null);
|
|
reconnectAttemptsRef.current = 0;
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
if (!termRef.current) return;
|
|
|
|
if (event.data instanceof Blob) {
|
|
event.data.arrayBuffer().then((buffer) => {
|
|
const data = new Uint8Array(buffer);
|
|
termRef.current?.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 {
|
|
termRef.current?.write(event.data);
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onclose = (event) => {
|
|
setStatus("disconnected");
|
|
if (event.code !== 1000) {
|
|
setError(`Connection closed (code: ${event.code})`);
|
|
|
|
// Attempt reconnection
|
|
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
|
reconnectAttemptsRef.current++;
|
|
const delay = RECONNECT_DELAY_BASE * Math.pow(2, reconnectAttemptsRef.current - 1);
|
|
setTimeout(() => {
|
|
if (document.visibilityState !== "hidden") {
|
|
connectWebSocket();
|
|
}
|
|
}, delay);
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
setStatus("error");
|
|
setError("WebSocket error");
|
|
};
|
|
|
|
return ws;
|
|
}, [instanceId]);
|
|
|
|
useEffect(() => {
|
|
if (!terminalRef.current) return;
|
|
|
|
// Initialize terminal
|
|
const currentFontSize = calculateFontSize();
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: currentFontSize,
|
|
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",
|
|
},
|
|
});
|
|
|
|
termRef.current = term;
|
|
|
|
const fitAddon = new FitAddon();
|
|
fitAddonRef.current = fitAddon;
|
|
term.loadAddon(fitAddon);
|
|
term.loadAddon(new WebLinksAddon());
|
|
|
|
term.open(terminalRef.current);
|
|
|
|
// Connect WebSocket
|
|
const ws = connectWebSocket();
|
|
|
|
// Fit after layout settles - use rAF + timeout to ensure DOM is ready
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
fitAddon.fit();
|
|
// Force a second fit after layout fully settles
|
|
setTimeout(() => {
|
|
fitAddon.fit();
|
|
const { cols, rows } = term;
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "resize",
|
|
cols,
|
|
rows,
|
|
})
|
|
);
|
|
}
|
|
}, 500);
|
|
|
|
// Fit after mobile header auto-hides (3s delay + 0.3s transition)
|
|
setTimeout(() => {
|
|
fitAddon.fit();
|
|
const { cols, rows } = term;
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "resize",
|
|
cols,
|
|
rows,
|
|
})
|
|
);
|
|
}
|
|
}, 4000);
|
|
});
|
|
});
|
|
|
|
// Handle terminal input
|
|
term.onData((data) => {
|
|
if (ws.readyState !== WebSocket.OPEN) return;
|
|
|
|
// Apply active modifier to single-character input
|
|
const modifier = activeModifierRef.current;
|
|
if (modifier && data.length === 1) {
|
|
const modified = applyModifierToChar(data, modifier);
|
|
if (modified) {
|
|
ws.send(modified);
|
|
onModifierChange?.(null);
|
|
return;
|
|
}
|
|
}
|
|
|
|
ws.send(data);
|
|
});
|
|
|
|
// Handle resize with debounce
|
|
let resizeTimeout: ReturnType<typeof setTimeout>;
|
|
const handleResize = () => {
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(() => {
|
|
fitAddon.fit();
|
|
const { cols, rows } = term;
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "resize",
|
|
cols,
|
|
rows,
|
|
})
|
|
);
|
|
}
|
|
}, 250);
|
|
};
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
// Notify parent about terminal readiness
|
|
if (onTerminalReadyRef.current) {
|
|
const sendData = (data: string) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(data);
|
|
}
|
|
};
|
|
const focusInput = () => {
|
|
termRef.current?.focus();
|
|
};
|
|
const changeFontSize = (delta: number) => {
|
|
handleFontSizeChange(delta);
|
|
};
|
|
onTerminalReadyRef.current(sendData, status, focusInput, changeFontSize);
|
|
}
|
|
|
|
// Visibility API for reconnection
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === "visible" && ws.readyState !== WebSocket.OPEN) {
|
|
reconnectAttemptsRef.current = 0;
|
|
connectWebSocket();
|
|
}
|
|
};
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
return () => {
|
|
clearTimeout(resizeTimeout);
|
|
window.removeEventListener("resize", handleResize);
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
ws.close();
|
|
term.dispose();
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [instanceId, connectWebSocket]);
|
|
|
|
// Update parent about status changes
|
|
useEffect(() => {
|
|
if (onTerminalReady && termRef.current) {
|
|
const sendData = (data: string) => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(data);
|
|
}
|
|
};
|
|
const focusInput = () => {
|
|
termRef.current?.focus();
|
|
};
|
|
const changeFontSize = (delta: number) => {
|
|
handleFontSizeChange(delta);
|
|
};
|
|
onTerminalReady(sendData, status, focusInput, changeFontSize);
|
|
}
|
|
}, [status, onTerminalReady]);
|
|
|
|
const handleFontSizeChange = (delta: number) => {
|
|
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize + delta));
|
|
setFontSize(newSize);
|
|
localStorage.setItem(FONT_SIZE_KEY, newSize.toString());
|
|
if (termRef.current && fitAddonRef.current) {
|
|
termRef.current.options.fontSize = newSize;
|
|
requestAnimationFrame(() => {
|
|
if (termRef.current && fitAddonRef.current) {
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
const { cols, rows } = termRef.current;
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(
|
|
JSON.stringify({
|
|
type: "resize",
|
|
cols,
|
|
rows,
|
|
})
|
|
);
|
|
}
|
|
} catch {
|
|
// Ignore fit errors during re-initialization
|
|
}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleCopy = async () => {
|
|
if (!termRef.current) return;
|
|
const selection = termRef.current.getSelection();
|
|
if (selection) {
|
|
try {
|
|
await navigator.clipboard.writeText(selection);
|
|
} catch {
|
|
// Fallback for older browsers
|
|
const textarea = document.createElement("textarea");
|
|
textarea.value = selection;
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(textarea);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handlePaste = async () => {
|
|
try {
|
|
const text = await navigator.clipboard.readText();
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(text);
|
|
}
|
|
} catch {
|
|
// Clipboard API not available
|
|
}
|
|
};
|
|
|
|
// Focus terminal on mobile to keep keyboard open
|
|
const handleTerminalClick = () => {
|
|
if (isMobile && termRef.current) {
|
|
termRef.current.focus();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={`terminal-wrapper ${isMobile ? "mobile" : ""}`}>
|
|
<div className="terminal-header">
|
|
<div className="terminal-header-left">
|
|
<div className="terminal-status">
|
|
<span
|
|
className={`status-dot ${status}`}
|
|
aria-label={`Terminal status: ${status}`}
|
|
/>
|
|
<span className="status-text">
|
|
{reconnectAttemptsRef.current > 0 && status !== "connected"
|
|
? `Reconnecting (${reconnectAttemptsRef.current}/${RECONNECT_ATTEMPTS})...`
|
|
: status}
|
|
</span>
|
|
</div>
|
|
{isMobile && (
|
|
<>
|
|
<button
|
|
className="terminal-header-button"
|
|
onClick={handleCopy}
|
|
type="button"
|
|
aria-label="Copy selection"
|
|
>
|
|
Copy
|
|
</button>
|
|
<button
|
|
className="terminal-header-button"
|
|
onClick={handlePaste}
|
|
type="button"
|
|
aria-label="Paste from clipboard"
|
|
>
|
|
Paste
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="terminal-header-right">
|
|
<button
|
|
className="terminal-header-button"
|
|
onClick={() => handleFontSizeChange(-1)}
|
|
type="button"
|
|
aria-label="Decrease font size"
|
|
>
|
|
A-
|
|
</button>
|
|
<button
|
|
className="terminal-header-button"
|
|
onClick={() => handleFontSizeChange(1)}
|
|
type="button"
|
|
aria-label="Increase font size"
|
|
>
|
|
A+
|
|
</button>
|
|
{onClose && (
|
|
<button className="terminal-close" onClick={onClose} type="button">
|
|
Close
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{error && (
|
|
<div className="terminal-error">
|
|
{error}
|
|
{status === "error" && (
|
|
<button
|
|
className="terminal-reconnect"
|
|
onClick={() => {
|
|
reconnectAttemptsRef.current = 0;
|
|
connectWebSocket();
|
|
}}
|
|
type="button"
|
|
>
|
|
Reconnect
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={terminalRef}
|
|
className="terminal-container"
|
|
onClick={handleTerminalClick}
|
|
/>
|
|
{isMobile && (
|
|
<input
|
|
ref={hiddenInputRef}
|
|
type="text"
|
|
className="terminal-hidden-input"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|