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 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
|
||||
}
|
||||
|
||||
@@ -169,6 +169,10 @@ export const useTerminalPage = () => {
|
||||
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
|
||||
if (!isAltShift) return;
|
||||
|
||||
const activeSessionIndex = activeSessionId
|
||||
? sessions.findIndex((session) => session.id === activeSessionId)
|
||||
: -1;
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case "n":
|
||||
e.preventDefault();
|
||||
@@ -187,17 +191,14 @@ export const useTerminalPage = () => {
|
||||
break;
|
||||
case "arrowleft":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx > 0) setActiveSessionId(sessions[idx - 1].id);
|
||||
if (activeSessionIndex > 0) {
|
||||
setActiveSessionId(sessions[activeSessionIndex - 1].id);
|
||||
}
|
||||
break;
|
||||
case "arrowright":
|
||||
e.preventDefault();
|
||||
if (activeSessionId) {
|
||||
const idx = sessions.findIndex((s) => s.id === activeSessionId);
|
||||
if (idx < sessions.length - 1)
|
||||
setActiveSessionId(sessions[idx + 1].id);
|
||||
if (activeSessionIndex >= 0 && activeSessionIndex < sessions.length - 1) {
|
||||
setActiveSessionId(sessions[activeSessionIndex + 1].id);
|
||||
}
|
||||
break;
|
||||
case "r":
|
||||
@@ -208,6 +209,8 @@ export const useTerminalPage = () => {
|
||||
e.preventDefault();
|
||||
setIsFullscreen((prev) => !prev);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user