Skip to content

Commit 1b4134d

Browse files
author
TakashiKyoto
committed
fix(tui): fix Ctrl+V paste on Windows/WSL2
1 parent 8d720f9 commit 1b4134d

File tree

2 files changed

+79
-29
lines changed

2 files changed

+79
-29
lines changed

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

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -792,7 +792,13 @@ export function Prompt(props: PromptProps) {
792792
})
793793
return
794794
}
795-
// If no image, let the default paste behavior continue
795+
// Handle text paste - Windows/WSL2 don't trigger onPaste via bracketed paste
796+
if (content?.mime === "text/plain" && content.data) {
797+
e.preventDefault()
798+
const normalized = content.data.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
799+
input.insertText(normalized)
800+
return
801+
}
796802
}
797803
if (keybind.match("input_clear", e) && store.prompt.input !== "") {
798804
input.clear()
@@ -874,28 +880,34 @@ export function Prompt(props: PromptProps) {
874880
if (!isUrl) {
875881
try {
876882
const file = Bun.file(filepath)
877-
// Handle SVG as raw text content, not as base64 image
878-
if (file.type === "image/svg+xml") {
879-
event.preventDefault()
880-
const content = await file.text().catch(() => {})
881-
if (content) {
882-
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
883-
return
883+
// IMPORTANT: Check if file exists before treating pasted text as a file path
884+
// Bun.file().type infers MIME from extension even for non-existent files,
885+
// which would incorrectly prevent normal text paste for strings like "image.png"
886+
const fileExists = await file.exists()
887+
if (fileExists) {
888+
// Handle SVG as raw text content, not as base64 image
889+
if (file.type === "image/svg+xml") {
890+
const content = await file.text().catch(() => {})
891+
if (content) {
892+
event.preventDefault()
893+
pasteText(content, `[SVG: ${file.name ?? "image"}]`)
894+
return
895+
}
884896
}
885-
}
886-
if (file.type.startsWith("image/")) {
887-
event.preventDefault()
888-
const content = await file
889-
.arrayBuffer()
890-
.then((buffer) => Buffer.from(buffer).toString("base64"))
891-
.catch(() => {})
892-
if (content) {
893-
await pasteImage({
894-
filename: file.name,
895-
mime: file.type,
896-
content,
897-
})
898-
return
897+
if (file.type.startsWith("image/")) {
898+
const content = await file
899+
.arrayBuffer()
900+
.then((buffer) => Buffer.from(buffer).toString("base64"))
901+
.catch(() => {})
902+
if (content) {
903+
event.preventDefault()
904+
await pasteImage({
905+
filename: file.name,
906+
mime: file.type,
907+
content,
908+
})
909+
return
910+
}
899911
}
900912
}
901913
} catch {}

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)