diff --git a/packages/web/src/__tests__/osc52.test.ts b/packages/web/src/__tests__/osc52.test.ts new file mode 100644 index 000000000..bf1549e70 --- /dev/null +++ b/packages/web/src/__tests__/osc52.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect } from "vitest"; + +/** + * Pure-logic tests for OSC 52 clipboard parsing. + * + * The actual handler lives inside DirectTerminal's useEffect (registered via + * xterm's `terminal.parser.registerOscHandler`), so we extract the parsing + * logic here to validate it independently of the DOM/xterm runtime. + */ + +/** Mirrors the parsing logic in DirectTerminal's OSC 52 handler. */ +function parseOsc52(data: string): { handled: boolean; text?: string } { + const semicolonIndex = data.indexOf(";"); + if (semicolonIndex === -1) return { handled: false }; + + const base64Content = data.substring(semicolonIndex + 1); + if (base64Content === "?") { + // Query request — acknowledge but no text + return { handled: true }; + } + + try { + const text = atob(base64Content); + return { handled: true, text }; + } catch { + return { handled: true }; // malformed base64 — swallow + } +} + +describe("OSC 52 parsing", () => { + it("decodes clipboard selection (c)", () => { + // "c" = clipboard, base64("hello") = "aGVsbG8=" + const result = parseOsc52("c;aGVsbG8="); + expect(result).toEqual({ handled: true, text: "hello" }); + }); + + it("decodes primary selection (p)", () => { + const result = parseOsc52("p;aGVsbG8="); + expect(result).toEqual({ handled: true, text: "hello" }); + }); + + it("decodes multi-target selection (pc)", () => { + // tmux sometimes sends multiple targets like "pc" + const result = parseOsc52("pc;aGVsbG8="); + expect(result).toEqual({ handled: true, text: "hello" }); + }); + + it("handles query request (? payload)", () => { + const result = parseOsc52("c;?"); + expect(result).toEqual({ handled: true }); + }); + + it("returns unhandled when no semicolon present", () => { + const result = parseOsc52("garbage"); + expect(result).toEqual({ handled: false }); + }); + + it("handles invalid base64 gracefully", () => { + const result = parseOsc52("c;!!!not-base64!!!"); + expect(result).toEqual({ handled: true }); // swallowed + }); + + it("decodes unicode text", () => { + // btoa can't encode non-latin1 directly, but tmux sends UTF-8 bytes + // as base64. In a browser, atob returns a byte string which is valid + // for ASCII-subset UTF-8. + const base64 = btoa("line1\nline2\ttab"); + const result = parseOsc52(`c;${base64}`); + expect(result).toEqual({ handled: true, text: "line1\nline2\ttab" }); + }); + + it("decodes empty string payload", () => { + // base64("") = "" + const result = parseOsc52("c;"); + expect(result).toEqual({ handled: true, text: "" }); + }); +}); diff --git a/packages/web/src/components/DirectTerminal.tsx b/packages/web/src/components/DirectTerminal.tsx index a4833a611..fe913cfb7 100644 --- a/packages/web/src/components/DirectTerminal.tsx +++ b/packages/web/src/components/DirectTerminal.tsx @@ -21,6 +21,22 @@ interface DirectTerminalProps { height?: string; } +/** Copy text to clipboard using a hidden textarea (fallback for non-secure contexts). */ +function fallbackCopy(text: string): void { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand("copy"); + } catch { + console.warn("[DirectTerminal] Fallback clipboard copy failed"); + } + document.body.removeChild(textarea); +} + /** * Direct xterm.js terminal with native WebSocket connection. * Implements Extended Device Attributes (XDA) handler to enable @@ -135,7 +151,7 @@ export function DirectTerminal({ // **CRITICAL FIX**: Register XDA (Extended Device Attributes) handler // This makes tmux recognize our terminal and enable clipboard support - terminal.parser.registerCsiHandler( + const xdaDisposable = terminal.parser.registerCsiHandler( { prefix: ">", final: "q" }, // CSI > q is XTVERSION / XDA () => { // Respond with XTerm identification that tmux recognizes @@ -148,6 +164,34 @@ export function DirectTerminal({ }, ); + // Register OSC 52 handler for clipboard integration + // tmux sends clipboard data as: OSC 52 ; c; ST + const osc52Disposable = terminal.parser.registerOscHandler(52, (data: string) => { + const semicolonIndex = data.indexOf(";"); + if (semicolonIndex === -1) return false; + + const base64Content = data.substring(semicolonIndex + 1); + if (base64Content === "?") { + // Query request — not supported, ignore + return true; + } + + try { + const text = atob(base64Content); + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).catch((err) => { + console.warn("[DirectTerminal] Clipboard write failed:", err); + fallbackCopy(text); + }); + } else { + fallbackCopy(text); + } + } catch (err) { + console.warn("[DirectTerminal] Failed to decode OSC 52 data:", err); + } + return true; + }); + // Open terminal in DOM terminal.open(terminalRef.current); terminalInstance.current = terminal; @@ -229,6 +273,8 @@ export function DirectTerminal({ cleanup = () => { window.removeEventListener("resize", handleResize); disposable.dispose(); + xdaDisposable.dispose(); + osc52Disposable.dispose(); websocket.close(); terminal.dispose(); }; @@ -366,7 +412,7 @@ export function DirectTerminal({ {statusText} - {/* XDA clipboard badge */} + {/* OSC 52 clipboard badge */} - XDA + OSC 52