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 5a3e1d451d6d..3a044f1e53e1 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -247,6 +247,7 @@ export function Prompt(props: PromptProps) { if (content?.mime.startsWith("image/")) { await pasteAttachment({ filename: "clipboard", + filepath: content.filepath, mime: content.mime, content: content.data, }) @@ -802,7 +803,7 @@ export function Prompt(props: PromptProps) { type: "file" as const, mime: file.mime, filename: file.filename, - url: `data:${file.mime};base64,${file.content}`, + url: file.filepath ? `file://${file.filepath}` : `data:${file.mime};base64,${file.content}`, source: { type: "file", path: file.filepath ?? file.filename ?? "", @@ -937,6 +938,7 @@ export function Prompt(props: PromptProps) { e.preventDefault() await pasteAttachment({ filename: "clipboard", + filepath: content.filepath, mime: content.mime, content: content.data, }) diff --git a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts index 87c0a63abc82..76b376d10b2f 100644 --- a/packages/opencode/src/cli/cmd/tui/util/clipboard.ts +++ b/packages/opencode/src/cli/cmd/tui/util/clipboard.ts @@ -26,6 +26,7 @@ export namespace Clipboard { export interface Content { data: string mime: string + filepath?: string } // Checks clipboard for images first, then falls back to text. @@ -40,7 +41,7 @@ export namespace Clipboard { const os = platform() if (os === "darwin") { - const tmpfile = path.join(tmpdir(), "opencode-clipboard.png") + const tmpfile = path.join(tmpdir(), `opencode-clipboard-${Date.now()}.png`) try { await Process.run( [ @@ -59,10 +60,8 @@ export namespace Clipboard { { nothrow: true }, ) const buffer = await Filesystem.readBytes(tmpfile) - return { data: buffer.toString("base64"), mime: "image/png" } + return { data: buffer.toString("base64"), mime: "image/png", filepath: tmpfile } } catch { - } finally { - await fs.rm(tmpfile, { force: true }).catch(() => {}) } } @@ -77,7 +76,9 @@ export namespace Clipboard { if (base64.text) { const imageBuffer = Buffer.from(base64.text.trim(), "base64") if (imageBuffer.length > 0) { - return { data: imageBuffer.toString("base64"), mime: "image/png" } + const tmpfile = path.join(tmpdir(), `opencode-clipboard-${Date.now()}.png`) + await fs.writeFile(tmpfile, imageBuffer).catch(() => {}) + return { data: imageBuffer.toString("base64"), mime: "image/png", filepath: tmpfile } } } } @@ -85,13 +86,17 @@ export namespace Clipboard { if (os === "linux") { const wayland = await Process.run(["wl-paste", "-t", "image/png"], { nothrow: true }) if (wayland.stdout.byteLength > 0) { - return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png" } + const tmpfile = path.join(tmpdir(), `opencode-clipboard-${Date.now()}.png`) + await fs.writeFile(tmpfile, Buffer.from(wayland.stdout)).catch(() => {}) + return { data: Buffer.from(wayland.stdout).toString("base64"), mime: "image/png", filepath: tmpfile } } const x11 = await Process.run(["xclip", "-selection", "clipboard", "-t", "image/png", "-o"], { nothrow: true, }) if (x11.stdout.byteLength > 0) { - return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png" } + const tmpfile = path.join(tmpdir(), `opencode-clipboard-${Date.now()}.png`) + await fs.writeFile(tmpfile, Buffer.from(x11.stdout)).catch(() => {}) + return { data: Buffer.from(x11.stdout).toString("base64"), mime: "image/png", filepath: tmpfile } } } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 4c18d1f7e09d..0fbb9c461674 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -16,6 +16,9 @@ import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" import { Effect } from "effect" import { EffectLogger } from "@/effect/logger" +import { fileURLToPath } from "url" +import { readFileSync } from "fs" +import { readFile } from "fs/promises" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ interface FetchDecompressionError extends Error { @@ -617,26 +620,42 @@ export namespace MessageV2 { } if (typeof output === "object") { - const outputObject = output as { + const obj = output as { text: string attachments?: Array<{ mime: string; url: string }> } - const attachments = (outputObject.attachments ?? []).filter((attachment) => { - return attachment.url.startsWith("data:") && attachment.url.includes(",") - }) + const attachments = (obj.attachments ?? []).filter( + (a) => a.url.startsWith("data:") || a.url.startsWith("file://"), + ) return { type: "content", value: [ - { type: "text", text: outputObject.text }, - ...attachments.map((attachment) => ({ - type: "media", - mediaType: attachment.mime, - data: iife(() => { - const commaIndex = attachment.url.indexOf(",") - return commaIndex === -1 ? attachment.url : attachment.url.slice(commaIndex + 1) - }), - })), + { type: "text", text: obj.text }, + ...attachments + .map((attachment) => { + if (attachment.url.startsWith("file://")) { + try { + return { + type: "media" as const, + mediaType: attachment.mime, + data: readFileSync(fileURLToPath(attachment.url)).toString("base64"), + } + } catch { + return { + type: "text" as const, + text: `[Attached ${attachment.mime}: file not found]`, + } + } + } + const comma = attachment.url.indexOf(",") + return { + type: "media" as const, + mediaType: attachment.mime, + data: comma === -1 ? attachment.url : attachment.url.slice(comma + 1), + } + }) + .filter((p): p is { type: "media"; mediaType: string; data: string } => p !== null), ], } } @@ -826,15 +845,40 @@ export namespace MessageV2 { const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }])) - return yield* Effect.promise(() => - convertToModelMessages( - result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")), - { - //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) - tools, - }, + const filtered = result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")) + const resolved = yield* Effect.promise(() => + Promise.all( + filtered.map((msg) => + Promise.all( + msg.parts.map(async (part) => { + if (part.type === "file" && part.url.startsWith("file://")) { + try { + const buf = await readFile(fileURLToPath(part.url)) + return { + ...part, + url: `data:${(part as any).mediaType};base64,${buf.toString("base64")}`, + } + } catch { + return { + ...part, + type: "text" as const, + text: `[Attached ${(part as any).mediaType}: file not found]`, + } + } + } + return part + }), + ).then((parts) => ({ ...msg, parts })), + ), ), ) + + return yield* Effect.promise(() => + convertToModelMessages(resolved, { + //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) + tools, + }), + ) }) export function toModelMessages( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 97a37865dfa2..0d84e449a7b3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1191,9 +1191,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the messageID: info.id, sessionID: input.sessionID, type: "file", - url: - `data:${part.mime};base64,` + - Buffer.from(yield* fsys.readFile(filepath).pipe(Effect.catch(Effect.die))).toString("base64"), + url: part.url, mime: part.mime, filename: part.filename!, source: part.source,