[BUG] Ctrl+V image paste silently fails on WSL+WSLg (BI_BITFIELDS BMP decode)

Resolved 💬 2 comments Opened Apr 18, 2026 by cyberguard Closed May 26, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Claude Code: Silent failure on image paste (Ctrl+V) under WSL + WSLg

Product: Claude Code CLI
Version confirmed: 2.1.114 (likely affects every version using the JNH / y_K clipboard pipeline)
Severity: High — core feature (image paste) is 100% broken for a large user class, with a misleading error message and no diagnostic output.
Report prepared: 2026-04-18

---

1. Executive summary

On Windows + WSL2 + WSLg, pressing Ctrl+V in the Claude Code REPL after copying any screenshot to the Windows clipboard reliably produces the toast:

No image found in clipboard. Use ctrl+v to paste images.

The clipboard does contain an image. Every command in Claude's pipeline up to the last step succeeds. The real failure is a silently-swallowed exception in the post-save image-processing step, triggered by a BMP compression variant (BI_BITFIELDS) that Claude's bundled image processor cannot decode.

The root cause is not in one place — it's the interaction of three separate bugs/design choices:

  1. WSLg clipboard bridge — drops the image/png MIME type and re-exports Windows CF_DIB/CF_BITMAP as image/bmp with BITMAPINFOHEADER.biCompression == BI_BITFIELDS (3).
  2. Claude Code saveImage fallback chain — tries image/png first but falls through to image/bmp on WSLg, so the image-on-disk is always a BI_BITFIELDS BMP.
  3. Claude Code JNH post-save pipeline — attempts to convert BMP→PNG via its bundled sharp/libvips; this decode fails on BI_BITFIELDS BMPs; the catch { return null; } swallows the error, and the UI reports "No image found in clipboard" — a message that has nothing to do with the real failure.

User impact: Every user who runs Claude Code in WSL and screenshots with any Windows screenshot tool (ShareX, Snipping Tool, Snip & Sketch, PrtSc) cannot paste images. It appears broken at the very first interaction.

Fix complexity (Claude side): Small. Two independent, cheap changes would resolve it. See §10.

---

2. Test environment

| Component | Value |
| --- | --- |
| Host OS | Windows 11, build 10.0.26200.8117 |
| WSL | 2.6.3.0 |
| WSL kernel | 6.6.87.2-1 (6.6.87.2-microsoft-standard-WSL2) |
| WSLg | 1.0.71 |
| MSRDC | 1.2.6353 |
| Distro | Ubuntu 22.04.5 LTS (Jammy), x86_64 |
| Terminal | Windows Terminal (reproduces); MobaXterm 26.3 with its own X server (also reproduces) |
| Claude Code | 2.1.114/home/<user>/.local/share/claude/versions/2.1.114, ELF x86_64, 236 411 520 bytes, BuildID[sha1]=052ef6d8cef1bef39149a31808f3d579db450889 |
| Node (for context) | 20.11.0 (not used at runtime; Claude Code is a Bun-compiled single binary) |
| wl-clipboard | 2.0.0-1 (/usr/bin/wl-paste) |
| xclip | system default (/usr/bin/xclip) |
| libvips42 | 8.12.1-1build1 (system package, irrelevant — Claude bundles its own) |
| imagemagick | 8:6.9.11.60+dfsg-1.3ubuntu0.22.04.5 (used only by the workaround) |
| Screenshot tools reproduced | ShareX 17.x, Windows Snipping Tool, Snip & Sketch, PrtSc-to-clipboard |
| Shell | zsh (also repros in bash / dash) |
| WSLg env (normal) | WAYLAND_DISPLAY=wayland-0, XDG_RUNTIME_DIR=/mnt/wslg/runtime-dir, DISPLAY=:0, /mnt/wslg/runtime-dir/wayland-0 socket present, /tmp/.X11-unix/X0 present |

---

3. Reproduction

Minimal repro (assumes Claude Code already installed, wl-clipboard and xclip present):

  1. In Windows, open any screenshot tool (ShareX, Win+Shift+S, PrtSc).
  2. Capture any region to the clipboard.
  3. In Windows Terminal (or any WSL-launching terminal), run:

``bash
wsl -d Ubuntu-22.04
claude
``

  1. In the Claude prompt, press Ctrl+V.
  2. Observed: toast → No image found in clipboard. Use ctrl+v to paste images.
  3. Expected: [Pasted image #1] attachment placeholder.

The clipboard is verifiably holding an image the whole time. See §4.

---

4. What actually happens — step by step

All commands below were run against a live clipboard containing a Windows Snipping Tool screenshot.

4.1 Windows side — clipboard has PNG + Bitmap + DIB

powershell.exe -NoProfile -Command 'Add-Type -AssemblyName System.Windows.Forms; `
  $d = [System.Windows.Forms.Clipboard]::GetDataObject(); `
  if ($d.GetDataPresent("PNG"))                      { Write-Host "PNG" } `
  if ($d.GetDataPresent("Bitmap"))                   { Write-Host "Bitmap" } `
  if ($d.GetDataPresent("DeviceIndependentBitmap"))  { Write-Host "DIB" }'
# → PNG
# → Bitmap
# → DIB

Windows Snipping Tool places three representations on the clipboard: PNG, Bitmap, DeviceIndependentBitmap. ShareX (with default "Copy image to clipboard") places Bitmap + DIB (no PNG). Either way, at least a DIB exists.

4.2 WSLg side — only image/bmp is exposed, and it's BI_BITFIELDS

$ wl-paste -l
image/bmp

Not image/png. Not image/bmp;variant=…. Just image/bmp. WSLg drops the Windows CF_PNG handle and forwards only the DIB, re-wrapped as image/bmp.

$ wl-paste --type image/bmp > /tmp/raw.bmp
$ file /tmp/raw.bmp
/tmp/raw.bmp: PC bitmap, Windows 3.x format, 1920 x 48 x 32, 3 compression, image size 368640, cbSize 368706, bits offset 66

The 3 compression is the killer: BITMAPINFOHEADER.biCompression == BI_BITFIELDS (value 3). Windows DIBs with alpha use this. libvips' bmpload has historically had weak/broken support for BI_BITFIELDS, particularly 32-bit + alpha masks.

Requesting PNG from WSLg fails:

$ wl-paste --type image/png > /tmp/x.png; echo $?
1
$ file /tmp/x.png
/tmp/x.png: ASCII text        # wl-paste's error message, written to stdout by the redirect

4.3 Claude Code's pipeline runs — but silently fails at the last step

Claude's commands (extracted from y_K() in the 2.1.114 binary, Linux branch):

checkImage:
  xclip -selection clipboard -t TARGETS -o 2>/dev/null
    | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)"
  || wl-paste -l 2>/dev/null
    | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)"

saveImage:
  xclip -selection clipboard -t image/png -o > <path> 2>/dev/null
  || wl-paste --type image/png       > <path> 2>/dev/null
  || xclip -selection clipboard -t image/bmp -o > <path> 2>/dev/null
  || wl-paste --type image/bmp       > <path>

Running them verbatim against the same clipboard succeeds:

# checkImage
$ ( xclip -selection clipboard -t TARGETS -o 2>/dev/null \
    | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)" \
    || wl-paste -l 2>/dev/null \
    | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)" )
image/bmp
$ echo $?
0                                    # ✅ pass

# saveImage — falls all the way through to the last branch, writes a BMP
$ rm -f /tmp/claude_cli_latest_screenshot.png
$ /bin/sh -c 'xclip -selection clipboard -t image/png -o > /tmp/claude_cli_latest_screenshot.png 2>/dev/null \
           || wl-paste --type image/png       > /tmp/claude_cli_latest_screenshot.png 2>/dev/null \
           || xclip -selection clipboard -t image/bmp -o > /tmp/claude_cli_latest_screenshot.png 2>/dev/null \
           || wl-paste --type image/bmp       > /tmp/claude_cli_latest_screenshot.png'
$ echo $?
0                                    # ✅ pass
$ file /tmp/claude_cli_latest_screenshot.png
PC bitmap, Windows 3.x format, 1920 x 48 x 32, 3 compression, ...

Confirmed empirically that this runs inside Claude too: we deleted /tmp/claude_cli_latest_screenshot.png, asked the user to press Ctrl+V in the Claude REPL, and the file reappeared with a new size & mtime. So the spawn was executed, checkImage returned 0, saveImage returned 0, file was written. The failure is strictly after this.

4.4 The silent catch

Decompiled JNH (the post-save handler) from the binary:

async function JNH(H) {
  let { commands: $, screenshotPath: q } = y_K();
  try {
    if ((await Nj($.checkImage, { reject: !1 })).exitCode !== 0) return null;
    if ((await Nj($.saveImage,  { reject: !1 })).exitCode !== 0) return null;

    let f = k$().readFileBytesSync(q);

    // BMP magic ('B','M') → convert to PNG via bundled sharp
    if (f.length >= 2 && f[0] === 66 && f[1] === 77) {
      f = await (await tOH())(f).png().toBuffer();        // ← THROWS on BI_BITFIELDS BMP
    }

    let A = await $7H(f, f.length, "png", H),
        z = A.buffer.toString("base64"),
        Y = JsH(z);
    Nj($.deleteFile, { reject: !1 });
    return { base64: z, mediaType: Y, dimensions: A.dimensions };
  } catch {
    return null;                                          // ← swallowed, no log
  }
}
  • tOH() is the lazy-loaded image processor (bundled sharp / libvips).
  • The BMP on disk is BI_BITFIELDS. sharp(f).png().toBuffer() rejects (libvips cannot decode).
  • catch { return null; } consumes the error with no console.error, no N("…", { level: "error" }), no debug-log entry.
  • JNH returning null triggers the caller rHH:

``js
rHH = fq.useCallback(() => {
JNH(vz(HH)).then(T$ => {
if (T$) m8(T$.base64, T$.mediaType);
else {
const msg = J6.isSSH()
? "No image found in clipboard. You're SSH'd; try scp?"
:
No image found in clipboard. Use ${FD("chat:imagePaste","Chat","ctrl+v")} to paste images.;
L5({ key: "no-image-in-clipboard", text: msg, priority: "immediate", timeoutMs: 1000 });
}
});
}, [L5, m8, HH]);
``
→ user sees "No image found in clipboard" even though an image definitely was there, the checkImage + saveImage commands both succeeded, and the saved file exists on disk.

4.5 Verification that the decode is the failure

Python/PIL decodes the exact same file fine:

$ python3 -c 'from PIL import Image; i=Image.open("/tmp/claude_cli_latest_screenshot.png"); print(i.mode, i.size, i.format)'
RGB (1918, 912) BMP

ImageMagick decodes it fine:

$ convert /tmp/claude_cli_latest_screenshot.png /tmp/out.png && file /tmp/out.png
/tmp/out.png: PNG image data, 1918 x 912, 8-bit/color RGB, non-interlaced

sharp/libvips inside Claude does not. (Cannot be confirmed from outside the binary, but every other hypothesis has been ruled out — see §5.)

---

5. Hypotheses ruled out

| Hypothesis | Verdict | Evidence |
| --- | --- | --- |
| Ctrl+V swallowed by terminal | No | Windows Terminal default did bind Ctrl+V to paste; unbinding it ("command":"unbound","keys":"ctrl+v") is required but not sufficient — the bug persists after the unbind. |
| Wrong clipboard backend (xclip vs wl-paste) | No | checkImage tries both via \|\|; verbatim replay returns image/bmp exit 0. |
| Env not inherited by Claude spawn (WAYLAND_DISPLAY, XDG_RUNTIME_DIR) | No | Inspected /proc/<claude-pid>/environ; both set correctly. |
| Clipboard clobbered by mouse-click / terminal selection | No | Repro happens with Alt+Tab only; and after each failing Ctrl+V, wl-paste -l still returns image/bmp. The image is still there. |
| saveImage never runs | No | Deleting /tmp/claude_cli_latest_screenshot.png and pressing Ctrl+V in Claude re-creates the file with the correct current-screenshot bytes. |
| File read fails | No | File is on disk and readable (stat, file, xxd all work). |
| Native image processor not present | Possibly overlapping. tOH() can console.warn("Native image processor not available, falling back to sharp") and fall back. This warning was not observed, but it's a console.warn that may be discarded by Bun's stderr handling in TTY mode; we could not verify. |
| Size limits (maxBase64Size: 5242880, maxWidth/Height: 2000) | No | Tested with images under all limits; same failure. |
| ShareX-specific | No | Reproduces with Snipping Tool, Snip & Sketch, PrtSc. |

---

6. Why every surface hint is misleading

For a user, the failure mode is maximally confusing:

  • The error says "No image found in clipboard" — false. The image is there.
  • The error suggests "Use ctrl+v to paste images" — exactly what the user just did.
  • The error mentions SSH ("You're SSH'd; try scp?") when relevant — absent on WSL, so the user gets the bare version and has nowhere to go.
  • --debug --debug-file produces logs that contain zero traces of the paste pipeline, because Nj doesn't log exec (only Cr does) and the catch is silent.
  • The screenshot file Claude just wrote to /tmp/claude_cli_latest_screenshot.png is left on disk (the deleteFile spawn is inside the try, after the step that threw, so it never runs).

A motivated user can only find the truth by disassembling the binary. We did.

---

7. Root cause — the three interacting bugs

7.1 WSLg bug (Microsoft — not Anthropic)

WSLg's RDP-based clipboard bridge translates Windows DIB data into image/bmp wl-clipboard MIME type, in BI_BITFIELDS form, and does not forward CF_PNG even when it exists on the Windows side. This is upstream, not Anthropic's responsibility — but it is the daily reality for every WSL user.

Anthropic can't fix this. Anthropic can work around it.

7.2 Claude Code bug #1 — sharp/libvips BMP decode (symptom)

Whatever image processor tOH() returns cannot decode BITMAPINFOHEADER with biCompression == 3 and 32-bit bits-per-pixel. Neither bundled sharp versions of libvips nor libvips 8.x in general have reliable support for BI_BITFIELDS BMPs.

Fixes:

  • Upgrade bundled sharp / libvips to a version that handles BI_BITFIELDS BMPs, or
  • Add a pre-conversion step: detect BM magic + biCompression == 3, bypass sharp, decode via the Node built-in Image / re-encode via a pure-JS BMP decoder, or
  • Skip BMP entirely on WSL — see §7.3.

7.3 Claude Code bug #2 — saveImage fallback order on WSL (underlying issue)

The current Linux saveImage chain treats BMP as an acceptable last resort. On WSLg, the "last resort" is the only thing available, so every paste goes through the BMP path. The BMP path feeds sharp, which fails.

If saveImage on WSL asked for the BMP and then re-encoded to PNG via a reliable decoder before handing off to the rest of the pipeline, the bug would be invisible. A one-line shell change would fix this: pipe the BMP through convert or magick or vipsthumbnail before writing to disk. (This is exactly what the user-side workaround in §8 does.)

Ideally, on E8() === "wsl" specifically, Claude would also attempt a PowerShell path (Get-Clipboard -Format Image; $img.Save(..., Png)) in parallel or as a priority, because PowerShell's System.Windows.Forms.Clipboard.GetImage returns a managed System.Drawing.Bitmap that can be saved directly to PNG, bypassing the WSLg BMP translation entirely. The binary's win32 branch of y_K already contains exactly this command — it's just dead code on WSL because E8() returns "wsl", not "windows".

// y_K()  — extracted
let f = {
  darwin: { ... },
  linux:  { checkImage: 'xclip ... || wl-paste ...', saveImage: 'xclip ... || wl-paste ...', ... },
  win32:  { checkImage: 'powershell -NoProfile -Command "(Get-Clipboard -Format Image) -ne $null"',
            saveImage:  'powershell -NoProfile -Command "$img = Get-Clipboard -Format Image; if ($img) { $img.Save(\'${_}\', [System.Drawing.Imaging.ImageFormat]::Png) }"', ... }
};
return { commands: f.linux || f.linux, screenshotPath: _ };   // ← the win32 branch is never used

Note return { commands: f.linux || f.linux } — the expression is literally f.linux || f.linux, a typo (or minifier oddity) that excludes f.win32 and excludes a WSL-specific branch that doesn't exist yet. We verified PowerShell works perfectly from WSL:

$ powershell.exe -NoProfile -Command "\$img = Get-Clipboard -Format Image; if (\$img) { \$img.Save('/tmp/ps-test.png', [System.Drawing.Imaging.ImageFormat]::Png); 'saved' }"
saved
$ file /tmp/ps-test.png
/tmp/ps-test.png: PNG image data, ...

7.4 Claude Code bug #3 — silent catch (ergonomic)

catch { return null; } with zero telemetry is the reason this bug existed in the wild for users without the means to disassemble. A single line — catch (e) { YH(e); return null; } — using the existing error-logging helper YH / N(..., { level: "error" }), would have pointed any debug-flag user directly at sharp.

---

8. User-side workaround (verified working)

Since sharp is only invoked when the saved file starts with BM, the fix is to make sure it never does. We install a wl-paste wrapper that advertises image/png and converts on demand.

Prerequisites

sudo apt-get install -y wl-clipboard imagemagick

Wrapper

File: ~/.local/bin-prepend/wl-paste (mode 0755)

#!/bin/bash
# Wrapper: expose image/png when WSLg only publishes image/bmp.
# Passthrough for every other case.
REAL=/usr/bin/wl-paste

if [[ "$1" == "-l" || "$1" == "--list-types" ]]; then
    types=$("$REAL" -l 2>/dev/null); rc=$?
    if [[ $rc -eq 0 ]] && echo "$types" | grep -q '^image/bmp$' \
                       && ! echo "$types" | grep -q '^image/png$'; then
        printf '%s\n' "$types"
        printf 'image/png\n'
        exit 0
    fi
    printf '%s\n' "$types"; exit $rc
fi

if [[ "$1" == "--type" && "$2" == "image/png" ]]; then
    types=$("$REAL" -l 2>/dev/null)
    if echo "$types" | grep -q '^image/png$'; then exec "$REAL" "$@"; fi
    if echo "$types" | grep -q '^image/bmp$'; then
        "$REAL" --type image/bmp 2>/dev/null | /usr/bin/convert bmp:- png:-
        exit $?
    fi
    exit 1
fi

exec "$REAL" "$@"

PATH

echo 'export PATH="$HOME/.local/bin-prepend:$PATH"' >> ~/.zshrc   # or ~/.bashrc

Result

  • checkImage sees both image/bmp and image/png → grep matches image/png → exit 0.
  • saveImage — the first real branch (wl-paste --type image/png) now succeeds. File magic is PNG (89 50) not BMP (42 4D). Claude's if (f[0] === 66 && f[1] === 77) branch is skipped; sharp is never called.
  • $7H / resize / base64 proceed on the PNG directly → [Pasted image #1].

Verified empirically end-to-end by the user with both ShareX and Windows Snipping Tool.

---

9. Additional field notes

  • E8() returns "wsl" in the WSL case — the binary has a separate "wsl" platform constant used elsewhere (pK1(), oZK(), au$()), but the clipboard path only branches on linux / win32. Adding a wsl case is therefore consistent with the rest of the codebase.
  • Yi4 (the keybinding value for chat:imagePaste) is "alt+v" on Windows, "ctrl+v" everywhere else — confirmed correct on WSL.
  • Windows Terminal default Ctrl+V binding intercepts the keystroke before Claude sees it. This must be unbound ({ "command": "unbound", "keys": "ctrl+v" }) for Ctrl+V paste to reach Claude. This is a separate, trivial obstacle from the main bug, but it confuses users who give up before disassembling. MobaXterm doesn't have this issue (it uses Shift+Insert).
  • --debug does not help. The Nj spawn helper does not log exec commands (only Cr does), and the catch in JNH is silent. A debug flag --debug clipboard that adds exec logging + the catch-error log would have let us diagnose this in 30 seconds instead of several hours.
  • The orphan file. After every failing Ctrl+V on WSL, /tmp/claude_cli_latest_screenshot.png is left on disk. deleteFile is inside the try after the step that threw. Minor but real.

---

10. Suggested fix (Anthropic side)

Three independent, small changes. Any one of them individually would resolve the outward bug; all three together would produce a correct, diagnosable implementation.

10.1 Fix the platform branch and add a WSL case to y_K()

Pseudocode (adapt to the actual minified build):

function y_K() {
  const home = HY();
  const screenshotPath = bc.join(home, "claude_cli_latest_screenshot.png");
  const f = {
    darwin: { ... },
    linux:  { ... },
    wsl:    {
      // Prefer PowerShell — bypasses WSLg's lossy BMP translation entirely
      checkImage: 'powershell.exe -NoProfile -Command "(Get-Clipboard -Format Image) -ne $null"',
      saveImage:  `powershell.exe -NoProfile -Command "$img = Get-Clipboard -Format Image; if ($img) { $img.Save('${wslpath(screenshotPath)}', [System.Drawing.Imaging.ImageFormat]::Png) }"`,
      getPath:    'powershell.exe -NoProfile -Command "Get-Clipboard"',
      deleteFile: `rm -f "${screenshotPath}"`,
    },
    win32:  { ... },
  };
  const platform = E8();                       // returns "wsl" on WSL
  return { commands: f[platform] ?? f.linux, screenshotPath };
}

Also: fix the typo f.linux || f.linux. (Either a copy-paste mistake or a minifier artifact, but it masks any platform-specific routing you might add later.)

10.2 Harden the BMP decode path

If falling back to BMP is kept, replace

f = await (await tOH())(f).png().toBuffer();

with a decoder that handles BI_BITFIELDS. Options, roughly ranked:

  1. Upgrade bundled libvips to a version that supports all BMP variants.
  2. Add a pure-JS BMP fallback decoder for BI_BITFIELDS + BI_ALPHABITFIELDS when sharp throws.
  3. Shell out to convert / magick / vipsthumbnail if sharp decode throws.

10.3 Stop swallowing the error

} catch (e) {
  YH(e);                                       // logs to debug file
  N(`JNH failed: ${xH(e)}`, { level: "error" });
  return null;
}

And clean up the orphan file: move the Nj($.deleteFile, ...) out of the try block into a finally.

Bonus: the toast text. When the saved file exists but JNH returned null, the user is being lied to. Differentiate:

const msg =
  saveFileExists
    ? "Clipboard image could not be decoded. This may be a known WSLg BMP issue; please report with --debug-file output."
    : "No image found in clipboard. Use ctrl+v to paste images.";

---

11. Suggested regression test

# Fixture: a BI_BITFIELDS 32bpp BMP such as the WSLg bridge produces.
printf '\x42\x4d...'   # or check in /tests/fixtures/wslg-bitfields.bmp

# Unit test: JNH should return a non-null result when given a BI_BITFIELDS BMP via saveImage.

The fixture is any 32-bit Windows screenshot copied through WSLg:

wl-paste --type image/bmp > tests/fixtures/wslg-bitfields.bmp

---

12. Contact for follow-up

We built a full empirical trace of this failure, including:

  • /proc/<pid>/environ dumps of the Claude process across every terminal + env combination,
  • verbatim replays of every command in y_K() with matching exit codes,
  • disassembled source of JNH, y_K, tOH, rHH, Yi4 from claude 2.1.114,
  • a working user-side wrapper (§8) that proves the fix in ~40 lines of shell.

Happy to share the raw debug logs, the full binary-extracted snippets, and the wrapper tarball.

---

Appendix A — literal y_K (decompiled from 2.1.114)

function y_K(){
  let $ = HY(), q = "claude_cli_latest_screenshot.png",
      K = {
        darwin: bc.join($, "claude_cli_latest_screenshot.png"),
        linux:  bc.join($, "claude_cli_latest_screenshot.png"),
        win32:  bc.join($, "claude_cli_latest_screenshot.png")
      },
      _ = K.linux || K.linux,
      f = {
        darwin: {
          checkImage: "osascript -e 'the clipboard as «class PNGf»'",
          saveImage:  `osascript -e 'set png_data to (the clipboard as «class PNGf»)' ...`,
          getPath:    "osascript -e 'get POSIX path of (the clipboard as «class furl»)'",
          deleteFile: `rm -f "${_}"`
        },
        linux: {
          checkImage: 'xclip -selection clipboard -t TARGETS -o 2>/dev/null | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)" || wl-paste -l 2>/dev/null | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)"',
          saveImage:  `xclip -selection clipboard -t image/png -o > "${_}" 2>/dev/null || wl-paste --type image/png > "${_}" 2>/dev/null || xclip -selection clipboard -t image/bmp -o > "${_}" 2>/dev/null || wl-paste --type image/bmp > "${_}"`,
          getPath:    "xclip -selection clipboard -t text/plain -o 2>/dev/null || wl-paste 2>/dev/null",
          deleteFile: `rm -f "${_}"`
        },
        win32: {
          checkImage: 'powershell -NoProfile -Command "(Get-Clipboard -Format Image) -ne $null"',
          saveImage:  `powershell -NoProfile -Command "$img = Get-Clipboard -Format Image; if ($img) { $img.Save('${_.replace(/\\/g,"\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png) }"`,
          getPath:    'powershell -NoProfile -Command "Get-Clipboard"',
          deleteFile: `del /f "${_}"`
        }
      };
  return { commands: f.linux || f.linux, screenshotPath: _ };
}

