fix: forward browser terminal pastes as bracketed input
- Track application bracketed-paste mode from terminal output. - Capture browser and mobile clipboard pastes only while that mode is enabled, normalizing line endings and sending a single BPM-framed input event. - Remove the synchronous output decoding and console diagnostics that could stall terminal rendering under output load. - Keep the terminal's zero-scrollback configuration and resolve existing no-case-declarations lint blockers in terminal keyboard shortcuts. Quality gates: npm run typecheck, npm run lint
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user