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 96b9e8ffd57..e0d719cc4e5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -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/")) { @@ -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() @@ -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 diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 9c91cf3055a..f6bc383f076 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -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") {