Appendix B — literal JNH (decompiled from 2.1.114)

async function JNH(H) {
  let { commands: $, screenshotPath: q } = y_K();
  try {
    if ((await Nj($.checkImage, { reject: !1 })).exitCode !== 0) return null;
    if ((await Nj($.saveImage,  { reject: !1 })).exitCode !== 0) return null;
    let f = k$().readFileBytesSync(q);
    if (f.length >= 2 && f[0] === 66 && f[1] === 77)
      f = await (await tOH())(f).png().toBuffer();
    let A = await $7H(f, f.length, "png", H),
        z = A.buffer.toString("base64"),
        Y = JsH(z);
    return Nj($.deleteFile, { reject: !1 }),
           { base64: z, mediaType: Y, dimensions: A.dimensions };
  } catch { return null; }
}

Appendix C — literal rHH (the caller that shows the toast)

rHH = fq.useCallback(() => {
  JNH(vz(HH)).then(T$ => {
    if (T$) m8(T$.base64, T$.mediaType);
    else {
      const H6 = FD("chat:imagePaste", "Chat", "ctrl+v");
      const _K = J6.isSSH()
        ? "No image found in clipboard. You're SSH'd; try scp?"
        : `No image found in clipboard. Use ${H6} to paste images.`;
      L5({ key: "no-image-in-clipboard", text: _K, priority: "immediate", timeoutMs: 1000 });
    }
  });
}, [L5, m8, HH]);

Appendix D — Nj (the spawn helper that does not log)

function Nj(H, $) { return br(H, { ...$, shell: !0 }); }    // wraps execa-style br; no exec-logging, inherits process.env

Appendix E — E8() return values

// E8() returns one of: "macos" | "linux" | "wsl" | "windows" | "unknown"
// Evidence: strings/decomp shows case "linux": case "wsl": ... pairs in pK1(), oZK(), aZK(), au$(),
// and comparisons like E8() !== "wsl" elsewhere in the binary.

Appendix F — Yi4 (keybinding)

Yi4 = E8() === "windows" ? "alt+v" : "ctrl+v";
// In bindings: { ..., [Yi4]: "chat:imagePaste", ... }
// On WSL, this evaluates to "ctrl+v", which is what the user presses.

What Should Happen?

Pressing Ctrl+V in the Claude Code prompt with an image on the Windows clipboard should attach the image (show "[Pasted image #1]"), just as it does on macOS and native Linux. Every
Windows screenshot tool (ShareX, Snipping Tool, Snip & Sketch, PrtSc) places image data on the clipboard in a form that WSLg exposes to WSL as image/bmp — this is the normal case on WSL
and should be the happy path, not a silent failure.

Concretely, on WSL the saveImage pipeline should either (a) use PowerShell's Get-Clipboard -Format Image (which correctly returns PNG and bypasses WSLg's lossy BMP translation entirely —
the win32 branch already exists in y_K() but is dead code on WSL), or (b) decode the BI_BITFIELDS BMP reliably before handing to sharp, or (c) at minimum log the swallowed catch error
and show a toast that reflects the real failure rather than "No image found in clipboard."

