diff --git a/apps/web/src/components/features/terminal/terminal.tsx b/apps/web/src/components/features/terminal/terminal.tsx index bf77dac..079252b 100644 --- a/apps/web/src/components/features/terminal/terminal.tsx +++ b/apps/web/src/components/features/terminal/terminal.tsx @@ -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( ( @@ -68,6 +80,8 @@ export const TerminalComponent = React.forwardRef( const wsRef = useRef(null); const termRef = useRef(null); const fitAddonRef = useRef(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( 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( 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( // 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( 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( // 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( 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( 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 } diff --git a/apps/web/src/hooks/use-terminal-page.ts b/apps/web/src/hooks/use-terminal-page.ts index b916bc8..6bfcaca 100644 --- a/apps/web/src/hooks/use-terminal-page.ts +++ b/apps/web/src/hooks/use-terminal-page.ts @@ -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; } };