Skip to content
Open
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
56 changes: 34 additions & 22 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -793,7 +793,13 @@ export function Prompt(props: PromptProps) {
})
return
}
// If no image, let the default paste behavior continue
// Handle text paste - Windows/WSL2 don't trigger onPaste via bracketed paste
if (content?.mime === "text/plain" && content.data) {
e.preventDefault()
const normalized = content.data.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
input.insertText(normalized)
return
}
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
input.clear()
Expand Down Expand Up @@ -875,28 +881,34 @@ export function Prompt(props: PromptProps) {
if (!isUrl) {
try {
const file = Bun.file(filepath)
// Handle SVG as raw text content, not as base64 image
if (file.type === "image/svg+xml") {
event.preventDefault()
const content = await file.text().catch(() => {})
if (content) {
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
return
// IMPORTANT: Check if file exists before treating pasted text as a file path
// Bun.file().type infers MIME from extension even for non-existent files,
// which would incorrectly prevent normal text paste for strings like "image.png"
const fileExists = await file.exists()
if (fileExists) {
// Handle SVG as raw text content, not as base64 image
if (file.type === "image/svg+xml") {
const content = await file.text().catch(() => {})
if (content) {
event.preventDefault()
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
return
}
}
}
if (file.type.startsWith("image/")) {
event.preventDefault()
const content = await file
.arrayBuffer()
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteImage({
filename: file.name,
mime: file.type,
content,
})
return
if (file.type.startsWith("image/")) {
const content = await file
.arrayBuffer()
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
event.preventDefault()
await pasteImage({
filename: file.name,
mime: file.type,
content,
})
return
}
}
}
} catch {}
Expand Down
52 changes: 45 additions & 7 deletions packages/opencode/src/cli/cmd/tui/util/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,53 @@ export namespace Clipboard {
}

if (os === "win32" || release().includes("WSL")) {
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()
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
// Helper: encode PowerShell script as base64 UTF-16LE for -EncodedCommand
// This avoids ALL quoting/escaping issues with -command "..." which breaks
// when clipboard content ends with backslash sequences (e.g., "c:\path\file.png")
const encodePS = (script: string) => Buffer.from(script, "utf16le").toString("base64")

// Try to get image from Windows clipboard via PowerShell
const imgScript = `
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())
}
`.trim()
const imgEncoded = encodePS(imgScript)
const imgOut = (await $`powershell.exe -NonInteractive -NoProfile -EncodedCommand ${imgEncoded}`.nothrow().text()).trim()
if (imgOut) {
try {
const buf = Buffer.from(imgOut, "base64")
// Validate PNG magic bytes to prevent garbage PowerShell output from being treated as image
const isPng = buf.length >= 8 &&
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
if (isPng) {
return { data: buf.toString("base64"), mime: "image/png" }
}
} catch {
// Invalid base64, fall through to text
}
}

// Get TEXT from Windows clipboard via PowerShell
// CRITICAL: On WSL2, clipboardy uses Linux clipboard tools (xclip/wl-paste) which
// can't access Windows clipboard. We MUST use PowerShell to read Windows clipboard text.
// Using -EncodedCommand to avoid quoting issues with trailing backslashes in clipboard content.
const textScript = `
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
try { Get-Clipboard -Raw } catch { "" }
`.trim()
const textEncoded = encodePS(textScript)
const text = (await $`powershell.exe -NonInteractive -NoProfile -EncodedCommand ${textEncoded}`.nothrow().text())
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
if (text && text.trim()) {
return { data: text, mime: "text/plain" }
}
}

if (os === "linux") {
Expand Down