Error Messages/Logs

Visible UI message (misleading — the image IS on the clipboard):
    "No image found in clipboard. Use ctrl+v to paste images."

  Debug output (--debug --debug-file /tmp/claude-debug.log):
    No paste/clipboard/exec entries whatsoever when Ctrl+V is pressed.
    The Nj() spawn helper does not log execs (only Cr does), and the
    catch block in JNH silently returns null. Zero signal in the log.

  Filesystem evidence that the pipeline ran most of the way:
    $ rm -f /tmp/claude_cli_latest_screenshot.png
    # press Ctrl+V in Claude → toast "No image found in clipboard"
    $ ls -la /tmp/claude_cli_latest_screenshot.png
    -rw-r--r-- 1 user user 6996930 Apr 18 16:48 /tmp/claude_cli_latest_screenshot.png
    $ file /tmp/claude_cli_latest_screenshot.png
    PC bitmap, Windows 3.x format, 1918 x 912 x 32, 3 compression, ...
    #                                              ^^^^^^^^^^^^^
    # biCompression = 3 = BI_BITFIELDS — what sharp/libvips fails to decode.

  checkImage and saveImage exit codes, run verbatim:
    $ (xclip -selection clipboard -t TARGETS -o 2>/dev/null \
       | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)" \
       || wl-paste -l 2>/dev/null \
       | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)")
    image/bmp
    $ echo $?
    0                        # ✅ checkImage succeeds

    $ /bin/sh -c '...saveImage chain...'
    $ echo $?
    0                        # ✅ saveImage succeeds, BMP written to disk

