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
4 changes: 3 additions & 1 deletion packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -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 ?? "",
Expand Down Expand Up @@ -937,6 +938,7 @@ export function Prompt(props: PromptProps) {
e.preventDefault()
await pasteAttachment({
filename: "clipboard",
filepath: content.filepath,
mime: content.mime,
content: content.data,
})
Expand Down
19 changes: 12 additions & 7 deletions packages/opencode/src/cli/cmd/tui/util/clipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
[
Expand All @@ -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(() => {})
}
}

Expand All @@ -77,21 +76,27 @@ 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 }
}
}
}

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 }
}
}

Expand Down
84 changes: 64 additions & 20 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
],
}
}
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 1 addition & 3 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading