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/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index f5d4835af08d..a14826351e83 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -4,8 +4,35 @@ 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()) + +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" + 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 +70,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() @@ -56,9 +84,13 @@ 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 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() + 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,8 +98,13 @@ 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) { + 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 } } } @@ -84,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 } } } @@ -112,8 +154,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(() => {}) 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 */