Steps to Reproduce

Environment needed:

  • Windows 11 (or Win10 with WSLg), WSL2, any recent Linux distro (Ubuntu 22.04 tested)
  • Claude Code 2.1.114 installed in WSL
  • Windows Terminal
  • Any Windows screenshot tool (ShareX, Win+Shift+S / Snipping Tool, PrtSc)

Steps:

  1. From Windows Terminal, launch WSL:

wsl -d Ubuntu-22.04

  1. Unbind Ctrl+V in Windows Terminal first (otherwise it never reaches Claude):

Settings (Ctrl+,) → Actions → find "Paste" on "Ctrl+V" → delete that binding → Save.
(Use Ctrl+Shift+V for normal terminal paste afterwards.)

  1. Start Claude Code:

claude

  1. Take a screenshot on Windows that puts an image on the clipboard

(any of: ShareX region capture, Win+Shift+S, PrtSc, Snip & Sketch).

  1. Click back into the Claude Code prompt (or Alt+Tab) and press Ctrl+V.

Actual result:
Toast appears: "No image found in clipboard. Use ctrl+v to paste images."

Expected result:
"[Pasted image #1]" attachment placeholder appears in the prompt.

Proof the clipboard actually has an image at that moment (run in the same WSL shell):
wl-paste -l # → image/bmp
wl-paste --type image/bmp | file -
# → PC bitmap, Windows 3.x format,
# WxH x 32, 3 compression, ... ← BI_BITFIELDS

Proof Claude's own commands succeed — the failure is AFTER the save:
rm -f /tmp/claude_cli_latest_screenshot.png
# press Ctrl+V in Claude → "no image" toast
ls -la /tmp/claude_cli_latest_screenshot.png
# file exists with fresh mtime and correct BMP bytes — checkImage and
# saveImage both ran successfully. The failure is in the post-save
# BMP→PNG conversion inside JNH (sharp/libvips chokes on BI_BITFIELDS),
# and the catch block returns null silently.

Reproduction rate: 100% on every tested WSL+WSLg setup, with every tested
screenshot tool. First-run experience for any new WSL user.

Claude Model

None

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.1.114

Platform

Anthropic API

Operating System

Windows

Terminal/Shell

WSL (Windows Subsystem for Linux)

Additional Information

Root cause (verified by decompiling claude 2.1.114 binary):

Three interacting bugs:

  1. WSLg clipboard bridge (upstream, Microsoft) — drops Windows CF_PNG and

re-exports CF_DIB as image/bmp with biCompression=3 (BI_BITFIELDS).
Not Anthropic's to fix, but it is daily reality for every WSL user.

  1. Claude Code saveImage chain — tries image/png first, falls through to

image/bmp on WSL because WSLg doesn't expose PNG. The saved file is
always a BI_BITFIELDS BMP.

  1. Claude Code JNH() post-save pipeline — sharp/libvips (bundled) cannot

