Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/app/src/components/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
addPart,
readClipboardImage: platform.readClipboardImage,
getPathForFile: platform.getPathForFile,
persistTempImage: platform.persistTempImage,
})

const fileAttachmentInput = () => (
Expand Down
16 changes: 15 additions & 1 deletion packages/app/src/components/prompt-input/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type PromptAttachmentsCoreInput = {
warn?: () => void
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
persistTempImage?: (buffer: ArrayBuffer, ext: string) => Promise<string>
}

type PromptAttachmentsInput = {
Expand All @@ -47,6 +48,7 @@ type PromptAttachmentsInput = {
addPart: (part: ContentPart) => boolean
readClipboardImage?: () => Promise<File | null>
getPathForFile?: (file: File) => string
persistTempImage?: (buffer: ArrayBuffer, ext: string) => Promise<string>
}

export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
Expand All @@ -68,11 +70,23 @@ export function createPromptAttachmentsCore(input: PromptAttachmentsCoreInput) {
const url = await dataUrl(file, mime)
if (!url) return false

let sourcePath = input.getPathForFile?.(file) || undefined
// Screenshots and other in-memory clipboard images have no disk path, so
// persist them to a temp file that file-path-based tools (e.g. MCP image readers) can access.
if (!sourcePath && input.persistTempImage) {
sourcePath = await input.persistTempImage(await file.arrayBuffer(), mime.split("/")[1] ?? "png").catch(
(err) => {
console.warn("Failed to persist temp image for attachment:", err)
return undefined
},
)
}

const attachment: ImageAttachmentPart = {
type: "image",
id: uuid(),
filename: file.name,
sourcePath: input.getPathForFile?.(file) || undefined,
sourcePath,
mime,
dataUrl: url,
}
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,9 @@ type PlatformBase = {
/** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null>

/** Persist an in-memory file (e.g. screenshot) to a temp file and return its path (desktop only) */
persistTempImage?(buffer: ArrayBuffer, ext: string): Promise<string>

/** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise<string>

Expand Down
44 changes: 39 additions & 5 deletions packages/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { execFile } from "node:child_process"
import { stat } from "node:fs/promises"
import { basename } from "node:path"
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import { stat, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { basename, join } from "node:path"
import { app, BrowserWindow, Notification, clipboard, dialog, ipcMain, shell } from "electron"
import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
Expand Down Expand Up @@ -42,6 +45,18 @@ type Deps = {
}

export function registerIpcHandlers(deps: Deps) {
const clipboardTempDir = join(tmpdir(), `opencode-clipboard-${randomUUID()}`)
try {
mkdirSync(clipboardTempDir, { recursive: true })
} catch (err) {
console.error("Failed to create clipboard temp directory:", err)
}
app.once("will-quit", () => {
try {
rmSync(clipboardTempDir, { recursive: true, force: true })
} catch {}
})

const updaterSubscriptions = createUpdaterSubscriptions()
app.once("will-quit", updaterSubscriptions.clear)

Expand Down Expand Up @@ -184,12 +199,31 @@ export function registerIpcHandlers(deps: Deps) {
})
})

ipcMain.handle("read-clipboard-image", () => {
ipcMain.handle("read-clipboard-image", async () => {
const image = clipboard.readImage()
if (image.isEmpty()) return null
const buffer = image.toPNG().buffer
const png = image.toPNG()
const size = image.getSize()
return { buffer, width: size.width, height: size.height }
// Persist the clipboard image to a temp file so file-path-based tools
// (e.g. MCP image readers) can access screenshots that only exist in memory.
const path = join(clipboardTempDir, `${randomUUID()}.png`)
try {
await writeFile(path, png)
} catch (err) {
console.error("Failed to persist clipboard image:", err)
return { buffer: png.buffer, width: size.width, height: size.height }
}
return { buffer: png.buffer, width: size.width, height: size.height, path }
})

ipcMain.handle("persist-temp-image", async (_event, buffer: ArrayBuffer, ext: string) => {
const path = join(clipboardTempDir, `${randomUUID()}.${ext || "png"}`)
try {
await writeFile(path, Buffer.from(buffer))
} catch {
throw new Error(`Failed to persist temp image to ${path}`)
}
return path
})

ipcMain.on("show-notification", (_event: IpcMainEvent, title: string, body?: string) => {
Expand Down
1 change: 1 addition & 0 deletions packages/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ const api: ElectronAPI = {
openLink: (url) => ipcRenderer.send("open-link", url),
openPath: (path, app) => ipcRenderer.invoke("open-path", path, app),
readClipboardImage: () => ipcRenderer.invoke("read-clipboard-image"),
persistTempImage: (buffer, ext) => ipcRenderer.invoke("persist-temp-image", buffer, ext),
showNotification: (title, body) => ipcRenderer.send("show-notification", title, body),
getWindowFocused: () => ipcRenderer.invoke("get-window-focused"),
setWindowFocus: () => ipcRenderer.invoke("set-window-focus"),
Expand Down
3 changes: 2 additions & 1 deletion packages/desktop/src/preload/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ export type ElectronAPI = {
saveFilePicker: (opts?: { title?: string; defaultPath?: string }) => Promise<string | null>
openLink: (url: string) => void
openPath: (path: string, app?: string) => Promise<void>
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number } | null>
readClipboardImage: () => Promise<{ buffer: ArrayBuffer; width: number; height: number; path?: string } | null>
persistTempImage: (buffer: ArrayBuffer, ext: string) => Promise<string>
showNotification: (title: string, body?: string) => void
getWindowFocused: () => Promise<boolean>
setWindowFocus: () => Promise<void>
Expand Down
8 changes: 7 additions & 1 deletion packages/desktop/src/renderer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -303,9 +303,15 @@ const createPlatform = (windowState: DesktopWindowState): Platform => {
const image = await window.api.readClipboardImage().catch(() => null)
if (!image) return null
const blob = new Blob([image.buffer], { type: "image/png" })
return new File([blob], `pasted-image-${Date.now()}.png`, {
const file = new File([blob], `pasted-image-${crypto.randomUUID()}.png`, {
type: "image/png",
})
if (image.path) attachmentPaths.set(file, image.path)
return file
},

async persistTempImage(buffer: ArrayBuffer, ext: string) {
return window.api.persistTempImage(buffer, ext)
},
}
}
Expand Down
53 changes: 48 additions & 5 deletions packages/tui/src/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,26 @@
import { execFile, spawn } from "node:child_process"
import { readFile, rm } from "node:fs/promises"
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import { access, readFile, rm } from "node:fs/promises"
import { platform, release, tmpdir } from "node:os"
import path from "node:path"
import { promisify } from "node:util"

const exec = promisify(execFile)

// Clipboard image reads return a file path so path-based MCP tools can read it,
// so the temp file must outlive read(). To avoid leaking one PNG per paste, they
// go in a single per-process dir that is removed when the process exits.
let clipboardImageDir: string | undefined
function clipboardImagePath() {
if (!clipboardImageDir) {
clipboardImageDir = path.join(tmpdir(), `opencode-clipboard-${randomUUID()}`)
mkdirSync(clipboardImageDir, { recursive: true })
process.once("exit", () => rmSync(clipboardImageDir!, { recursive: true, force: true }))
}
return path.join(clipboardImageDir, `${randomUUID()}.png`)
}

function command(command: string, args: string[] = [], input?: string) {
return new Promise<Buffer>((resolve, reject) => {
const child = spawn(command, args, { stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"] })
Expand All @@ -26,9 +41,39 @@ function writeOsc52(text: string) {
process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence)
}

async function readClipboardFilePath() {
// «class furl» reads public.file-url (Finder Cmd+C) and alias records as a file reference.
// Plain text is coercible too, so verify the path exists before trusting it.
try {
const result = await exec("osascript", [
"-e",
`try
set theFile to the clipboard as «class furl»
try
return POSIX path of theFile
on error
set fileList to theFile as list
return POSIX path of (item 1 of fileList)
end try
on error
return ""
end try`,
])
const filePath = result.stdout.toString().trim()
if (!filePath) return undefined
await access(filePath)
return filePath
} catch {
return undefined
}
}

export async function read() {
if (platform() === "darwin") {
const file = path.join(tmpdir(), "opencode-clipboard.png")
const filePath = await readClipboardFilePath()
if (filePath) return { data: filePath, mime: "text/plain" }

const file = clipboardImagePath()
try {
await exec("osascript", [
"-e",
Expand All @@ -42,10 +87,8 @@ export async function read() {
"-e",
"close access fileRef",
])
return { data: (await readFile(file)).toString("base64"), mime: "image/png" }
return { data: (await readFile(file)).toString("base64"), mime: "image/png", path: file }
} catch {
// Fall through to text clipboard.
} finally {
await rm(file, { force: true }).catch(() => {})
}
}
Expand Down
23 changes: 20 additions & 3 deletions packages/tui/src/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ export function Prompt(props: PromptProps) {
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
filename: "clipboard",
filepath: content.path,
mime: content.mime,
content: content.data,
})
Expand Down Expand Up @@ -1194,10 +1195,26 @@ export function Prompt(props: PromptProps) {
async function pasteInputText(text: string) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim()
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
let filepath = pastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) {
const attachment = await readLocalAttachment(filepath)
let attachment = await readLocalAttachment(filepath)
// Bracketed paste only delivers the clipboard's text flavor, which for a
// copied file is the bare filename. Resolve the full path via the platform
// clipboard (which can read the file URL) and retry.
if (!attachment && clipboard.read) {
const clip = await clipboard.read()
if (clip?.mime === "text/plain" && clip.data && clip.data !== pastedContent) {
const clipPath = pastedFilepath(clip.data, terminalEnvironment.platform)
if (clipPath !== filepath) {
const retry = await readLocalAttachment(clipPath)
if (retry) {
attachment = retry
filepath = clipPath
}
}
}
}
const filename = path.basename(filepath)
if (attachment?.type === "text") {
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`)
Expand Down Expand Up @@ -1257,7 +1274,7 @@ export function Prompt(props: PromptProps) {
const part: Omit<FilePart, "id" | "messageID" | "sessionID"> = {
type: "file" as const,
mime: file.mime,
filename: file.filename,
filename: file.filepath ?? file.filename,
url: `data:${file.mime};base64,${file.content}`,
source: {
type: "file",
Expand Down
2 changes: 1 addition & 1 deletion packages/tui/src/context/clipboard.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createContext, type JSX, useContext } from "solid-js"
import { read, write } from "../clipboard"

export type ClipboardContent = Readonly<{ data: string; mime: string }>
export type ClipboardContent = Readonly<{ data: string; mime: string; path?: string }>
export type ClipboardService = Readonly<{
read?(): Promise<ClipboardContent | undefined>
write?(text: string): Promise<void>
Expand Down
Loading