Files
headquarter/apps/web/src/components/features/terminal/terminal.tsx
T
Developer a1a77c99a6 fix: enable multiline paste in web terminal
Multiline pastes into the web terminal (especially into pi) were split
into one prompt per line because bracketed-paste markers were not
reaching the foreground app intact.

- Put the host PTY into raw mode (tty.setraw) after openpty() so it acts
  as a pass-through pipe. The default canonical line discipline was
  line-buffering input, splitting multiline pastes at newlines, and
  mangling bracketed-paste markers before docker exec / pi could see
  them. The in-container PTY (docker exec -t) provides real discipline.
- Route the mobile Paste button through xterm.js (term.paste) instead of
  sending raw clipboard text to the WebSocket, so content is wrapped in
  bracketed-paste markers when the app has enabled BPM.
- Treat a text frame as a control message only when it is a JSON object
  with a known type (resize/ack/reset); otherwise forward as raw input
  so JSON-shaped pastes are no longer silently dropped.

Quality gates: ruff, mypy (changed files), pytest unit (227 passed),
tsc, eslint
2026-07-11 11:32:23 +00:00

889 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,
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());
// 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";