decode BI_BITFIELDS 32bpp BMPs. Throws. A bare catch block returns
null with zero telemetry. The caller rHH shows "No image found in
clipboard" — a toast unrelated to the actual failure.

Extracted from the binary:

y_K() contains a full win32 branch with PowerShell commands
(Get-Clipboard -Format Image → $img.Save(... Png)) that works
perfectly when invoked from WSL — but it's dead code. The return
statement is return { commands: f.linux || f.linux, screenshotPath: _ }
(note the typo f.linux || f.linux). E8() already returns "wsl" as
a distinct platform value used elsewhere in the binary
(pK1, oZK, aZK, au$) — only the clipboard path doesn't branch on it.

Suggested fixes (any one resolves the outward bug):

a) Add a "wsl" case to y_K() using the existing PowerShell commands.
b) Upgrade bundled libvips to a version handling BI_BITFIELDS, or
shell out to convert/magick on decode failure.
c) Replace catch { return null; } with
catch (e) { YH(e); N(..., { level: "error" }); return null; }
and move deleteFile into a finally block.
d) Differentiate toast: if the save file exists, say
"Clipboard image could not be decoded" instead of "no image found".

Verified user-side workaround:

A ~35-line wl-paste wrapper that exposes image/png by running
wl-paste --type image/bmp | convert bmp:- png:- on demand. With
this in PATH before /usr/bin, saveImage's first PNG branch succeeds,
the file written has PNG magic (89 50) not BMP (42 4D), the broken
BMP→sharp code path is skipped entirely, and Ctrl+V works with every
screenshot tool. Reproduces and fixes deterministically.

Debug flag worth adding:
--debug clipboard that logs every Nj() exec + the catch error in
JNH. Would have reduced diagnosis from several hours to 30 seconds.

Files I can share on request:

  • /tmp/claude-debug.log (2446 lines; confirms zero paste entries despite

the toast firing)

  • /proc/<pid>/environ dumps for Claude processes in every tested terminal

(MobaXterm vs Windows Terminal, DISPLAY set/unset, WAYLAND_DISPLAY
set/unset) — all inspected, env is not the bug

  • The full decompiled y_K, JNH, rHH, tOH, Nj, E8, Yi4 function bodies

extracted with python3 + regex from the 236 MB ELF binary at
/home/<user>/.local/share/claude/versions/2.1.114

  • The working wl-paste wrapper script (~35 lines, imagemagick dep)
  • A detailed 17 KB technical spec covering all of the above, prepared

for this bug report

No repo reproduces the issue because it's environmental — any new
WSL+WSLg+Windows-screenshot-tool setup reproduces 100% on first use.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