From 3b76899e95d9c3eef4bbca555de5d806a2926f24 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Tue, 16 Jun 2026 21:32:49 +0000 Subject: [PATCH 1/2] feat(site): add OSC 52 clipboard support to web terminal Register an OSC 52 handler on the xterm.js parser so that programs like tmux can copy text to the browser's system clipboard via escape sequences. The handler decodes the base64 payload and writes it using the existing copyToClipboard utility, which provides an HTTP fallback for insecure contexts. Closes #16577 --- .../modules/terminal/WorkspaceTerminal.tsx | 24 +++++++++++++++++++ .../pages/TerminalPage/TerminalPage.test.tsx | 23 ++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/site/src/modules/terminal/WorkspaceTerminal.tsx b/site/src/modules/terminal/WorkspaceTerminal.tsx index 058fdd7ff7f..a0934b40f9d 100644 --- a/site/src/modules/terminal/WorkspaceTerminal.tsx +++ b/site/src/modules/terminal/WorkspaceTerminal.tsx @@ -240,6 +240,30 @@ export const WorkspaceTerminal = ({ }), ); + // OSC 52 clipboard support. Programs like tmux send this escape + // sequence to copy text to the host's system clipboard. + nextTerminal.parser.registerOscHandler(52, (data) => { + const separatorIndex = data.indexOf(";"); + if (separatorIndex === -1) { + return false; + } + const payload = data.slice(separatorIndex + 1); + // A "?" payload is a clipboard read query. Responding would + // require writing back to the PTY, which is not supported. + if (!payload || payload === "?") { + return false; + } + try { + const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0)); + const decoded = new TextDecoder().decode(bytes); + void copyToClipboard(decoded); + } catch { + // Invalid base64; let the default handler run. + return false; + } + return true; + }); + const copySelection = () => { const selection = nextTerminal.getSelection(); if (selection) { diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx index 74f5caabc71..eac462cb72f 100644 --- a/site/src/pages/TerminalPage/TerminalPage.test.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -249,6 +249,29 @@ describe("TerminalPage", () => { expect(closeSpy).toHaveBeenCalled(); }); + it("copies to clipboard via OSC 52", async () => { + const writeTextMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: writeTextMock, readText: vi.fn() }, + writable: true, + configurable: true, + }); + + const ws = createWorkspaceTerminalWebSocket(); + await renderTerminal(); + // Wait for initial resize so the connection is fully established. + await ws.nextMessage; + + // Send the OSC 52 sequence from the issue: + // printf "\033]52;c;$(echo -n 'Coder is Cool' | base64)\a" + const base64 = btoa("Coder is Cool"); + ws.send(`\x1b]52;c;${base64}\x07`); + + await waitFor(() => { + expect(writeTextMock).toHaveBeenCalledWith("Coder is Cool"); + }); + }); + it("skips confirmation dialog for trusted app commands", async () => { // Override the workspace response so the agent has an app with // a command that matches the ?app= slug. From d3ed5604b0caf557b802f78d651c35fbfbba99bd Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 17 Jun 2026 17:17:29 +0000 Subject: [PATCH 2/2] fix(site): address review findings for OSC 52 clipboard support Fix comment accuracy: 'let the default handler run' to 'ignore' since xterm.js has no built-in OSC 52 handler. Restore navigator.clipboard after test using save/restore pattern in a scoped describe block. Add edge-case tests for missing separator, clipboard read query, and invalid base64. --- .../modules/terminal/WorkspaceTerminal.tsx | 2 +- .../pages/TerminalPage/TerminalPage.test.tsx | 84 +++++++++++++++---- 2 files changed, 68 insertions(+), 18 deletions(-) diff --git a/site/src/modules/terminal/WorkspaceTerminal.tsx b/site/src/modules/terminal/WorkspaceTerminal.tsx index a0934b40f9d..a1f96888b09 100644 --- a/site/src/modules/terminal/WorkspaceTerminal.tsx +++ b/site/src/modules/terminal/WorkspaceTerminal.tsx @@ -258,7 +258,7 @@ export const WorkspaceTerminal = ({ const decoded = new TextDecoder().decode(bytes); void copyToClipboard(decoded); } catch { - // Invalid base64; let the default handler run. + // Invalid base64; ignore. return false; } return true; diff --git a/site/src/pages/TerminalPage/TerminalPage.test.tsx b/site/src/pages/TerminalPage/TerminalPage.test.tsx index eac462cb72f..4c1269c54f4 100644 --- a/site/src/pages/TerminalPage/TerminalPage.test.tsx +++ b/site/src/pages/TerminalPage/TerminalPage.test.tsx @@ -1,7 +1,7 @@ import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { HttpResponse, http } from "msw"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { API } from "#/api/api"; import type { Workspace } from "#/api/typesGenerated"; import { @@ -249,26 +249,76 @@ describe("TerminalPage", () => { expect(closeSpy).toHaveBeenCalled(); }); - it("copies to clipboard via OSC 52", async () => { - const writeTextMock = vi.fn().mockResolvedValue(undefined); - Object.defineProperty(navigator, "clipboard", { - value: { writeText: writeTextMock, readText: vi.fn() }, - writable: true, - configurable: true, + describe("OSC 52 clipboard", () => { + let writeTextMock: ReturnType; + let originalClipboard: PropertyDescriptor | undefined; + + beforeEach(() => { + originalClipboard = Object.getOwnPropertyDescriptor( + navigator, + "clipboard", + ); + writeTextMock = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: writeTextMock, readText: vi.fn() }, + writable: true, + configurable: true, + }); }); - const ws = createWorkspaceTerminalWebSocket(); - await renderTerminal(); - // Wait for initial resize so the connection is fully established. - await ws.nextMessage; + afterEach(() => { + if (originalClipboard) { + Object.defineProperty(navigator, "clipboard", originalClipboard); + } + }); - // Send the OSC 52 sequence from the issue: - // printf "\033]52;c;$(echo -n 'Coder is Cool' | base64)\a" - const base64 = btoa("Coder is Cool"); - ws.send(`\x1b]52;c;${base64}\x07`); + it("copies to clipboard via OSC 52", async () => { + const ws = createWorkspaceTerminalWebSocket(); + await renderTerminal(); + await ws.nextMessage; - await waitFor(() => { - expect(writeTextMock).toHaveBeenCalledWith("Coder is Cool"); + // OSC 52 sequence per #16577: + // printf "\033]52;c;$(echo -n 'Coder is Cool' | base64)\a" + const base64 = btoa("Coder is Cool"); + ws.send(`\x1b]52;c;${base64}\x07`); + + await waitFor(() => { + expect(writeTextMock).toHaveBeenCalledWith("Coder is Cool"); + }); + }); + + it("ignores OSC 52 with missing separator", async () => { + const ws = createWorkspaceTerminalWebSocket(); + await renderTerminal(); + await ws.nextMessage; + + ws.send("\x1b]52;no-separator\x07"); + + // Give the parser time to process, then verify no clipboard write. + await new Promise((r) => setTimeout(r, 100)); + expect(writeTextMock).not.toHaveBeenCalled(); + }); + + it("ignores OSC 52 clipboard read query", async () => { + const ws = createWorkspaceTerminalWebSocket(); + await renderTerminal(); + await ws.nextMessage; + + ws.send("\x1b]52;c;?\x07"); + + await new Promise((r) => setTimeout(r, 100)); + expect(writeTextMock).not.toHaveBeenCalled(); + }); + + it("ignores OSC 52 with invalid base64", async () => { + const ws = createWorkspaceTerminalWebSocket(); + await renderTerminal(); + await ws.nextMessage; + + ws.send("\x1b]52;c;!!!invalid-base64!!!\x07"); + + await new Promise((r) => setTimeout(r, 100)); + expect(writeTextMock).not.toHaveBeenCalled(); }); });