Merge fix/web-terminal-bracketed-paste into dev
This commit is contained in:
@@ -48,6 +48,18 @@ const MIN_FONT_SIZE = 4;
|
|||||||
const MAX_FONT_SIZE = 24;
|
const MAX_FONT_SIZE = 24;
|
||||||
const RECONNECT_ATTEMPTS = 3;
|
const RECONNECT_ATTEMPTS = 3;
|
||||||
const RECONNECT_DELAY_BASE = 1000;
|
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>(
|
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 wsRef = useRef<WebSocket | null>(null);
|
||||||
const termRef = useRef<Terminal | null>(null);
|
const termRef = useRef<Terminal | null>(null);
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||||
|
const bracketedPasteEnabledRef = useRef(false);
|
||||||
|
const pasteTextRef = useRef<(text: string) => void>(() => {});
|
||||||
const reconnectAttemptsRef = useRef(0);
|
const reconnectAttemptsRef = useRef(0);
|
||||||
const onTerminalReadyRef = useRef(onTerminalReady);
|
const onTerminalReadyRef = useRef(onTerminalReady);
|
||||||
onTerminalReadyRef.current = onTerminalReady;
|
onTerminalReadyRef.current = onTerminalReady;
|
||||||
@@ -111,6 +125,41 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
ws.binaryType = "arraybuffer";
|
ws.binaryType = "arraybuffer";
|
||||||
wsRef.current = ws;
|
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 = () => {
|
ws.onopen = () => {
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -156,14 +205,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
|
|
||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
const data = new Uint8Array(event.data);
|
const data = new Uint8Array(event.data);
|
||||||
|
updateBracketedPasteMode(data);
|
||||||
termRef.current.write(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
|
// Flow control: accumulate processed bytes
|
||||||
ackAccumulator += data.length;
|
ackAccumulator += data.length;
|
||||||
if (ackAccumulator >= ACK_THRESHOLD) {
|
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.
|
// the mouse-wheel scrolls through stale frames instead of the app.
|
||||||
// scrollback:0 keeps only the live viewport: no bar, no stale-frame
|
// scrollback:0 keeps only the live viewport: no bar, no stale-frame
|
||||||
// wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.)
|
// wheel jank. (Scrollbar is also hidden via CSS for belt-and-suspenders.)
|
||||||
scrollback: 10000,
|
scrollback: 0,
|
||||||
ignoreBracketedPasteMode: false,
|
ignoreBracketedPasteMode: false,
|
||||||
fastScrollSensitivity: 0,
|
fastScrollSensitivity: 0,
|
||||||
scrollSensitivity: 0,
|
scrollSensitivity: 0,
|
||||||
@@ -367,6 +411,32 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
term.focus();
|
term.focus();
|
||||||
const ws = connectWebSocket();
|
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.
|
// Mobile touch scroll.
|
||||||
// In normal mode xterm.js has a scrollable viewport; in alternate
|
// In normal mode xterm.js has a scrollable viewport; in alternate
|
||||||
// screen (tmux/vim) there is no scrollback and the only way to
|
// 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
|
// Handle terminal input
|
||||||
term.onData((data) => {
|
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;
|
const currentWs = wsRef.current;
|
||||||
if (currentWs?.readyState !== WebSocket.OPEN) return;
|
if (currentWs?.readyState !== WebSocket.OPEN) return;
|
||||||
|
|
||||||
@@ -619,6 +683,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
handleVisibilityChange,
|
handleVisibilityChange,
|
||||||
);
|
);
|
||||||
if (touchCleanup) touchCleanup();
|
if (touchCleanup) touchCleanup();
|
||||||
|
container.removeEventListener("paste", handleBrowserPaste, true);
|
||||||
|
pasteTextRef.current = () => {};
|
||||||
|
bracketedPasteEnabledRef.current = false;
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close(1000, "Component unmounting");
|
ws.close(1000, "Component unmounting");
|
||||||
}
|
}
|
||||||
@@ -740,14 +807,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
|
|||||||
|
|
||||||
const handlePaste = async () => {
|
const handlePaste = async () => {
|
||||||
try {
|
try {
|
||||||
const text = await navigator.clipboard.readText();
|
pasteTextRef.current(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 {
|
} catch {
|
||||||
// Clipboard API not available
|
// Clipboard API not available
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -169,6 +169,10 @@ export const useTerminalPage = () => {
|
|||||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||||
if (!isAltShift) return;
|
if (!isAltShift) return;
|
||||||
|
|
||||||
|
const activeSessionIndex = activeSessionId
|
||||||
|
? sessions.findIndex((session) => session.id === activeSessionId)
|
||||||
|
: -1;
|
||||||
|
|
||||||
switch (e.key.toLowerCase()) {
|
switch (e.key.toLowerCase()) {
|
||||||
case "n":
|
case "n":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -187,17 +191,14 @@ export const useTerminalPage = () => {
|
|||||||
break;
|
break;
|
||||||
case "arrowleft":
|
case "arrowleft":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeSessionId) {
|
if (activeSessionIndex > 0) {
|
||||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
setActiveSessionId(sessions[activeSessionIndex - 1].id);
|
||||||
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "arrowright":
|
case "arrowright":
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeSessionId) {
|
if (activeSessionIndex >= 0 && activeSessionIndex < sessions.length - 1) {
|
||||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
setActiveSessionId(sessions[activeSessionIndex + 1].id);
|
||||||
if (idx < sessions.length - 1)
|
|
||||||
setActiveSessionId(sessions[idx + 1].id);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "r":
|
case "r":
|
||||||
@@ -208,6 +209,8 @@ export const useTerminalPage = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsFullscreen((prev) => !prev);
|
setIsFullscreen((prev) => !prev);
|
||||||
break;
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user