fix: implement OSC 52 clipboard handler in DirectTerminal

The XDA handler told tmux we support clipboard, but the actual OSC 52
handler that receives clipboard data and writes it to
navigator.clipboard was never implemented. This adds the handler with
secure-context detection and a textarea-based fallback, plus cleanup
on unmount.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Prateek 2026-02-27 20:03:32 +05:30
parent 4cda43795b
commit fbc3755833
2 changed files with 126 additions and 3 deletions

View File

@ -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: "" });
});
});

View File

@ -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;<base64-data> 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({
<span className={cn("text-[10px] font-medium uppercase tracking-[0.06em]", statusTextColor)}>
{statusText}
</span>
{/* XDA clipboard badge */}
{/* OSC 52 clipboard badge */}
<span
className="rounded px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-[0.06em]"
style={{
@ -374,7 +420,7 @@ export function DirectTerminal({
background: `color-mix(in srgb, ${accentColor} 12%, transparent)`,
}}
>
XDA
OSC 52
</span>
<button
onClick={() => setFullscreen(!fullscreen)}