From 01011cae2bfaaf5704f53f496e7c87a4e87d2fb5 Mon Sep 17 00:00:00 2001 From: neriousy Date: Mon, 19 Jan 2026 18:03:59 +0100 Subject: [PATCH 1/2] feat: cmd+v paste files --- packages/app/src/components/prompt-input.tsx | 110 ++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2f85652a93e..b6b8fd88eb1 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -54,7 +54,7 @@ import { usePlatform } from "@/context/platform" import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client" import { Binary } from "@opencode-ai/util/binary" import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Decode, base64Encode } from "@opencode-ai/util/encode" const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"] const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"] @@ -285,6 +285,77 @@ export const PromptInput: Component = (props) => { reader.readAsDataURL(file) } + const normalizeClipboardPath = (value: string) => { + const trimmed = value.trim() + if (!trimmed) return "" + return trimmed.replace(/^'+|'+$/g, "").replace(/\\ /g, " ") + } + + const resolveClipboardPath = (value: string) => { + if (!value) return "" + if (/^(https?):\/\//.test(value)) return "" + + const normalized = value.replace(/\\/g, "/") + const directory = sdk.directory.replace(/\\/g, "/").replace(/\/+$/, "") + const isDrivePath = /^[a-zA-Z]:\//.test(normalized) + if (isDrivePath && !normalized.startsWith(directory)) return "" + if (normalized.startsWith("/") && !normalized.startsWith(directory)) return "" + + if (!normalized.startsWith(directory)) return normalized + + const relative = normalized.slice(directory.length).replace(/^\/+/, "") + if (!relative) return "" + return relative + } + + const attachClipboardFile = async (path: string) => { + const resolved = resolveClipboardPath(path) + if (!resolved) return false + + const result = await sdk.client.file.read({ path: resolved }).catch(() => undefined) + if (!result?.data) return false + + const content = result.data + const mime = content.mimeType ?? "text/plain" + if (mime === "image/svg+xml") { + const text = content.encoding === "base64" ? base64Decode(content.content) : content.content + if (text) addPart({ type: "text", content: text, start: 0, end: 0 }) + return true + } + + if (content.encoding !== "base64") return false + + if (mime.startsWith("image/") || mime === "application/pdf") { + const attachment: ImageAttachmentPart = { + type: "image", + id: crypto.randomUUID(), + filename: getFilename(resolved), + mime, + dataUrl: `data:${mime};base64,${content.content}`, + } + const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef) + prompt.set([...prompt.current(), attachment], cursorPosition) + return true + } + + return false + } + + const attachClipboardFiles = async (value: string) => { + const candidates = value + .split(/\r?\n/) + .map((entry) => normalizeClipboardPath(entry)) + .filter(Boolean) + + if (candidates.length === 0) return false + + for (const candidate of candidates) { + if (await attachClipboardFile(candidate)) return true + } + + return false + } + const removeImageAttachment = (id: string) => { const current = prompt.current() const next = current.filter((part) => part.type !== "image" || part.id !== id) @@ -311,6 +382,9 @@ export const PromptInput: Component = (props) => { } const plainText = clipboardData.getData("text/plain") ?? "" + const handled = await attachClipboardFiles(plainText) + if (handled) return + addPart({ type: "text", content: plainText, start: 0, end: 0 }) } @@ -846,6 +920,40 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { + const isCtrlPaste = + event.key.toLowerCase() === "v" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey + const clipboard = navigator.clipboard + if (isCtrlPaste && platform.platform === "desktop" && clipboard?.read) { + event.preventDefault() + event.stopPropagation() + + void clipboard + .read() + .then(async (items) => { + const fileItem = items + .flatMap((item) => item.types.map((type) => ({ item, type }))) + .find((entry) => ACCEPTED_FILE_TYPES.includes(entry.type) || entry.type === "image/svg+xml") + if (fileItem) { + const blob = await fileItem.item.getType(fileItem.type) + if (fileItem.type === "image/svg+xml") { + const text = await blob.text() + if (text) addPart({ type: "text", content: text, start: 0, end: 0 }) + return + } + await addImageAttachment(new File([blob], "clipboard", { type: fileItem.type })) + return + } + + const text = await clipboard.readText().catch(() => "") + if (!text) return + const handled = await attachClipboardFiles(text) + if (handled) return + addPart({ type: "text", content: text, start: 0, end: 0 }) + }) + .catch(() => {}) + return + } + if (event.key === "Backspace") { const selection = window.getSelection() if (selection && selection.isCollapsed) { From 92f176d21b7811f649c5dacb1c234323213be5b0 Mon Sep 17 00:00:00 2001 From: neriousy Date: Mon, 19 Jan 2026 18:43:18 +0100 Subject: [PATCH 2/2] fix: remove ctrl+v handler that blocked paste due to clipboard permissions The keyboard handler was intercepting Ctrl+V and attempting to use navigator.clipboard.read() which requires explicit browser permissions. When denied (the default in web browsers), paste would fail silently. Removed the keyboard handler to let the native paste event work normally through the existing handlePaste handler. --- packages/app/src/components/prompt-input.tsx | 34 -------------------- 1 file changed, 34 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index b6b8fd88eb1..4a45f9c18b3 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -920,40 +920,6 @@ export const PromptInput: Component = (props) => { } const handleKeyDown = (event: KeyboardEvent) => { - const isCtrlPaste = - event.key.toLowerCase() === "v" && event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey - const clipboard = navigator.clipboard - if (isCtrlPaste && platform.platform === "desktop" && clipboard?.read) { - event.preventDefault() - event.stopPropagation() - - void clipboard - .read() - .then(async (items) => { - const fileItem = items - .flatMap((item) => item.types.map((type) => ({ item, type }))) - .find((entry) => ACCEPTED_FILE_TYPES.includes(entry.type) || entry.type === "image/svg+xml") - if (fileItem) { - const blob = await fileItem.item.getType(fileItem.type) - if (fileItem.type === "image/svg+xml") { - const text = await blob.text() - if (text) addPart({ type: "text", content: text, start: 0, end: 0 }) - return - } - await addImageAttachment(new File([blob], "clipboard", { type: fileItem.type })) - return - } - - const text = await clipboard.readText().catch(() => "") - if (!text) return - const handled = await attachClipboardFiles(text) - if (handled) return - addPart({ type: "text", content: text, start: 0, end: 0 }) - }) - .catch(() => {}) - return - } - if (event.key === "Backspace") { const selection = window.getSelection() if (selection && selection.isCollapsed) {