|
|
|
@@ -48,6 +48,18 @@ const MIN_FONT_SIZE = 4;
|
|
|
|
|
const MAX_FONT_SIZE = 24;
|
|
|
|
|
const RECONNECT_ATTEMPTS = 3;
|
|
|
|
|
const RECONNECT_DELAY_BASE = 1000;
|
|
|
|
|
const BRACKETED_PASTE_ENABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x34, 0x68];
|
|
|
|
|
const BRACKETED_PASTE_DISABLE_SEQUENCE = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x30, 0x34, 0x6c];
|
|
|
|
|
const BRACKETED_PASTE_CONTROL_TAIL_LENGTH =
|
|
|
|
|
BRACKETED_PASTE_ENABLE_SEQUENCE.length - 1;
|
|
|
|
|
|
|
|
|
|
function matchesByteSequence(
|
|
|
|
|
data: Uint8Array,
|
|
|
|
|
start: number,
|
|
|
|
|
sequence: readonly number[],
|
|
|
|
|
): boolean {
|
|
|
|
|
return sequence.every((byte, index) => data[start + index] === byte);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|
|
|
|
(
|
|
|
|
@@ -68,6 +80,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|
|
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
|
|
|
const termRef = useRef<Terminal | null>(null);
|
|
|
|
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
|
|
|
const bracketedPasteEnabledRef = useRef(false);
|
|
|
|
|
const pasteTextRef = useRef<(text: string) => void>(() => {});
|
|
|
|
|
const reconnectAttemptsRef = useRef(0);
|
|
|
|
|
const onTerminalReadyRef = useRef(onTerminalReady);
|
|
|
|
|
onTerminalReadyRef.current = onTerminalReady;
|
|
|
|
@@ -111,6 +125,41 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|
|
|
|
ws.binaryType = "arraybuffer";
|
|
|
|
|
wsRef.current = ws;
|
|
|
|
|
|
|
|
|
|
let bracketedPasteControlTail = new Uint8Array(0);
|
|
|
|
|
const updateBracketedPasteMode = (data: Uint8Array) => {
|
|
|
|
|
const combined = new Uint8Array(
|
|
|
|
|
bracketedPasteControlTail.length + data.length,
|
|
|
|
|
);
|
|
|
|
|
combined.set(bracketedPasteControlTail);
|
|
|
|
|
combined.set(data, bracketedPasteControlTail.length);
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < combined.length; index++) {
|
|
|
|
|
if (
|
|
|
|
|
index + BRACKETED_PASTE_ENABLE_SEQUENCE.length <= combined.length &&
|
|
|
|
|
matchesByteSequence(
|
|
|
|
|
combined,
|
|
|
|
|
index,
|
|
|
|
|
BRACKETED_PASTE_ENABLE_SEQUENCE,
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
bracketedPasteEnabledRef.current = true;
|
|
|
|
|
} else if (
|
|
|
|
|
index + BRACKETED_PASTE_DISABLE_SEQUENCE.length <= combined.length &&
|
|
|
|
|
matchesByteSequence(
|
|
|
|
|
combined,
|
|
|
|
|
index,
|
|
|
|
|
BRACKETED_PASTE_DISABLE_SEQUENCE,
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
bracketedPasteEnabledRef.current = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bracketedPasteControlTail = combined.slice(
|
|
|
|
|
Math.max(0, combined.length - BRACKETED_PASTE_CONTROL_TAIL_LENGTH),
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ws.onopen = () => {
|
|
|
|
|
setStatus("connected");
|
|
|
|
|
setError(null);
|
|
|
|
@@ -156,14 +205,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|
|
|
|
|
|
|
|
|
if (event.data instanceof ArrayBuffer) {
|
|
|
|
|
const data = new Uint8Array(event.data);
|
|
|
|
|
updateBracketedPasteMode(data);
|
|
|
|
|
termRef.current.write(data);
|
|
|
|
|
|
|
|
|
|
// TEMP diagnostic: log when pi's bracketed-paste enable sequence reaches xterm
|
|
|
|
|
const text = new TextDecoder().decode(data);
|
|
|
|
|
if (text.includes("\x1b[?2004h")) {
|
|
|
|
|
console.log("[BPMDIAG] enable sequence (\\e[?2004h) reached xterm");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Flow control: accumulate processed bytes
|
|
|
|
|
ackAccumulator += data.length;
|
|
|
|
|
if (ackAccumulator >= ACK_THRESHOLD) {
|
|
|
|
@@ -289,7 +333,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|
|
|
|
// 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: 10000,
|
|
|
|
|
scrollback: 0,
|
|
|
|
|
ignoreBracketedPasteMode: false,
|
|
|
|
|
fastScrollSensitivity: 0,
|
|
|
|
|
scrollSensitivity: 0,
|
|
|
|
@@ -367,6 +411,32 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|
|
|
|
term.focus();
|
|
|
|
|
const ws = connectWebSocket();
|
|
|
|
|
|
|
|
|
|
pasteTextRef.current = (text: string) => {
|
|
|
|
|
const currentWs = wsRef.current;
|
|
|
|
|
if (currentWs?.readyState !== WebSocket.OPEN) return;
|
|
|
|
|
|
|
|
|
|
if (bracketedPasteEnabledRef.current) {
|
|
|
|
|
const normalizedText = text.replace(/\r\n|\r|\n/g, "\r");
|
|
|
|
|
currentWs.send(`\x1b[200~${normalizedText}\x1b[201~`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
term.paste(text);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleBrowserPaste = (event: ClipboardEvent) => {
|
|
|
|
|
if (!bracketedPasteEnabledRef.current) return;
|
|
|
|
|
const text = event.clipboardData?.getData("text/plain");
|
|
|
|
|
if (text === undefined || wsRef.current?.readyState !== WebSocket.OPEN) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
event.stopImmediatePropagation();
|
|
|
|
|
pasteTextRef.current(text);
|
|
|
|
|
};
|
|
|
|
|
container.addEventListener("paste", handleBrowserPaste, true);
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
@@ -506,12 +576,6 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|
|
|
|
|
|
|
|
|
// Handle terminal input
|
|
|
|
|
term.onData((data) => {
|
|
|
|
|
// TEMP diagnostic: log paste-like outbound data and whether BPM-wrapped
|
|
|
|
|
if (data.length > 10 && (data.includes("\n") || data.includes("\r"))) {
|
|
|
|
|
const bpmWrapped = data.startsWith("\x1b[200~") && data.endsWith("\x1b[201~");
|
|
|
|
|
console.log("[PASTEDIAG] bpmWrapped=" + bpmWrapped + " len=" + data.length);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const currentWs = wsRef.current;
|
|
|
|
|
if (currentWs?.readyState !== WebSocket.OPEN) return;
|
|
|
|
|
|
|
|
|
@@ -619,6 +683,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|
|
|
|
handleVisibilityChange,
|
|
|
|
|
);
|
|
|
|
|
if (touchCleanup) touchCleanup();
|
|
|
|
|
container.removeEventListener("paste", handleBrowserPaste, true);
|
|
|
|
|
pasteTextRef.current = () => {};
|
|
|
|
|
bracketedPasteEnabledRef.current = false;
|
|
|
|
|
if (ws) {
|
|
|
|
|
ws.close(1000, "Component unmounting");
|
|
|
|
|
}
|
|
|
|
@@ -740,14 +807,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
pasteTextRef.current(await navigator.clipboard.readText());
|
|
|
|
|
} catch {
|
|
|
|
|
// Clipboard API not available
|
|
|
|
|
}
|
|
|
|
|