From fc95a07b669599a186b229c17d4a1292bd6fedfd Mon Sep 17 00:00:00 2001 From: 477174 <477174.dev@gmail.com> Date: Mon, 2 Mar 2026 13:17:04 -0300 Subject: [PATCH 1/3] fix(tui): fix clipboard image paste on WSL via WSLg Wayland bridge PowerShell processes spawned from WSL cannot access the Windows clipboard session (fundamental session isolation). The previous code tried PowerShell on WSL, which always failed silently. - Skip PowerShell clipboard read on WSL (dead code, ~500ms wasted) - Fix wl-paste/wl-copy on WSLg: override XDG_RUNTIME_DIR to /mnt/wslg/runtime-dir where the Wayland socket actually lives - Requires wl-clipboard package (apt install wl-clipboard) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../src/cli/cmd/tui/util/clipboard.ts | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index f5d4835af08d..70a48ad45083 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -4,8 +4,19 @@ import clipboardy from "clipboardy" import { lazy } from "../../../../util/lazy.js" import { tmpdir } from "os" import path from "path" +import { existsSync } from "fs" import { Filesystem } from "../../../../util/filesystem" +const isWSL = platform() === "linux" && /wsl|microsoft/i.test(release()) + +// WSLg Wayland socket lives at /mnt/wslg/runtime-dir, not XDG_RUNTIME_DIR +function wslgRuntimeDir(): string | undefined { + if (!isWSL) return undefined + const wslgDir = "/mnt/wslg/runtime-dir" + if (existsSync(wslgDir + "/wayland-0")) return wslgDir + return undefined +} + /** * Writes text to clipboard via OSC 52 escape sequence. * This allows clipboard operations to work over SSH by having @@ -43,7 +54,8 @@ export namespace Clipboard { } } - if (os === "win32" || release().includes("WSL")) { + // PowerShell clipboard: only on native Windows (on WSL, processes can't access the Windows clipboard session) + if (os === "win32") { const script = "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }" const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text() @@ -57,8 +69,11 @@ export namespace Clipboard { if (os === "linux") { const mimePriority = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp"] - if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-paste")) { - const types = await $`wl-paste --list-types`.nothrow().quiet().text() + const wlPasteAvailable = process.env["WAYLAND_DISPLAY"] && Bun.which("wl-paste") + if (wlPasteAvailable) { + const runtimeDir = wslgRuntimeDir() + const wlEnv = runtimeDir ? { ...process.env, XDG_RUNTIME_DIR: runtimeDir } : process.env + const types = await $`wl-paste --list-types`.env(wlEnv).nothrow().quiet().text() if (types) { const available = types .split("\n") @@ -66,7 +81,7 @@ export namespace Clipboard { .filter(Boolean) for (const mime of mimePriority) { if (available.includes(mime)) { - const data = await $`wl-paste -t ${mime}`.nothrow().quiet().arrayBuffer() + const data = await $`wl-paste -t ${mime}`.env(wlEnv).nothrow().quiet().arrayBuffer() if (data && data.byteLength > 0) { return { data: Buffer.from(data).toString("base64"), mime } } @@ -112,8 +127,10 @@ export namespace Clipboard { if (os === "linux") { if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) { console.log("clipboard: using wl-copy") + const runtimeDir = wslgRuntimeDir() + const env = runtimeDir ? { ...process.env, XDG_RUNTIME_DIR: runtimeDir } : undefined return async (text: string) => { - const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" }) + const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore", env }) proc.stdin.write(text) proc.stdin.end() await proc.exited.catch(() => {}) From f4682d238fa892e4bfaf4fa6abb73f914fc55e59 Mon Sep 17 00:00:00 2001 From: 477174 <477174.dev@gmail.com> Date: Mon, 2 Mar 2026 17:33:21 -0300 Subject: [PATCH 2/3] fix(tui): convert BMP clipboard images to PNG for API compatibility Windows clipboard stores screenshots as BMP, which LLM APIs reject. When the clipboard only offers image/bmp (common on WSL via WSLg), convert to PNG using ffmpeg, ImageMagick magick, or convert before returning. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../src/cli/cmd/tui/util/clipboard.ts | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 70a48ad45083..a14826351e83 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -9,7 +9,23 @@ import { Filesystem } from "../../../../util/filesystem" const isWSL = platform() === "linux" && /wsl|microsoft/i.test(release()) -// WSLg Wayland socket lives at /mnt/wslg/runtime-dir, not XDG_RUNTIME_DIR +async function convertBmpToPng(bmpData: Buffer): Promise { + const converter = Bun.which("ffmpeg") ? ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", "pipe:0", "-f", "image2pipe", "-c:v", "png", "pipe:1"] + : Bun.which("magick") ? ["magick", "bmp:-", "png:-"] + : Bun.which("convert") ? ["convert", "bmp:-", "png:-"] + : undefined + if (!converter) return undefined + try { + const proc = Bun.spawn(converter, { stdin: "pipe", stdout: "pipe", stderr: "ignore" }) + proc.stdin.write(bmpData) + proc.stdin.end() + const output = await new Response(proc.stdout).arrayBuffer() + await proc.exited + if (output.byteLength > 0) return Buffer.from(output) + } catch {} + return undefined +} + function wslgRuntimeDir(): string | undefined { if (!isWSL) return undefined const wslgDir = "/mnt/wslg/runtime-dir" @@ -68,7 +84,8 @@ export namespace Clipboard { } if (os === "linux") { - const mimePriority = ["image/png", "image/jpeg", "image/webp", "image/gif", "image/bmp"] + const apiMimes = ["image/png", "image/jpeg", "image/webp", "image/gif"] + const mimePriority = [...apiMimes, "image/bmp"] const wlPasteAvailable = process.env["WAYLAND_DISPLAY"] && Bun.which("wl-paste") if (wlPasteAvailable) { const runtimeDir = wslgRuntimeDir() @@ -83,6 +100,11 @@ export namespace Clipboard { if (available.includes(mime)) { const data = await $`wl-paste -t ${mime}`.env(wlEnv).nothrow().quiet().arrayBuffer() if (data && data.byteLength > 0) { + if (mime === "image/bmp") { + const converted = await convertBmpToPng(Buffer.from(data)) + if (converted) return { data: converted.toString("base64"), mime: "image/png" } + continue + } return { data: Buffer.from(data).toString("base64"), mime } } } @@ -99,6 +121,11 @@ export namespace Clipboard { if (available.includes(mime)) { const data = await $`xclip -selection clipboard -t ${mime} -o`.nothrow().quiet().arrayBuffer() if (data && data.byteLength > 0) { + if (mime === "image/bmp") { + const converted = await convertBmpToPng(Buffer.from(data)) + if (converted) return { data: converted.toString("base64"), mime: "image/png" } + continue + } return { data: Buffer.from(data).toString("base64"), mime } } } From d96daad0b0d527d6cac6f23473f9b966b504ed17 Mon Sep 17 00:00:00 2001 From: 477174 <477174.dev@gmail.com> Date: Mon, 2 Mar 2026 17:33:32 -0300 Subject: [PATCH 3/3] feat(tui): add Ctrl+Alt+V keybind and /image command for clipboard image paste Windows Terminal intercepts Ctrl+V and doesn't pass it to terminal apps when the clipboard contains only an image. Add alternative mechanisms that bypass WT: - input_paste_image keybind (Ctrl+Alt+V) confirmed to pass through WT - /image slash command (aliases: /paste-image) - 'Paste image from clipboard' in command palette (Ctrl+P) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../cli/cmd/tui/component/prompt/index.tsx | 25 ++++++++++++++++++- packages/opencode/src/config/config.ts | 5 ++++ packages/sdk/js/src/gen/types.gen.ts | 4 +++ packages/sdk/js/src/v2/gen/types.gen.ts | 4 +++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 5c32d2a4403a..a5d38c19896c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -247,6 +247,30 @@ export function Prompt(props: PromptProps) { } }, }, + { + title: "Paste image from clipboard", + description: "Read image data directly from clipboard (use when Ctrl+V doesn't work)", + value: "prompt.paste_image", + keybind: "input_paste_image", + category: "Prompt", + slash: { + name: "image", + aliases: ["paste-image"], + }, + onSelect: async () => { + const content = await Clipboard.read() + if (!content) return + if (content.mime.startsWith("image/")) { + await pasteImage({ + filename: "clipboard", + mime: content.mime, + content: content.data, + }) + } else if (content.mime === "text/plain" && content.data) { + input.insertText(content.data) + } + }, + }, { title: "Interrupt session", value: "session.interrupt", @@ -943,7 +967,6 @@ export function Prompt(props: PromptProps) { }) return } - // If no image, let the default paste behavior continue } if (keybind.match("input_clear", e) && store.prompt.input !== "") { input.clear() diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 21fa1897c45b..0be1363658cb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -835,6 +835,11 @@ export namespace Config { variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), + input_paste_image: z + .string() + .optional() + .default("ctrl+alt+v") + .describe("Paste image from clipboard (useful on WSL where Ctrl+V may be intercepted)"), input_submit: z.string().optional().default("return").describe("Submit input"), input_newline: z .string() diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 8eefe5bfe985..144437031065 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -938,6 +938,10 @@ export type KeybindsConfig = { * Paste from clipboard */ input_paste?: string + /** + * Paste image from clipboard (useful on WSL where Ctrl+V may be intercepted) + */ + input_paste_image?: string /** * Submit input */ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 026e07162818..85eb6f020cc0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1236,6 +1236,10 @@ export type KeybindsConfig = { * Paste from clipboard */ input_paste?: string + /** + * Paste image from clipboard (useful on WSL where Ctrl+V may be intercepted) + */ + input_paste_image?: string /** * Submit input */