Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58a10c65e9 |
@@ -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 });
|
||||
|
||||
59
app/src/components/terminal/osc52.test.ts
Normal file
59
app/src/components/terminal/osc52.test.ts
Normal file
@@ -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("");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
26
container/osc52-clipboard
Normal file
26
container/osc52-clipboard
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user