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
82 changes: 37 additions & 45 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -778,10 +778,10 @@ export function Prompt(props: PromptProps) {
e.preventDefault()
return
}
// Handle clipboard paste (Ctrl+V) - check for images first on Windows
// This is needed because Windows terminal doesn't properly send image data
// through bracketed paste, so we need to intercept the keypress and
// directly read from clipboard before the terminal handles it
// Handle clipboard paste (Ctrl+V) - IMAGES ONLY
// Windows terminal doesn't send image data through bracketed paste,
// so we intercept Ctrl+V and read directly from clipboard for images.
// Text is handled by onPaste (which always calls preventDefault first).
if (keybind.match("input_paste", e)) {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
Expand All @@ -793,7 +793,7 @@ export function Prompt(props: PromptProps) {
})
return
}
// If no image, let the default paste behavior continue
// Text: let onPaste handle it (don't insert here)
}
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
input.clear()
Expand Down Expand Up @@ -853,70 +853,62 @@ export function Prompt(props: PromptProps) {
}}
onSubmit={submit}
onPaste={async (event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
}
// ALWAYS preventDefault FIRST to avoid double-paste on WSL2/ConPTY
event.preventDefault()
if (props.disabled) return

// Normalize line endings at the boundary
// Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
// Replace CRLF first, then any remaining CR
// Normalize line endings
const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
if (!pastedContent) {
command.trigger("prompt.paste")
return
}
if (!pastedContent) return

// trim ' from the beginning and end of the pasted content. just
// ' and nothing else
// Check if pasted content is a file path
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
const isUrl = /^(https?):\/\//.test(filepath)
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
const fileExists = await file.exists()
if (fileExists) {
// Handle SVG as raw text content
if (file.type === "image/svg+xml") {
const content = await file.text().catch(() => {})
if (content) {
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
// Handle images
if (file.type.startsWith("image/")) {
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
}
}
}
} catch {}
}

// Large paste: show summary
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if (
(lineCount >= 3 || pastedContent.length > 150) &&
!sync.data.config.experimental?.disable_paste_summary
) {
event.preventDefault()
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}

// Force layout update and render for the pasted content
setTimeout(() => {
input.getLayoutNode().markDirty()
renderer.requestRender()
}, 0)
// Small paste: direct insert
input.insertText(normalizedText)
}}
ref={(r: TextareaRenderable) => {
input = r
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