diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ff17b6142..7686336a1 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Windows PowerShell image paste so Ctrl+V can fall back to the PowerShell clipboard bridge when the native clipboard reader reports no image ([#2429](https://github.com/can1357/oh-my-pi/issues/2429)). + ## [15.12.3] - 2026-06-12 ### Fixed diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 3d9f6080d..a1a2afb6a 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -66,9 +66,10 @@ export async function copyToClipboard(text: string): Promise { } } -// PowerShell one-liner that emits the clipboard image as base64-encoded PNG on -// stdout, or nothing when the clipboard does not hold image data. Used as the -// WSL bridge — arboard cannot read the Windows clipboard through WSLg. +// PowerShell one-liner that emits the Windows clipboard image as base64-encoded +// PNG on stdout, or nothing when the clipboard does not hold image data. Used +// for native Windows fallback and WSL interop because arboard can miss host +// clipboard image payloads in those terminal paths. const POWERSHELL_IMAGE_SCRIPT = ` $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.Windows.Forms @@ -84,31 +85,36 @@ if ($img -ne $null) { const POWERSHELL_TIMEOUT_MS = 8000; /** - * Read a clipboard image through the Windows host's PowerShell. + * Read an image through the Windows host's PowerShell. * - * WSLg exposes a Wayland socket but no native clipboard image transport, so - * `arboard` returns `ContentNotAvailable`. PowerShell, reached via WSL interop, - * can read the Windows clipboard directly and round-trip the bitmap as PNG. + * Native Windows uses this as a fallback when arboard reports no image or + * cannot access the clipboard. WSLg exposes a Wayland socket but no native + * clipboard image transport, so arboard returns `ContentNotAvailable` there; + * PowerShell, reached via WSL interop, can read the Windows clipboard directly + * and round-trip the bitmap as PNG. * * Returns null when no image is on the clipboard, the host PowerShell is * missing, or the bridge times out. */ async function readImageViaPowerShell(): Promise { try { - const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_IMAGE_SCRIPT], { - stdout: "pipe", - stderr: "ignore", - stdin: "ignore", - }); + const proc = Bun.spawn( + ["powershell.exe", "-NoProfile", "-NonInteractive", "-Sta", "-Command", POWERSHELL_IMAGE_SCRIPT], + { + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + }, + ); const timer = setTimeout(() => proc.kill(), POWERSHELL_TIMEOUT_MS); let stdout = ""; try { stdout = await new Response(proc.stdout).text(); await proc.exited; } catch (err) { - // powershell.exe is a Windows process reached over WSL interop; if it - // doesn't reap cleanly, swallow the error so the dispatcher can fall - // through to the native bridge instead of throwing. + // powershell.exe can be a Windows process reached either natively or + // over WSL interop; if it doesn't reap cleanly, report no image instead + // of surfacing an opaque bridge failure to the prompt. logger.warn("clipboard: powershell read failed", { error: String(err) }); return null; } finally { @@ -179,9 +185,10 @@ async function readTextViaPowerShell(): Promise { * Read an image from the system clipboard. * * Returns null on Termux (no image clipboard support) or when no display - * server is available (headless/SSH without forwarding). Under WSL the - * Windows clipboard is reached through `powershell.exe`, since WSLg's - * Wayland clipboard does not carry image payloads through to `arboard`. + * server is available (headless/SSH without forwarding). Under native Windows + * and WSL, the Windows clipboard is also reached through `powershell.exe` + * because terminal clipboard paths can leave image payloads invisible to the + * native bridge. * * @returns PNG payload or null when no image is available. */ @@ -198,6 +205,16 @@ export async function readImageFromClipboard(): Promise { // no display, so arboard would reject anyway. } + if (process.platform === "win32") { + try { + const image = await native.readImageFromClipboard(); + if (image) return image; + } catch (err) { + logger.warn("clipboard: native Windows image read failed", { error: String(err) }); + } + return await readImageViaPowerShell(); + } + if (!hasDisplay()) { return null; } diff --git a/packages/coding-agent/test/utils/clipboard.test.ts b/packages/coding-agent/test/utils/clipboard.test.ts index 80e22ae3d..e0cfca5f8 100644 --- a/packages/coding-agent/test/utils/clipboard.test.ts +++ b/packages/coding-agent/test/utils/clipboard.test.ts @@ -147,6 +147,21 @@ describe("readImageFromClipboard dispatch", () => { expect(nativeSpy).not.toHaveBeenCalled(); }); + it("uses the PowerShell bridge on native Windows when arboard has no image payload", async () => { + setPlatform("win32"); + const calls: SpawnCall[] = []; + spyPowershell(calls, RED_1X1_PNG_BASE64); + vi.spyOn(native, "readImageFromClipboard").mockResolvedValue(null); + + const image = await readImageFromClipboard(); + + expect(calls).toHaveLength(1); + expect(calls[0]?.cmd[0]).toBe("powershell.exe"); + expect(image?.mimeType).toBe("image/png"); + expect(Array.from(image!.data.subarray(0, 8))).toEqual([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + expect(calls[0]?.cmd).toContain("-Sta"); + }); + it("delegates straight to the native bridge on non-WSL linux with a display", async () => { setPlatform("linux"); process.env.DISPLAY = ":0";