51a399c775
Sidebar SessionItem was only opening web tool URLs in new tabs. Terminal sessions linked to the project page in the same tab. SessionCard 'Open' buttons for terminal sessions navigated in-place. Changes: - app-shell.tsx: SessionItem now builds terminal URLs (/instances/:id/terminal) and always uses target=_blank - session-card.tsx: compute openHref for both web and terminal sessions, render <a> links with target=_blank instead of callback buttons - use-instance-actions.ts: handleOpen now uses window.open(..., '_blank') for terminal sessions and project fallback All session opening (sidebar, cards, callbacks) now consistently opens in a new tab. Quality gates: tsc clean
867 lines
24 KiB
TypeScript
867 lines
24 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 { WebglAddon } from "xterm-addon-webgl";
|
|
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);
|
|
ws.binaryType = "arraybuffer";
|
|
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);
|
|
};
|
|
|
|
// Flow control: accumulate processed bytes and send ack
|
|
let ackAccumulator = 0;
|
|
const ACK_THRESHOLD = 4096;
|
|
let ackTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const flushAck = () => {
|
|
if (ackAccumulator > 0 && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({ type: "ack", chars: ackAccumulator }));
|
|
ackAccumulator = 0;
|
|
}
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
if (!termRef.current) return;
|
|
|
|
if (event.data instanceof ArrayBuffer) {
|
|
const data = new Uint8Array(event.data);
|
|
termRef.current.write(data);
|
|
|
|
// Flow control: accumulate processed bytes
|
|
ackAccumulator += data.length;
|
|
if (ackAccumulator >= ACK_THRESHOLD) {
|
|
flushAck();
|
|
} else if (!ackTimeout) {
|
|
ackTimeout = setTimeout(() => {
|
|
ackTimeout = null;
|
|
flushAck();
|
|
}, 100);
|
|
}
|
|
} 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,
|
|
scrollback: 10000,
|
|
ignoreBracketedPasteMode: false,
|
|
fastScrollSensitivity: 5,
|
|
scrollSensitivity: 1,
|
|
smoothScrollDuration: 0,
|
|
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());
|
|
|
|
// Load WebGL renderer for GPU acceleration, fall back to DOM
|
|
let webglAddon: WebglAddon | null = null;
|
|
try {
|
|
webglAddon = new WebglAddon();
|
|
term.loadAddon(webglAddon);
|
|
webglAddon.onContextLoss(() => {
|
|
console.warn("WebGL context lost, falling back to DOM renderer");
|
|
try {
|
|
webglAddon?.dispose();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
webglAddon = null;
|
|
// Trigger a refit since cell dimensions may differ
|
|
requestAnimationFrame(() => fitTerminal());
|
|
});
|
|
} catch (e) {
|
|
console.warn("WebGL renderer failed to load, using DOM renderer", e);
|
|
}
|
|
|
|
const container = terminalRef.current;
|
|
|
|
// Define fitTerminal before connectWebSocket so it's available in onmessage
|
|
let lastSentCols = 0;
|
|
let lastSentRows = 0;
|
|
const fitTerminal = () => {
|
|
if (!fitAddonRef.current || !termRef.current) return;
|
|
try {
|
|
fitAddonRef.current.fit();
|
|
} catch {
|
|
// Ignore fit errors during initialization
|
|
return;
|
|
}
|
|
const { cols, rows } = termRef.current;
|
|
// Only send resize when dimensions actually changed
|
|
if (
|
|
cols > 0 &&
|
|
rows > 0 &&
|
|
(cols !== lastSentCols || rows !== lastSentRows)
|
|
) {
|
|
lastSentCols = cols;
|
|
lastSentRows = rows;
|
|
const currentWs = wsRef.current;
|
|
if (currentWs?.readyState === WebSocket.OPEN) {
|
|
currentWs.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
}
|
|
}
|
|
};
|
|
|
|
// Open xterm first (must happen before fit)
|
|
term.open(container);
|
|
term.focus();
|
|
const ws = connectWebSocket();
|
|
|
|
// Mobile touch scroll.
|
|
// In normal mode xterm.js has a scrollable viewport; in alternate
|
|
// screen (tmux/vim) there is no scrollback and the only way to
|
|
// scroll is to send mouse-wheel protocol sequences to the
|
|
// application. We detect which situation we're in by checking
|
|
// whether the viewport has scrollable height.
|
|
let touchCleanup: (() => void) | undefined;
|
|
if (isMobile) {
|
|
let startY = 0;
|
|
let startX = 0;
|
|
let isScrolling = false;
|
|
|
|
const onTouchStart = (e: TouchEvent) => {
|
|
if (e.touches.length === 1) {
|
|
startY = e.touches[0].clientY;
|
|
startX = e.touches[0].clientX;
|
|
isScrolling = false;
|
|
}
|
|
};
|
|
const onTouchMove = (e: TouchEvent) => {
|
|
if (e.touches.length !== 1) return;
|
|
const touch = e.touches[0];
|
|
const deltaY = startY - touch.clientY;
|
|
const deltaX = Math.abs(startX - touch.clientX);
|
|
if (!isScrolling) {
|
|
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 4) {
|
|
isScrolling = true;
|
|
}
|
|
}
|
|
if (isScrolling) {
|
|
e.preventDefault();
|
|
const viewport = container.querySelector(
|
|
".xterm-viewport",
|
|
) as HTMLElement | null;
|
|
if (!viewport) return;
|
|
|
|
// If the viewport is scrollable, scroll it directly.
|
|
// Otherwise we are in alternate screen (tmux/vim) and must
|
|
// send SGR 1006 mouse-wheel protocol data.
|
|
const hasScrollback = viewport.scrollHeight > viewport.clientHeight;
|
|
if (hasScrollback) {
|
|
viewport.scrollTop += deltaY;
|
|
} else {
|
|
const ws = wsRef.current;
|
|
if (ws?.readyState === WebSocket.OPEN && termRef.current) {
|
|
// Use the cursor position as the wheel location so
|
|
// tmux knows which pane to scroll.
|
|
const buf = termRef.current.buffer.active;
|
|
const col = buf.cursorX + 1;
|
|
const row = buf.cursorY + 1;
|
|
// SGR 1006: 64 = wheel-up, 65 = wheel-down
|
|
const btn = deltaY > 0 ? 64 : 65;
|
|
ws.send(`\x1b[<${btn};${col};${row}M`);
|
|
}
|
|
}
|
|
startY = touch.clientY;
|
|
}
|
|
};
|
|
const onTouchEnd = () => {
|
|
isScrolling = false;
|
|
};
|
|
|
|
container.addEventListener("touchstart", onTouchStart, {
|
|
passive: true,
|
|
capture: true,
|
|
});
|
|
container.addEventListener("touchmove", onTouchMove, {
|
|
passive: false,
|
|
capture: true,
|
|
});
|
|
container.addEventListener("touchend", onTouchEnd, {
|
|
capture: true,
|
|
});
|
|
touchCleanup = () => {
|
|
container.removeEventListener("touchstart", onTouchStart, {
|
|
capture: true,
|
|
});
|
|
container.removeEventListener("touchmove", onTouchMove, {
|
|
capture: true,
|
|
});
|
|
container.removeEventListener("touchend", onTouchEnd, {
|
|
capture: true,
|
|
});
|
|
};
|
|
}
|
|
|
|
// 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);
|
|
|
|
return () => {
|
|
isUnmountingRef.current = true;
|
|
clearTimeout(resizeTimeout);
|
|
clearTimeout(windowResizeTimeout);
|
|
clearTimeout(headerHideTimeout);
|
|
resizeObserver.disconnect();
|
|
window.removeEventListener("resize", handleWindowResize);
|
|
document.removeEventListener(
|
|
"visibilitychange",
|
|
handleVisibilityChange,
|
|
);
|
|
if (touchCleanup) touchCleanup();
|
|
if (ws) {
|
|
ws.close(1000, "Component unmounting");
|
|
}
|
|
if (heartbeatCheckRef.current) {
|
|
window.clearInterval(heartbeatCheckRef.current);
|
|
heartbeatCheckRef.current = null;
|
|
}
|
|
// Dispose WebGL addon BEFORE the terminal to avoid race with
|
|
// RenderService.setRenderer accessing a disposed renderer
|
|
if (webglAddon) {
|
|
try {
|
|
webglAddon.dispose();
|
|
} catch {
|
|
// Ignore disposal errors from partially torn-down terminal
|
|
}
|
|
webglAddon = null;
|
|
}
|
|
try {
|
|
term.dispose();
|
|
} catch {
|
|
// Ignore disposal errors from partially torn-down terminal
|
|
}
|
|
};
|
|
}, [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";
|