Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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()
Expand Down
56 changes: 50 additions & 6 deletions packages/opencode/src/cli/cmd/tui/util/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Buffer | undefined> {
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
Expand Down Expand Up @@ -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()
Expand All @@ -56,18 +84,27 @@ 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")
.map((x) => x.trim())
.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 }
}
}
Expand All @@ -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 }
}
}
Expand Down Expand Up @@ -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(() => {})
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/js/src/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading