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 d5e0a0aa2a1..25efb4e5f5d 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -30,6 +30,7 @@ import { DialogAlert } from "../../ui/dialog-alert" import { useToast } from "../../ui/toast" import { useKV } from "../../context/kv" import { useTextareaKeybindings } from "../textarea-keybindings" +import { release } from "os" export type PromptProps = { sessionID?: string @@ -185,7 +186,7 @@ export function Prompt(props: PromptProps) { const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { await pasteImage({ - filename: "clipboard", + filename: content.filename ?? "clipboard", mime: content.mime, content: content.data, }) @@ -784,13 +785,12 @@ export function Prompt(props: PromptProps) { if (content?.mime.startsWith("image/")) { e.preventDefault() await pasteImage({ - filename: "clipboard", + filename: content.filename ?? "clipboard", mime: content.mime, content: content.data, }) return } - // If no image, let the default paste behavior continue } if (keybind.match("input_clear", e) && store.prompt.input !== "") { input.clear() @@ -850,8 +850,9 @@ export function Prompt(props: PromptProps) { }} onSubmit={submit} onPaste={async (event: PasteEvent) => { + event.preventDefault() + if (props.disabled) { - event.preventDefault() return } @@ -860,6 +861,20 @@ export function Prompt(props: PromptProps) { // Replace CRLF first, then any remaining CR const normalizedText = event.text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const pastedContent = normalizedText.trim() + + // Check clipboard for copied image files FIRST (Windows only) + if (!pastedContent || pastedContent.length < 500) { + const clipboardContent = await Clipboard.read() + if (clipboardContent?.mime.startsWith("image/")) { + await pasteImage({ + filename: clipboardContent.filename ?? "clipboard", + mime: clipboardContent.mime, + content: clipboardContent.data, + }) + return + } + } + if (!pastedContent) { command.trigger("prompt.paste") return @@ -867,14 +882,20 @@ export function Prompt(props: PromptProps) { // trim ' from the beginning and end of the pasted content. just // ' and nothing else - const filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") + let filepath = pastedContent.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") + + // Convert Windows path to WSL path if running in WSL + if (release().includes("WSL") && /^[A-Z]:\\/.test(filepath)) { + const drive = filepath[0].toLowerCase() + filepath = filepath.replace(/^[A-Z]:\\/, `/mnt/${drive}/`).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"}]`) @@ -882,7 +903,6 @@ export function Prompt(props: PromptProps) { } } if (file.type.startsWith("image/")) { - event.preventDefault() const content = await file .arrayBuffer() .then((buffer) => Buffer.from(buffer).toString("base64")) @@ -904,11 +924,13 @@ export function Prompt(props: PromptProps) { (lineCount >= 3 || pastedContent.length > 150) && !sync.data.config.experimental?.disable_paste_summary ) { - event.preventDefault() pasteText(pastedContent, `[Pasted ~${lineCount} lines]`) return } + // Since we called preventDefault(), manually insert the text + input.insertText(pastedContent) + // Force layout update and render for the pasted content setTimeout(() => { input.getLayoutNode().markDirty() diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 9c91cf3055a..297e2bc8344 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -9,6 +9,7 @@ export namespace Clipboard { export interface Content { data: string mime: string + filename?: string } export async function read(): Promise { @@ -30,13 +31,59 @@ 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") + const script = [ + "Add-Type -AssemblyName System.Windows.Forms;", + "Add-Type -AssemblyName System.Drawing;", + "if ([System.Windows.Forms.Clipboard]::ContainsFileDropList()) {", + " $files = [System.Windows.Forms.Clipboard]::GetFileDropList();", + " if ($files.Count -gt 0) {", + " $filePath = $files[0];", + " try {", + " $img = [System.Drawing.Image]::FromFile($filePath);", + " $ms = New-Object System.IO.MemoryStream;", + " $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png);", + " $img.Dispose();", + ' Write-Output "FILEPATH:$filePath";', + " [System.Convert]::ToBase64String($ms.ToArray())", + " } catch {}", + " }", + "} elseif ([System.Windows.Forms.Clipboard]::ContainsImage()) {", + " $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())", + " }", + "}", + ].join(" ") + const output = await $`powershell.exe -Sta -NonInteractive -NoProfile -command "${script}"`.nothrow().text() + if (output) { + const lines = output.trim().split("\n") + let filepath: string | undefined + let base64: string + + if (lines[0]?.startsWith("FILEPATH:")) { + filepath = lines[0].substring(9).trim() + base64 = lines.slice(1).join("") + + console.log("[DEBUG clipboard.ts] Original filepath:", filepath) + if (release().includes("WSL") && /^[A-Z]:\\/.test(filepath)) { + const drive = filepath[0].toLowerCase() + filepath = filepath.replace(/^[A-Z]:\\/, `/mnt/${drive}/`).replace(/\\/g, "/") + console.log("[DEBUG clipboard.ts] Converted to WSL path:", filepath) + } + console.log("[DEBUG clipboard.ts] Extracted filename:", filepath ? path.basename(filepath) : "(none)") + } else { + base64 = output.trim() + } + + const imageBuffer = Buffer.from(base64, "base64") if (imageBuffer.length > 0) { - return { data: imageBuffer.toString("base64"), mime: "image/png" } + return { + data: imageBuffer.toString("base64"), + mime: "image/png", + filename: filepath ? path.basename(filepath) : undefined, + } } } }