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:
Developer
2026-07-14 10:02:30 +00:00
parent d1777b88ad
commit 41d24beade
2 changed files with 91 additions and 28 deletions
@@ -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
}