fix(coding-agent): restored windows image paste fallback

Fell back to the PowerShell clipboard bridge when the native Windows clipboard reader reports no image, preserving Ctrl+V image paste in PowerShell terminals.\n\nFixes #2429
This commit is contained in:
roboomp
2026-06-12 21:52:59 +00:00
parent db421bb2ef
commit d718d54a33
3 changed files with 54 additions and 18 deletions
+4
View File
@@ -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
+35 -18
View File
@@ -66,9 +66,10 @@ export async function copyToClipboard(text: string): Promise<void> {
}
}
// 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<ClipboardImage | null> {
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<string | null> {
* 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<ClipboardImage | null> {
// 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;
}
@@ -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";