diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index b991636a8..c53a382bc 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -1,4 +1,4 @@ -import { execSync, spawn } from "child_process"; +import { type ExecFileSyncOptionsWithStringEncoding, execFileSync, execSync, spawn } from "child_process"; import { platform } from "os"; import { isWaylandSession } from "./clipboard-image.ts"; import { clipboard } from "./clipboard-native.ts"; @@ -32,8 +32,32 @@ function emitOsc52(text: string): boolean { return true; } -/** Read plain text from the system clipboard, if native clipboard access is available. */ +type ClipboardReadResult = { ok: true; text: string | null } | { ok: false }; + +const READ_CLIPBOARD_OPTIONS: ExecFileSyncOptionsWithStringEncoding = { + encoding: "utf8", + maxBuffer: 50 * 1024 * 1024, + timeout: 5000, +}; + +function readWaylandClipboardText(): ClipboardReadResult { + try { + const text = execFileSync("wl-paste", ["--no-newline", "--type", "text"], READ_CLIPBOARD_OPTIONS); + return { ok: true, text: text || null }; + } catch { + return { ok: false }; + } +} + +/** Read plain text from the system clipboard. */ export async function readClipboardText(): Promise { + if (platform() === "linux" && isWaylandSession() && process.env.WAYLAND_DISPLAY) { + const result = readWaylandClipboardText(); + if (result.ok) { + return result.text; + } + } + if (!clipboard) { return null; } diff --git a/packages/coding-agent/test/clipboard.test.ts b/packages/coding-agent/test/clipboard.test.ts index c3089a39d..5713d5aed 100644 --- a/packages/coding-agent/test/clipboard.test.ts +++ b/packages/coding-agent/test/clipboard.test.ts @@ -1,4 +1,4 @@ -import { execSync, spawn } from "child_process"; +import { execFileSync, execSync, spawn } from "child_process"; import { platform } from "os"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { copyToClipboard, readClipboardText } from "../src/utils/clipboard.ts"; @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => { getText: vi.fn<() => Promise>(), setText: vi.fn<(text: string) => Promise>(), }, + execFileSync: vi.fn(), execSync: vi.fn(), spawn: vi.fn(), platform: vi.fn<() => NodeJS.Platform>(), @@ -24,6 +25,7 @@ vi.mock("../src/utils/clipboard-native.js", () => { vi.mock("child_process", () => { return { + execFileSync: mocks.execFileSync, execSync: mocks.execSync, spawn: mocks.spawn, }; @@ -41,6 +43,7 @@ vi.mock("../src/utils/clipboard-image.js", () => { }; }); +const mockedExecFileSync = vi.mocked(execFileSync); const mockedExecSync = vi.mocked(execSync); const mockedSpawn = vi.mocked(spawn); const mockedPlatform = vi.mocked(platform); @@ -62,6 +65,7 @@ beforeEach(() => { nativeResolved = false; mocks.clipboard.getText.mockReset(); mocks.clipboard.setText.mockReset(); + mocks.execFileSync.mockReset(); mocks.execSync.mockReset(); mocks.spawn.mockReset(); mocks.platform.mockReset(); @@ -96,6 +100,46 @@ describe("readClipboardText", () => { await expect(readClipboardText()).resolves.toBe("clipboard text"); }); + test("reads the Wayland clipboard before the stale native X11 clipboard", async () => { + // Regression test for #7248. + mockedPlatform.mockReturnValue("linux"); + mocks.isWaylandSession.mockReturnValue(true); + vi.stubEnv("WAYLAND_DISPLAY", "wayland-0"); + mockedExecFileSync.mockReturnValue("Wayland text"); + mocks.clipboard.getText.mockResolvedValue("stale X11 text"); + + await expect(readClipboardText()).resolves.toBe("Wayland text"); + expect(mockedExecFileSync).toHaveBeenCalledWith("wl-paste", ["--no-newline", "--type", "text"], { + encoding: "utf8", + maxBuffer: 50 * 1024 * 1024, + timeout: 5000, + }); + expect(mocks.clipboard.getText).not.toHaveBeenCalled(); + }); + + test("does not fall back to stale X11 text when the Wayland clipboard is empty", async () => { + mockedPlatform.mockReturnValue("linux"); + mocks.isWaylandSession.mockReturnValue(true); + vi.stubEnv("WAYLAND_DISPLAY", "wayland-0"); + mockedExecFileSync.mockReturnValue(""); + mocks.clipboard.getText.mockResolvedValue("stale X11 text"); + + await expect(readClipboardText()).resolves.toBeNull(); + expect(mocks.clipboard.getText).not.toHaveBeenCalled(); + }); + + test("falls back to the native clipboard when wl-paste is unavailable", async () => { + mockedPlatform.mockReturnValue("linux"); + mocks.isWaylandSession.mockReturnValue(true); + vi.stubEnv("WAYLAND_DISPLAY", "wayland-0"); + mockedExecFileSync.mockImplementation(() => { + throw new Error("wl-paste unavailable"); + }); + mocks.clipboard.getText.mockResolvedValue("X11 fallback text"); + + await expect(readClipboardText()).resolves.toBe("X11 fallback text"); + }); + test("returns null for empty or unavailable clipboard text", async () => { await expect(readClipboardText()).resolves.toBeNull();