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
38 changes: 30 additions & 8 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -850,8 +850,9 @@ export function Prompt(props: PromptProps) {
}}
onSubmit={submit}
onPaste={async (event: PasteEvent) => {
event.preventDefault()

if (props.disabled) {
event.preventDefault()
return
}

Expand All @@ -860,29 +861,48 @@ 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
}

// 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"}]`)
return
}
}
if (file.type.startsWith("image/")) {
event.preventDefault()
const content = await file
.arrayBuffer()
.then((buffer) => Buffer.from(buffer).toString("base64"))
Expand All @@ -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()
Expand Down
59 changes: 53 additions & 6 deletions packages/opencode/src/cli/cmd/tui/util/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export namespace Clipboard {
export interface Content {
data: string
mime: string
filename?: string
}

export async function read(): Promise<Content | undefined> {
Expand All @@ -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,
}
}
}
}
Expand Down