From 014b0fb23ec480e158ab36c584bd346b76eef784 Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 14 Jan 2026 03:32:06 +0100 Subject: [PATCH] fix(coding-agent): prevent clipboard image paste from getting stuck on Wayland --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/utils/clipboard.ts | 79 ++++++++++++-------- 2 files changed, 50 insertions(+), 33 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7781b4f31..4c4c68ee3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed clipboard image paste getting stuck on Wayland when no image is present (was falling back to X11 and timing out) + ## [5.1.0] - 2026-01-14 ### Changed diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 7f59e4d6d..a20aed2a8 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -151,19 +151,29 @@ export async function readImageFromClipboard(): Promise { return null; } +type ClipboardReadResult = + | { status: "found"; image: ClipboardImage } + | { status: "empty" } // Tools ran successfully, no image in clipboard + | { status: "unavailable" }; // Tools not found or failed to run + async function readImageLinux(timeout: number): Promise { const wayland = isWaylandSession(); if (wayland) { - const image = await readImageWayland(timeout); - if (image) return image; + const result = await readImageWayland(timeout); + if (result.status === "found") return result.image; + if (result.status === "empty") return null; // Don't fall back to X11 if Wayland worked } - return await readImageX11(timeout); + const result = await readImageX11(timeout); + return result.status === "found" ? result.image : null; } -async function readImageWayland(timeout: number): Promise { - const types = await spawnAndRead(["wl-paste", "--list-types"], timeout); - if (!types) return null; +async function readImageWayland(timeout: number): Promise { + const wlPastePath = Bun.which("wl-paste"); + if (!wlPastePath) return { status: "unavailable" }; + + const types = await spawnAndRead([wlPastePath, "--list-types"], timeout); + if (!types) return { status: "unavailable" }; // Command failed const typeList = types .toString("utf-8") @@ -172,43 +182,46 @@ async function readImageWayland(timeout: number): Promise .filter(Boolean); const selectedType = selectPreferredImageMimeType(typeList); - if (!selectedType) return null; + if (!selectedType) return { status: "empty" }; // No image types available - const imageData = await spawnAndRead(["wl-paste", "--type", selectedType, "--no-newline"], timeout); - if (!imageData || imageData.length === 0) return null; + const imageData = await spawnAndRead([wlPastePath, "--type", selectedType, "--no-newline"], timeout); + if (!imageData || imageData.length === 0) return { status: "empty" }; return { - data: imageData.toString("base64"), - mimeType: baseMimeType(selectedType), + status: "found", + image: { + data: imageData.toString("base64"), + mimeType: baseMimeType(selectedType), + }, }; } -async function readImageX11(timeout: number): Promise { - const targets = await spawnAndRead(["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"], timeout); +async function readImageX11(timeout: number): Promise { + const xclipPath = Bun.which("xclip"); + if (!xclipPath) return { status: "unavailable" }; - let candidateTypes: string[] = []; - if (targets) { - candidateTypes = targets - .toString("utf-8") - .split(/\r?\n/) - .map((t) => t.trim()) - .filter(Boolean); - } + const targets = await spawnAndRead([xclipPath, "-selection", "clipboard", "-t", "TARGETS", "-o"], timeout); + if (!targets) return { status: "unavailable" }; // xclip failed (no X server?) - const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : null; - const tryTypes = preferred ? [preferred, ...PREFERRED_IMAGE_MIME_TYPES] : [...PREFERRED_IMAGE_MIME_TYPES]; + const candidateTypes = targets + .toString("utf-8") + .split(/\r?\n/) + .map((t) => t.trim()) + .filter(Boolean); - for (const mimeType of tryTypes) { - const imageData = await spawnAndRead(["xclip", "-selection", "clipboard", "-t", mimeType, "-o"], timeout); - if (imageData && imageData.length > 0) { - return { - data: imageData.toString("base64"), - mimeType: baseMimeType(mimeType), - }; - } - } + const selectedType = selectPreferredImageMimeType(candidateTypes); + if (!selectedType) return { status: "empty" }; // Clipboard has no image types - return null; + const imageData = await spawnAndRead([xclipPath, "-selection", "clipboard", "-t", selectedType, "-o"], timeout); + if (!imageData || imageData.length === 0) return { status: "empty" }; + + return { + status: "found", + image: { + data: imageData.toString("base64"), + mimeType: baseMimeType(selectedType), + }, + }; } async function readImageMacOS(timeout: number): Promise {