Skip to content

Commit 90b0caf

Browse files
author
TakashiKyoto
committed
fix(tui): fix Ctrl+V paste on Windows/WSL2
- Add file.exists() check before treating pasted text as file path (fixes silent failure for text containing ".png", ".jpg", etc.) - Call event.preventDefault() FIRST in onPaste to prevent double-paste on WSL2/ConPTY terminals - Simplify onKeyDown to handle images only; text fully handled by onPaste - Add PowerShell clipboard read for WSL2 (clipboardy can't access Windows clipboard)
1 parent 0e9664d commit 90b0caf

File tree

2 files changed

+82
-52
lines changed

2 files changed

+82
-52
lines changed

packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

Lines changed: 37 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -778,10 +778,10 @@ export function Prompt(props: PromptProps) {
778778
e.preventDefault()
779779
return
780780
}
781-
// Handle clipboard paste (Ctrl+V) - check for images first on Windows
782-
// This is needed because Windows terminal doesn't properly send image data
783-
// through bracketed paste, so we need to intercept the keypress and
784-
// directly read from clipboard before the terminal handles it
781+
// Handle clipboard paste (Ctrl+V) - IMAGES ONLY
782+
// Windows terminal doesn't send image data through bracketed paste,
783+
// so we intercept Ctrl+V and read directly from clipboard for images.
784+
// Text is handled by onPaste (which always calls preventDefault first).
785785
if (keybind.match("input_paste", e)) {
786786
const content = await Clipboard.read()
787787
if (content?.mime.startsWith("image/")) {
@@ -793,7 +793,7 @@ export function Prompt(props: PromptProps) {
793793
})
794794
return
795795
}
796-
// If no image, let the default paste behavior continue
796+
// Text: let onPaste handle it (don't insert here)
797797
}
798798
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
799799
input.clear()
@@ -853,70 +853,62 @@ export function Prompt(props: PromptProps) {
853853
}}
854854
onSubmit={submit}
855855
onPaste={async (event: PasteEvent) => {
856-
if (props.disabled) {
857-
event.preventDefault()
858-
return
859-
}
856+
// ALWAYS preventDefault FIRST to avoid double-paste on WSL2/ConPTY
857+
event.preventDefault()
858+
if (props.disabled) return
860859

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

871-
// trim ' from the beginning and end of the pasted content. just
872-
// ' and nothing else
865+
// Check if pasted content is a file path
873866
const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ")
874867
const isUrl = /^(https?):\/\//.test(filepath)
875868
if (!isUrl) {
876869
try {
877870
const file = Bun.file(filepath)
878-
// Handle SVG as raw text content, not as base64 image
879-
if (file.type === "image/svg+xml") {
880-
event.preventDefault()
881-
const content = await file.text().catch(() => {})
882-
if (content) {
883-
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
884-
return
871+
const fileExists = await file.exists()
872+
if (fileExists) {
873+
// Handle SVG as raw text content
874+
if (file.type === "image/svg+xml") {
875+
const content = await file.text().catch(() => {})
876+
if (content) {
877+
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
878+
return
879+
}
885880
}
886-
}
887-
if (file.type.startsWith("image/")) {
888-
event.preventDefault()
889-
const content = await file
890-
.arrayBuffer()
891-
.then((buffer) => Buffer.from(buffer).toString("base64"))
892-
.catch(() => {})
893-
if (content) {
894-
await pasteImage({
895-
filename: file.name,
896-
mime: file.type,
897-
content,
898-
})
899-
return
881+
// Handle images
882+
if (file.type.startsWith("image/")) {
883+
const content = await file
884+
.arrayBuffer()
885+
.then((buffer) => Buffer.from(buffer).toString("base64"))
886+
.catch(() => {})
887+
if (content) {
888+
await pasteImage({
889+
filename: file.name,
890+
mime: file.type,
891+
content,
892+
})
893+
return
894+
}
900895
}
901896
}
902897
} catch {}
903898
}
904899

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

915-
// Force layout update and render for the pasted content
916-
setTimeout(() => {
917-
input.getLayoutNode().markDirty()
918-
renderer.requestRender()
919-
}, 0)
910+
// Small paste: direct insert
911+
input.insertText(normalizedText)
920912
}}
921913
ref={(r: TextareaRenderable) => {
922914
input = r

packages/opencode/src/cli/cmd/tui/util/clipboard.ts

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,53 @@ export namespace Clipboard {
3030
}
3131

3232
if (os === "win32" || release().includes("WSL")) {
33-
const script =
34-
"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()) }"
35-
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
36-
if (base64) {
37-
const imageBuffer = Buffer.from(base64.trim(), "base64")
38-
if (imageBuffer.length > 0) {
39-
return { data: imageBuffer.toString("base64"), mime: "image/png" }
33+
// Helper: encode PowerShell script as base64 UTF-16LE for -EncodedCommand
34+
// This avoids ALL quoting/escaping issues with -command "..." which breaks
35+
// when clipboard content ends with backslash sequences (e.g., "c:\path\file.png")
36+
const encodePS = (script: string) => Buffer.from(script, "utf16le").toString("base64")
37+
38+
// Try to get image from Windows clipboard via PowerShell
39+
const imgScript = `
40+
Add-Type -AssemblyName System.Windows.Forms
41+
$img = [System.Windows.Forms.Clipboard]::GetImage()
42+
if ($img) {
43+
$ms = New-Object System.IO.MemoryStream
44+
$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
45+
[System.Convert]::ToBase64String($ms.ToArray())
46+
}
47+
`.trim()
48+
const imgEncoded = encodePS(imgScript)
49+
const imgOut = (await $`powershell.exe -NonInteractive -NoProfile -EncodedCommand ${imgEncoded}`.nothrow().text()).trim()
50+
if (imgOut) {
51+
try {
52+
const buf = Buffer.from(imgOut, "base64")
53+
// Validate PNG magic bytes to prevent garbage PowerShell output from being treated as image
54+
const isPng = buf.length >= 8 &&
55+
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
56+
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
57+
if (isPng) {
58+
return { data: buf.toString("base64"), mime: "image/png" }
59+
}
60+
} catch {
61+
// Invalid base64, fall through to text
4062
}
4163
}
64+
65+
// Get TEXT from Windows clipboard via PowerShell
66+
// CRITICAL: On WSL2, clipboardy uses Linux clipboard tools (xclip/wl-paste) which
67+
// can't access Windows clipboard. We MUST use PowerShell to read Windows clipboard text.
68+
// Using -EncodedCommand to avoid quoting issues with trailing backslashes in clipboard content.
69+
const textScript = `
70+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
71+
try { Get-Clipboard -Raw } catch { "" }
72+
`.trim()
73+
const textEncoded = encodePS(textScript)
74+
const text = (await $`powershell.exe -NonInteractive -NoProfile -EncodedCommand ${textEncoded}`.nothrow().text())
75+
.replace(/\r\n/g, "\n")
76+
.replace(/\r/g, "\n")
77+
if (text && text.trim()) {
78+
return { data: text, mime: "text/plain" }
79+
}
4280
}
4381

4482
if (os === "linux") {

0 commit comments

Comments
 (0)