468f34202a
The web terminal only hosts full-screen TUI tools (pi-agent, opencode), which repaint in place in the normal buffer and do not use the alternate screen or mouse tracking. With scrollback enabled, every repaint accumulated as history, so xterm's viewport scrollbar appeared and the mouse-wheel scrolled through stale frames instead of interacting with the app; the scrollbar column also perturbed FitAddon's column count. - terminal.tsx: set scrollback:0 and scrollSensitivity/fastScrollSensitivity:0 so only the live viewport is kept (no bar, no stale-frame wheel jank). - utilities.css: hide .xterm-viewport scrollbar (scrollbar-width:none + ::-webkit-scrollbar display:none) as belt-and-suspenders. Wheel no longer scrolls stale frames; in-app scrolling uses the tool's own keys. Headquarter-only change; no tool is touched.
896 lines
29 KiB
TypeScript
896 lines
29 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?: (
|
|
sessionId: string | undefined,
|
|
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);
|
|
// Refit after reset/reconnect but do NOT clear the
|
|
// terminal: the server keeps the session buffer, and
|
|
// clearing would erase it when the user switches back
|
|
// to an already-connected session.
|
|
if (termRef.current) {
|
|
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,
|
|
// This terminal only ever hosts full-screen TUI tools (pi-agent,
|
|
// opencode), which repaint in place in the normal buffer and do not
|
|
// use the alternate screen or mouse tracking. With scrollback, every
|
|
// repaint accumulates as history → a viewport scrollbar appears and
|
|
// the mouse-wheel scrolls through stale frames instead of the app.
|
|
// scrollback:0 keeps only the live viewport: no bar, no stale-frame
|
|
// wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.)
|
|
scrollback: 0,
|
|
ignoreBracketedPasteMode: false,
|
|
fastScrollSensitivity: 0,
|
|
scrollSensitivity: 0,
|
|
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());
|
|
|
|
// NOTE: WebGL renderer disabled.
|
|
// The WebGL addon causes black-on-black rendering artifacts with
|
|
// tmux/vim reverse-video (inverse color) sequences on desktop.
|
|
// Mobile already uses the DOM renderer (WebGL fails there), which
|
|
// handles these color attributes correctly. The DOM renderer is
|
|
// fast enough for typical terminal workloads.
|
|
// See: xterm.js WebGL known issues with reverse video / minimumContrastRatio
|
|
|
|
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 the active buffer by reference equality
|
|
// (term.buffer.active === term.buffer.alternate) because the
|
|
// `type` string can be unreliable in some xterm.js versions.
|
|
let touchCleanup: (() => void) | undefined;
|
|
if (isMobile) {
|
|
let startY = 0;
|
|
let startX = 0;
|
|
let isScrolling = false;
|
|
let scrollPending = 0;
|
|
const WHEEL_DISTANCE = 12;
|
|
|
|
const flushScroll = (force = false) => {
|
|
if (scrollPending === 0) return;
|
|
if (!force && Math.abs(scrollPending) < WHEEL_DISTANCE) return;
|
|
const direction = Math.sign(scrollPending);
|
|
const steps = Math.max(
|
|
1,
|
|
Math.floor(Math.abs(scrollPending) / WHEEL_DISTANCE),
|
|
);
|
|
const ws = wsRef.current;
|
|
if (ws?.readyState === WebSocket.OPEN && termRef.current) {
|
|
const buf = termRef.current.buffer.active;
|
|
const col = buf.cursorX + 1;
|
|
const row = buf.cursorY + 1;
|
|
const btn = direction > 0 ? 64 : 65;
|
|
for (let i = 0; i < steps; i++) {
|
|
ws.send(`\x1b[<${btn};${col};${row}M`);
|
|
}
|
|
}
|
|
scrollPending = 0;
|
|
};
|
|
|
|
const isAlternateScreen = () => {
|
|
const term = termRef.current;
|
|
if (!term) return false;
|
|
return term.buffer.active === term.buffer.alternate;
|
|
};
|
|
|
|
const onTouchStart = (e: TouchEvent) => {
|
|
if (e.touches.length === 1) {
|
|
startY = e.touches[0].clientY;
|
|
startX = e.touches[0].clientX;
|
|
isScrolling = false;
|
|
scrollPending = 0;
|
|
}
|
|
};
|
|
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);
|
|
|
|
// Decide early whether this is a vertical scroll gesture.
|
|
if (!isScrolling) {
|
|
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 2) {
|
|
isScrolling = true;
|
|
}
|
|
}
|
|
if (!isScrolling) return;
|
|
|
|
// Take over the gesture so the page/toolbar never scrolls.
|
|
e.preventDefault();
|
|
|
|
if (isAlternateScreen()) {
|
|
// Alternate screen (tmux/vim): accumulate the swipe and
|
|
// send SGR 1006 mouse-wheel events in steps.
|
|
scrollPending += deltaY;
|
|
flushScroll();
|
|
} else if (termRef.current) {
|
|
// Normal buffer: let xterm.js scroll its own viewport by
|
|
// the number of lines corresponding to the swipe distance.
|
|
const lineHeight =
|
|
termRef.current.options.fontSize != null
|
|
? (termRef.current.options.fontSize as number) * 1.2
|
|
: 10;
|
|
const lines = Math.round(deltaY / lineHeight);
|
|
if (lines !== 0) {
|
|
termRef.current.scrollLines(lines);
|
|
}
|
|
}
|
|
startY = touch.clientY;
|
|
};
|
|
const onTouchEnd = () => {
|
|
if (isScrolling) {
|
|
flushScroll(true);
|
|
}
|
|
isScrolling = false;
|
|
};
|
|
|
|
container.addEventListener("touchstart", onTouchStart, {
|
|
passive: false,
|
|
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(
|
|
sessionId,
|
|
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;
|
|
}
|
|
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(
|
|
sessionId,
|
|
sendData,
|
|
status,
|
|
focusInput,
|
|
changeFontSize,
|
|
);
|
|
}
|
|
}, [sessionId, 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();
|
|
// Route through xterm.js instead of sending raw text directly.
|
|
// term.paste() wraps the content in bracketed-paste markers
|
|
// (\e[200~...\e[201~) when the app has enabled BPM, so multiline
|
|
// pastes arrive as a single input rather than one prompt per line.
|
|
// It emits via onData, which the existing handler forwards to the
|
|
// WebSocket, so the readyState check happens there.
|
|
termRef.current?.paste(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";
|