c1e16f2163
- Add body.terminal-page-open { overflow: hidden } to prevent page scroll
- TerminalPage adds/removes 'terminal-page-open' class on body when mounted
- Re-add capture-phase touch listeners in terminal.tsx with low 3px threshold
- Call e.preventDefault() immediately when vertical gesture is detected,
before browser compositor commits to page scroll
- Remove CSS touch-action overrides on xterm viewport (now handled in JS)
- Scroll forwarded via term.scrollLines() with 24px per line sensitivity
795 lines
22 KiB
TypeScript
795 lines
22 KiB
TypeScript
import React, {
|
|
useEffect,
|
|
useImperativeHandle,
|
|
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";
|
|
|
|
export interface TerminalProps {
|
|
instanceId: string;
|
|
sessionId?: string;
|
|
onClose?: () => void;
|
|
isMobile?: boolean;
|
|
showControls?: boolean;
|
|
activeModifier?: ModifierKey | null;
|
|
onModifierChange?: (modifier: ModifierKey | null) => void;
|
|
onTerminalReady?: (
|
|
sendData: (data: string) => void,
|
|
connectionStatus:
|
|
| "connecting"
|
|
| "connected"
|
|
| "disconnected"
|
|
| "error"
|
|
| "resetting",
|
|
focusInput: () => void,
|
|
changeFontSize: (delta: number) => void,
|
|
) => void;
|
|
}
|
|
|
|
export interface TerminalRef {
|
|
fit: () => void;
|
|
focus: () => void;
|
|
reset: () => void;
|
|
}
|
|
|
|
const FONT_SIZE_KEY = "terminal-font-size";
|
|
const MIN_FONT_SIZE = 4;
|
|
const MAX_FONT_SIZE = 24;
|
|
const RECONNECT_ATTEMPTS = 3;
|
|
const RECONNECT_DELAY_BASE = 1000;
|
|
|
|
export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|
(
|
|
{
|
|
instanceId,
|
|
sessionId,
|
|
onClose,
|
|
isMobile = false,
|
|
showControls = true,
|
|
activeModifier,
|
|
onModifierChange,
|
|
onTerminalReady,
|
|
},
|
|
ref,
|
|
) => {
|
|
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 handleFontSizeChangeRef = useRef<(delta: number) => void>(() => {});
|
|
const [status, setStatus] = useState<
|
|
"connecting" | "connected" | "disconnected" | "error" | "resetting"
|
|
>("connecting");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
|
const activeModifierRef = useRef(activeModifier);
|
|
activeModifierRef.current = activeModifier;
|
|
const [fontSize, setFontSize] = useState(() => {
|
|
if (typeof window === "undefined") return isMobile ? 8 : 8;
|
|
const stored = localStorage.getItem(FONT_SIZE_KEY);
|
|
if (stored) {
|
|
const parsed = parseInt(stored, 10);
|
|
return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, parsed));
|
|
}
|
|
return isMobile ? 8 : 8;
|
|
});
|
|
const lastPingRef = useRef<number>(0);
|
|
const heartbeatCheckRef = useRef<number | null>(null);
|
|
const isUnmountingRef = useRef(false);
|
|
const permanentErrorRef = useRef<string | null>(null);
|
|
|
|
const calculateFontSize = useCallback(() => {
|
|
return fontSize;
|
|
}, [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 wsPath = sessionId
|
|
? `/ws/tool-instances/${instanceId}/terminal/${sessionId}`
|
|
: `/ws/tool-instances/${instanceId}/terminal`;
|
|
const wsUrl = `${wsProtocol}//${wsHost}${wsPath}`;
|
|
|
|
// WebSocket connection established
|
|
const ws = new WebSocket(wsUrl);
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
setStatus("connected");
|
|
setError(null);
|
|
reconnectAttemptsRef.current = 0;
|
|
lastPingRef.current = Date.now();
|
|
|
|
// Send current terminal size immediately on connect
|
|
if (termRef.current) {
|
|
const { cols, rows } = termRef.current;
|
|
// Only send if we have valid dimensions
|
|
if (cols > 0 && rows > 0) {
|
|
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
}
|
|
}
|
|
|
|
// Start heartbeat check
|
|
if (heartbeatCheckRef.current) {
|
|
window.clearInterval(heartbeatCheckRef.current);
|
|
}
|
|
heartbeatCheckRef.current = window.setInterval(() => {
|
|
const elapsed = Date.now() - lastPingRef.current;
|
|
if (elapsed > 60000) {
|
|
// No ping for 60 seconds, connection may be dead
|
|
ws.close(4000, "Heartbeat timeout");
|
|
}
|
|
}, 30000);
|
|
};
|
|
|
|
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") {
|
|
if (msg.status === "connected") {
|
|
setStatus("connected");
|
|
setError(null);
|
|
// Clear terminal and refit after reset/reconnect
|
|
if (termRef.current) {
|
|
termRef.current.clear();
|
|
requestAnimationFrame(() => {
|
|
if (fitAddonRef.current && termRef.current) {
|
|
fitAddonRef.current.fit();
|
|
const { cols, rows } = termRef.current;
|
|
const currentWs = wsRef.current;
|
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
|
currentWs.send(
|
|
JSON.stringify({ type: "resize", cols, rows }),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} else if (msg.status === "resetting") {
|
|
setStatus("resetting");
|
|
}
|
|
} else if (msg.type === "ping") {
|
|
// Respond with pong and update last ping time
|
|
lastPingRef.current = Date.now();
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "pong" }));
|
|
}
|
|
}
|
|
} catch {
|
|
termRef.current?.write(event.data);
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onclose = (event) => {
|
|
// Clean up heartbeat check
|
|
if (heartbeatCheckRef.current) {
|
|
window.clearInterval(heartbeatCheckRef.current);
|
|
heartbeatCheckRef.current = null;
|
|
}
|
|
|
|
// Permanent errors: do not retry
|
|
if (event.code === 4001 || event.code === 4003 || event.code === 4004) {
|
|
const reason = event.reason || `Instance error (code: ${event.code})`;
|
|
setStatus("error");
|
|
setError(reason);
|
|
permanentErrorRef.current = reason;
|
|
return;
|
|
}
|
|
|
|
if (event.code === 1000) {
|
|
setStatus("disconnected");
|
|
return;
|
|
}
|
|
|
|
if (event.code === 4000) {
|
|
// Server closed old connection for concurrent connection - don't reconnect
|
|
// The new connection is already established
|
|
return;
|
|
}
|
|
|
|
// Transient errors: attempt reconnection
|
|
setStatus("disconnected");
|
|
setError(`Connection closed (code: ${event.code})`);
|
|
|
|
if (reconnectAttemptsRef.current < RECONNECT_ATTEMPTS) {
|
|
reconnectAttemptsRef.current++;
|
|
const delay =
|
|
RECONNECT_DELAY_BASE *
|
|
Math.pow(2, reconnectAttemptsRef.current - 1);
|
|
setTimeout(() => {
|
|
if (isUnmountingRef.current) {
|
|
return;
|
|
}
|
|
if (document.visibilityState !== "hidden") {
|
|
connectWebSocket();
|
|
}
|
|
}, delay);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
setStatus("error");
|
|
setError("WebSocket error");
|
|
};
|
|
|
|
return ws;
|
|
}, [instanceId, sessionId]);
|
|
|
|
useEffect(() => {
|
|
if (!terminalRef.current) return;
|
|
|
|
// Initialize terminal
|
|
const currentFontSize = calculateFontSize();
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontSize: currentFontSize,
|
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
|
lineHeight: 1.2,
|
|
letterSpacing: 0,
|
|
allowTransparency: false,
|
|
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());
|
|
|
|
const container = terminalRef.current;
|
|
|
|
// Define fitTerminal before connectWebSocket so it's available in onmessage
|
|
const fitTerminal = () => {
|
|
if (!fitAddonRef.current || !termRef.current) return;
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
} catch {
|
|
// Ignore fit errors during initialization
|
|
return;
|
|
}
|
|
const { cols, rows } = termRef.current;
|
|
// Force refresh if dimensions are valid
|
|
if (cols > 0 && rows > 0) {
|
|
try {
|
|
termRef.current.refresh(0, rows - 1);
|
|
} catch {
|
|
// Ignore refresh errors
|
|
}
|
|
}
|
|
const currentWs = wsRef.current;
|
|
if (currentWs?.readyState === WebSocket.OPEN && cols > 0 && rows > 0) {
|
|
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
}
|
|
};
|
|
|
|
// Open xterm first (must happen before fit)
|
|
term.open(container);
|
|
term.focus();
|
|
const ws = connectWebSocket();
|
|
|
|
// Initial fit after layout settles (terminal must be opened first)
|
|
let fitAttempts = 0;
|
|
const doInitialFit = () => {
|
|
if (!container.isConnected) return;
|
|
fitAttempts++;
|
|
// Ensure container has dimensions before fitting
|
|
if (container.clientWidth > 0 && container.clientHeight > 0) {
|
|
fitTerminal();
|
|
} else if (fitAttempts < 50) {
|
|
// Container not ready yet, try again (max 50 attempts ~ 1s)
|
|
requestAnimationFrame(doInitialFit);
|
|
}
|
|
};
|
|
requestAnimationFrame(doInitialFit);
|
|
|
|
// Refit after font load (metrics may change)
|
|
document.fonts.ready.then(() => {
|
|
requestAnimationFrame(() => fitTerminal());
|
|
});
|
|
|
|
// Handle terminal input
|
|
term.onData((data) => {
|
|
const currentWs = wsRef.current;
|
|
if (currentWs?.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) {
|
|
currentWs.send(modified);
|
|
onModifierChange?.(null);
|
|
return;
|
|
}
|
|
}
|
|
|
|
currentWs.send(data);
|
|
});
|
|
|
|
// Handle container resize with ResizeObserver for accurate dimension tracking
|
|
let resizeTimeout: ReturnType<typeof setTimeout>;
|
|
let lastWidth = 0;
|
|
let lastHeight = 0;
|
|
const resizeObserver = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
if (!entry) return;
|
|
|
|
const { width, height } = entry.contentRect;
|
|
// Only trigger if dimensions actually changed
|
|
if (width === lastWidth && height === lastHeight) return;
|
|
lastWidth = width;
|
|
lastHeight = height;
|
|
|
|
clearTimeout(resizeTimeout);
|
|
resizeTimeout = setTimeout(() => {
|
|
requestAnimationFrame(() => {
|
|
if (!container.isConnected) return;
|
|
fitTerminal();
|
|
});
|
|
}, 50);
|
|
});
|
|
resizeObserver.observe(container);
|
|
|
|
// Window resize fallback (for viewport changes that don't affect container dimensions)
|
|
let windowResizeTimeout: ReturnType<typeof setTimeout>;
|
|
const handleWindowResize = () => {
|
|
clearTimeout(windowResizeTimeout);
|
|
windowResizeTimeout = setTimeout(() => {
|
|
requestAnimationFrame(() => fitTerminal());
|
|
}, 250);
|
|
};
|
|
window.addEventListener("resize", handleWindowResize);
|
|
|
|
// Refit after mobile header auto-hides (3s delay + 0.3s transition)
|
|
const headerHideTimeout = setTimeout(() => {
|
|
fitTerminal();
|
|
}, 4000);
|
|
|
|
// Notify parent about terminal readiness
|
|
if (onTerminalReadyRef.current) {
|
|
const sendData = (data: string) => {
|
|
const currentWs = wsRef.current;
|
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
|
currentWs.send(data);
|
|
}
|
|
};
|
|
const focusInput = () => {
|
|
termRef.current?.focus();
|
|
};
|
|
const changeFontSize = (delta: number) => {
|
|
handleFontSizeChangeRef.current(delta);
|
|
};
|
|
onTerminalReadyRef.current(
|
|
sendData,
|
|
status,
|
|
focusInput,
|
|
changeFontSize,
|
|
);
|
|
}
|
|
|
|
// Visibility API for reconnection
|
|
const handleVisibilityChange = () => {
|
|
if (
|
|
document.visibilityState === "visible" &&
|
|
ws &&
|
|
ws.readyState !== WebSocket.OPEN
|
|
) {
|
|
if (permanentErrorRef.current) {
|
|
return;
|
|
}
|
|
reconnectAttemptsRef.current = 0;
|
|
connectWebSocket();
|
|
}
|
|
};
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
|
|
// Mobile touch scroll forwarding.
|
|
// xterm.js intercepts touch events for selection, which blocks the
|
|
// browser from scrolling the viewport. We listen on the container in
|
|
// capture phase, decide early if the gesture is vertical, and then
|
|
// preventDefault + stopPropagation so xterm.js never sees the event.
|
|
// The scroll is forwarded to term.scrollLines().
|
|
let touchStartY = 0;
|
|
let touchStartX = 0;
|
|
let isVerticalScroll = false;
|
|
let accumulatedDeltaY = 0;
|
|
|
|
const handleTouchStart = (e: TouchEvent) => {
|
|
if (e.touches.length === 1) {
|
|
touchStartY = e.touches[0].clientY;
|
|
touchStartX = e.touches[0].clientX;
|
|
isVerticalScroll = false;
|
|
accumulatedDeltaY = 0;
|
|
}
|
|
};
|
|
|
|
const handleTouchMove = (e: TouchEvent) => {
|
|
if (e.touches.length !== 1 || !termRef.current) return;
|
|
const touch = e.touches[0];
|
|
const deltaY = touchStartY - touch.clientY;
|
|
const deltaX = Math.abs(touchStartX - touch.clientX);
|
|
|
|
// Decide scroll direction on the first meaningful movement (3px)
|
|
if (!isVerticalScroll) {
|
|
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 3) {
|
|
isVerticalScroll = true;
|
|
}
|
|
}
|
|
|
|
if (isVerticalScroll) {
|
|
// preventDefault MUST be called on the first matching touchmove
|
|
// or the browser compositor will already have committed to page
|
|
// scroll before our JS runs.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
accumulatedDeltaY += deltaY;
|
|
touchStartY = touch.clientY;
|
|
const lines = Math.round(accumulatedDeltaY / 24);
|
|
if (lines !== 0) {
|
|
// Negative = scroll up (show older buffer content)
|
|
termRef.current.scrollLines(-lines);
|
|
accumulatedDeltaY = 0;
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleTouchEnd = () => {
|
|
isVerticalScroll = false;
|
|
accumulatedDeltaY = 0;
|
|
};
|
|
|
|
if (isMobile) {
|
|
container.addEventListener("touchstart", handleTouchStart, {
|
|
passive: true,
|
|
capture: true,
|
|
});
|
|
container.addEventListener("touchmove", handleTouchMove, {
|
|
passive: false,
|
|
capture: true,
|
|
});
|
|
container.addEventListener("touchend", handleTouchEnd, {
|
|
capture: true,
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
isUnmountingRef.current = true;
|
|
clearTimeout(resizeTimeout);
|
|
clearTimeout(windowResizeTimeout);
|
|
clearTimeout(headerHideTimeout);
|
|
resizeObserver.disconnect();
|
|
window.removeEventListener("resize", handleWindowResize);
|
|
document.removeEventListener(
|
|
"visibilitychange",
|
|
handleVisibilityChange,
|
|
);
|
|
if (isMobile) {
|
|
container.removeEventListener("touchstart", handleTouchStart, {
|
|
capture: true,
|
|
});
|
|
container.removeEventListener("touchmove", handleTouchMove, {
|
|
capture: true,
|
|
});
|
|
container.removeEventListener("touchend", handleTouchEnd, {
|
|
capture: true,
|
|
});
|
|
}
|
|
if (ws) {
|
|
ws.close(1000, "Component unmounting");
|
|
}
|
|
if (heartbeatCheckRef.current) {
|
|
window.clearInterval(heartbeatCheckRef.current);
|
|
heartbeatCheckRef.current = null;
|
|
}
|
|
term.dispose();
|
|
};
|
|
}, [instanceId, connectWebSocket]);
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
fit: () => {
|
|
if (fitAddonRef.current && termRef.current) {
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
const { cols, rows } = termRef.current;
|
|
if (
|
|
wsRef.current?.readyState === WebSocket.OPEN &&
|
|
cols > 0 &&
|
|
rows > 0
|
|
) {
|
|
wsRef.current.send(
|
|
JSON.stringify({ type: "resize", cols, rows }),
|
|
);
|
|
}
|
|
} catch {
|
|
// Ignore fit errors
|
|
}
|
|
}
|
|
},
|
|
focus: () => {
|
|
termRef.current?.focus();
|
|
},
|
|
reset: () => {
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(JSON.stringify({ type: "reset" }));
|
|
}
|
|
},
|
|
}));
|
|
|
|
// 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) => {
|
|
handleFontSizeChangeRef.current(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
|
|
}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
handleFontSizeChangeRef.current = handleFontSizeChange;
|
|
|
|
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" : ""} ${!showControls ? "no-controls" : ""}`}
|
|
>
|
|
{showControls && (
|
|
<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">
|
|
{status === "resetting"
|
|
? "Resetting..."
|
|
: 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>
|
|
<button
|
|
className="terminal-header-button"
|
|
onClick={() => setShowResetConfirm(true)}
|
|
type="button"
|
|
aria-label="Reset terminal"
|
|
>
|
|
Reset
|
|
</button>
|
|
{onClose && (
|
|
<button
|
|
className="terminal-close"
|
|
onClick={onClose}
|
|
type="button"
|
|
>
|
|
Close
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{showResetConfirm && (
|
|
<div className="terminal-reset-confirm">
|
|
<div className="terminal-reset-confirm-content">
|
|
<p>
|
|
Reset terminal? This will kill the current shell session and
|
|
start fresh.
|
|
</p>
|
|
<div className="terminal-reset-confirm-buttons">
|
|
<button
|
|
className="terminal-reset-confirm-button cancel"
|
|
onClick={() => setShowResetConfirm(false)}
|
|
type="button"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="terminal-reset-confirm-button confirm"
|
|
onClick={() => {
|
|
setShowResetConfirm(false);
|
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
wsRef.current.send(JSON.stringify({ type: "reset" }));
|
|
}
|
|
}}
|
|
type="button"
|
|
>
|
|
Reset
|
|
</button>
|
|
</div>
|
|
</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>
|
|
);
|
|
},
|
|
);
|
|
|
|
TerminalComponent.displayName = "TerminalComponent";
|