From 58a10c65e9067abde38a3a36d8aefc20c0376d87 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Thu, 5 Mar 2026 05:47:42 -0800 Subject: [PATCH] feat: add OSC 52 clipboard support for container-to-host copy Programs inside the container (e.g. Claude Code's "hit c to copy") can now write to the host system clipboard. A shell script shim installed as xclip/xsel/pbcopy emits OSC 52 escape sequences, which the xterm.js frontend intercepts and forwards to navigator.clipboard.writeText(). Co-Authored-By: Claude Opus 4.6 --- app/src/components/terminal/TerminalView.tsx | 20 +++++++ app/src/components/terminal/osc52.test.ts | 59 ++++++++++++++++++++ container/Dockerfile | 10 ++++ container/osc52-clipboard | 26 +++++++++ 4 files changed, 115 insertions(+) create mode 100644 app/src/components/terminal/osc52.test.ts create mode 100644 container/osc52-clipboard diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 421d6db..2c1e29d 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -82,6 +82,25 @@ export default function TerminalView({ sessionId, active }: Props) { // Send initial size resize(sessionId, term.cols, term.rows); + // Handle OSC 52 clipboard write sequences from programs inside the container. + // When a program (e.g. Claude Code) copies text via xclip/xsel/pbcopy, the + // container's shim emits an OSC 52 escape sequence which xterm.js routes here. + const osc52Disposable = term.parser.registerOscHandler(52, (data) => { + const idx = data.indexOf(";"); + if (idx === -1) return false; + const payload = data.substring(idx + 1); + if (payload === "?") return false; // clipboard read request, not supported + try { + const decoded = atob(payload); + navigator.clipboard.writeText(decoded).catch((e) => + console.error("OSC 52 clipboard write failed:", e), + ); + } catch (e) { + console.error("OSC 52 decode failed:", e); + } + return true; + }); + // Handle user input -> backend const inputDisposable = term.onData((data) => { sendInput(sessionId, data); @@ -170,6 +189,7 @@ export default function TerminalView({ sessionId, active }: Props) { aborted = true; detector.dispose(); detectorRef.current = null; + osc52Disposable.dispose(); inputDisposable.dispose(); scrollDisposable.dispose(); containerRef.current?.removeEventListener("paste", handlePaste, { capture: true }); diff --git a/app/src/components/terminal/osc52.test.ts b/app/src/components/terminal/osc52.test.ts new file mode 100644 index 0000000..ba1f442 --- /dev/null +++ b/app/src/components/terminal/osc52.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +/** + * Tests the OSC 52 clipboard parsing logic used in TerminalView. + * Extracted here to validate the decode/write path independently. + */ + +// Mirrors the handler registered in TerminalView.tsx +function handleOsc52(data: string): string | null { + const idx = data.indexOf(";"); + if (idx === -1) return null; + const payload = data.substring(idx + 1); + if (payload === "?") return null; + try { + return atob(payload); + } catch { + return null; + } +} + +describe("OSC 52 clipboard handler", () => { + it("decodes a valid clipboard write sequence", () => { + // "c;BASE64" where BASE64 encodes "https://example.com" + const encoded = btoa("https://example.com"); + const result = handleOsc52(`c;${encoded}`); + expect(result).toBe("https://example.com"); + }); + + it("decodes multi-line content", () => { + const text = "line1\nline2\nline3"; + const encoded = btoa(text); + const result = handleOsc52(`c;${encoded}`); + expect(result).toBe(text); + }); + + it("handles primary selection target (p)", () => { + const encoded = btoa("selected text"); + const result = handleOsc52(`p;${encoded}`); + expect(result).toBe("selected text"); + }); + + it("returns null for clipboard read request (?)", () => { + expect(handleOsc52("c;?")).toBe(null); + }); + + it("returns null for missing semicolon", () => { + expect(handleOsc52("invalid")).toBe(null); + }); + + it("returns null for invalid base64", () => { + expect(handleOsc52("c;!!!not-base64!!!")).toBe(null); + }); + + it("handles empty payload after selection target", () => { + // btoa("") = "" + const result = handleOsc52("c;"); + expect(result).toBe(""); + }); +}); diff --git a/container/Dockerfile b/container/Dockerfile index f861586..da9e0de 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -101,6 +101,16 @@ WORKDIR /workspace # ── Switch back to root for entrypoint (handles UID/GID remapping) ───────── USER root + +# ── OSC 52 clipboard support ───────────────────────────────────────────── +# Provides xclip/xsel/pbcopy shims that emit OSC 52 escape sequences, +# allowing programs inside the container to copy to the host clipboard. +COPY osc52-clipboard /usr/local/bin/osc52-clipboard +RUN chmod +x /usr/local/bin/osc52-clipboard \ + && ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xclip \ + && ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xsel \ + && ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/pbcopy + COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler diff --git a/container/osc52-clipboard b/container/osc52-clipboard new file mode 100644 index 0000000..130ee3e --- /dev/null +++ b/container/osc52-clipboard @@ -0,0 +1,26 @@ +#!/bin/bash +# OSC 52 clipboard provider — sends clipboard data to the host system clipboard +# via OSC 52 terminal escape sequences. Installed as xclip/xsel/pbcopy so that +# programs inside the container (e.g. Claude Code) can copy to clipboard. +# +# Supports common invocations: +# echo "text" | xclip -selection clipboard +# echo "text" | xsel --clipboard --input +# echo "text" | pbcopy +# +# Paste/output requests exit silently (not supported via OSC 52). + +# Detect paste/output mode — exit silently since we can't read the host clipboard +for arg in "$@"; do + case "$arg" in + -o|--output) exit 0 ;; + esac +done + +# Read all input from stdin +data=$(cat) +[ -z "$data" ] && exit 0 + +# Base64 encode and write OSC 52 escape sequence to the controlling terminal +encoded=$(printf '%s' "$data" | base64 | tr -d '\n') +printf '\033]52;c;%s\a' "$encoded" > /dev/tty 2>/dev/null