fix(coding-agent): read clipboard text on Wayland

Prefer wl-paste on Wayland while preserving the native clipboard fallback when the command is unavailable.

Fixes #7248

Co-authored-by: Ston Jia <ston.jia@outlook.com>
This commit is contained in:
Christian Klotz 2026-07-31 15:43:16 +03:00
parent 73b24639fd
commit bfc679d5e4
No known key found for this signature in database
2 changed files with 71 additions and 3 deletions

View file

@ -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<string | null> {
if (platform() === "linux" && isWaylandSession() && process.env.WAYLAND_DISPLAY) {
const result = readWaylandClipboardText();
if (result.ok) {
return result.text;
}
}
if (!clipboard) {
return null;
}

View file

@ -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<string>>(),
setText: vi.fn<(text: string) => Promise<void>>(),
},
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();