From 15300afe0b4e5cbe8b5b1878aece3905136e82c0 Mon Sep 17 00:00:00 2001 From: Kyle Crommett Date: Sat, 11 Oct 2025 21:01:00 -0700 Subject: [PATCH 1/4] refactor: use discriminated union for file content with mime types --- packages/opencode/src/file/index.ts | 119 ++++++++++++++++++++++------ 1 file changed, 93 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index e5023f0dc1..a4c33d6d83 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -40,33 +40,89 @@ export namespace File { export type Node = z.infer export const Content = z - .object({ - content: z.string(), - diff: z.string().optional(), - patch: z - .object({ - oldFileName: z.string(), - newFileName: z.string(), - oldHeader: z.string().optional(), - newHeader: z.string().optional(), - hunks: z.array( - z.object({ - oldStart: z.number(), - oldLines: z.number(), - newStart: z.number(), - newLines: z.number(), - lines: z.array(z.string()), - }), - ), - index: z.string().optional(), - }) - .optional(), - }) + .discriminatedUnion("type", [ + z.object({ + type: z.literal("text"), + content: z.string(), + diff: z.string().optional(), + patch: z + .object({ + oldFileName: z.string(), + newFileName: z.string(), + oldHeader: z.string().optional(), + newHeader: z.string().optional(), + hunks: z.array( + z.object({ + oldStart: z.number(), + oldLines: z.number(), + newStart: z.number(), + newLines: z.number(), + lines: z.array(z.string()), + }), + ), + index: z.string().optional(), + }) + .optional(), + }), + z.object({ + type: z.literal("binary"), + content: z.string(), + mimeType: z.string(), + }), + ]) .meta({ ref: "FileContent", }) export type Content = z.infer + function getMimeType(filepath: string): string { + const ext = path.extname(filepath).toLowerCase() + const mimeTypes: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".webp": "image/webp", + ".ico": "image/x-icon", + ".svg": "image/svg+xml", + ".zip": "application/zip", + ".tar": "application/x-tar", + ".gz": "application/gzip", + ".pdf": "application/pdf", + ".wasm": "application/wasm", + ".exe": "application/x-msdownload", + ".dll": "application/x-msdownload", + ".so": "application/x-sharedlib", + } + return mimeTypes[ext] || "application/octet-stream" + } + + async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise { + const ext = path.extname(filepath).toLowerCase() + + if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".ico"].includes(ext)) { + return true + } + + if ([".zip", ".tar", ".gz", ".exe", ".dll", ".so", ".pdf", ".wasm"].includes(ext)) { + return true + } + + const stat = await file.stat() + if (stat.size === 0) return false + + const bufferSize = Math.min(512, stat.size) + const buffer = await file.arrayBuffer() + const bytes = new Uint8Array(buffer.slice(0, bufferSize)) + + for (let i = 0; i < bytes.length; i++) { + if (bytes[i] === 0) return true + } + + return false + } + export const Event = { Edited: Bus.event( "file.edited", @@ -188,14 +244,25 @@ export namespace File { })) } - export async function read(file: string) { + export async function read(file: string): Promise { using _ = log.time("read", { file }) const project = Instance.project const full = path.join(Instance.directory, file) - const content = await Bun.file(full) + const bunFile = Bun.file(full) + + const isBinary = await isBinaryFile(full, bunFile) + + if (isBinary) { + const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0)) + const content = Buffer.from(buffer).toString("base64") + return { type: "binary", content, mimeType: getMimeType(full) } + } + + const content = await bunFile .text() .catch(() => "") .then((x) => x.trim()) + if (project.vcs === "git") { let diff = await $`git diff ${file}`.cwd(Instance.directory).quiet().nothrow().text() if (!diff.trim()) diff = await $`git diff --staged ${file}`.cwd(Instance.directory).quiet().nothrow().text() @@ -206,10 +273,10 @@ export namespace File { ignoreWhitespace: true, }) const diff = formatPatch(patch) - return { content, patch, diff } + return { type: "text", content, patch, diff } } } - return { content } + return { type: "text", content } } export async function list(dir?: string) { From 3b971e9d5f7549195d2b2ac50219605bd43eb5b7 Mon Sep 17 00:00:00 2001 From: Kyle Crommett Date: Sat, 11 Oct 2025 21:25:30 -0700 Subject: [PATCH 2/4] minor changes and docs update --- packages/sdk/js/src/gen/types.gen.ts | 43 ++++++++++++++++----------- packages/web/src/content/docs/sdk.mdx | 12 +++++++- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 706fff125e..3214d033f9 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -950,24 +950,31 @@ export type FileNode = { ignored: boolean } -export type FileContent = { - content: string - diff?: string - patch?: { - oldFileName: string - newFileName: string - oldHeader?: string - newHeader?: string - hunks: Array<{ - oldStart: number - oldLines: number - newStart: number - newLines: number - lines: Array - }> - index?: string - } -} +export type FileContent = + | { + type: "text" + content: string + diff?: string + patch?: { + oldFileName: string + newFileName: string + oldHeader?: string + newHeader?: string + hunks: Array<{ + oldStart: number + oldLines: number + newStart: number + newLines: number + lines: Array + }> + index?: string + } + } + | { + type: "binary" + content: string + mimeType: string + } export type File = { path: string diff --git a/packages/web/src/content/docs/sdk.mdx b/packages/web/src/content/docs/sdk.mdx index 2b90bd9bcb..6373a54492 100644 --- a/packages/web/src/content/docs/sdk.mdx +++ b/packages/web/src/content/docs/sdk.mdx @@ -262,7 +262,7 @@ const result = await client.session.prompt({ | `find.text({ query })` | Search for text in files | Array of match objects with `path`, `lines`, `line_number`, `absolute_offset`, `submatches` | | `find.files({ query })` | Find files by name | `string[]` (file paths) | | `find.symbols({ query })` | Find workspace symbols | Symbol[] | -| `file.read({ query })` | Read a file | `{ type: "raw" \| "patch", content: string }` | +| `file.read({ query })` | Read a file | FileContent | | `file.status({ query? })` | Get status for tracked files | File[] | --- @@ -282,6 +282,16 @@ const files = await client.find.files({ const content = await client.file.read({ query: { path: "src/index.ts" }, }) + +// Handle text vs binary content +if (content.type === "binary") { + const dataUrl = `data:${content.mimeType};base64,${content.content}` + // For images, you can render in a browser context +} else { + // Text content with optional diff/patch metadata + console.log(content.content) + if (content.diff) console.log("Has diff available") +} ``` --- From b91c1440306441d6744d1b3f0318dff40035abbc Mon Sep 17 00:00:00 2001 From: Kyle Crommett Date: Thu, 16 Oct 2025 01:49:33 -0700 Subject: [PATCH 3/4] feat: add base64 encoding for binary content and update data URL construction --- packages/opencode/src/file/index.ts | 28 ++++----------------------- packages/web/src/content/docs/sdk.mdx | 3 ++- 2 files changed, 6 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index a4c33d6d83..d06a1a6768 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -68,6 +68,7 @@ export namespace File { type: z.literal("binary"), content: z.string(), mimeType: z.string(), + encoding: z.literal("base64"), }), ]) .meta({ @@ -75,29 +76,6 @@ export namespace File { }) export type Content = z.infer - function getMimeType(filepath: string): string { - const ext = path.extname(filepath).toLowerCase() - const mimeTypes: Record = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".gif": "image/gif", - ".bmp": "image/bmp", - ".webp": "image/webp", - ".ico": "image/x-icon", - ".svg": "image/svg+xml", - ".zip": "application/zip", - ".tar": "application/x-tar", - ".gz": "application/gzip", - ".pdf": "application/pdf", - ".wasm": "application/wasm", - ".exe": "application/x-msdownload", - ".dll": "application/x-msdownload", - ".so": "application/x-sharedlib", - } - return mimeTypes[ext] || "application/octet-stream" - } - async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise { const ext = path.extname(filepath).toLowerCase() @@ -254,8 +232,10 @@ export namespace File { if (isBinary) { const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0)) + // Base64 keeps binary payloads safe inside the JSON API response const content = Buffer.from(buffer).toString("base64") - return { type: "binary", content, mimeType: getMimeType(full) } + const mimeType = bunFile.type || "application/octet-stream" + return { type: "binary", content, mimeType, encoding: "base64" } } const content = await bunFile diff --git a/packages/web/src/content/docs/sdk.mdx b/packages/web/src/content/docs/sdk.mdx index 6373a54492..130d5a63f2 100644 --- a/packages/web/src/content/docs/sdk.mdx +++ b/packages/web/src/content/docs/sdk.mdx @@ -285,7 +285,8 @@ const content = await client.file.read({ // Handle text vs binary content if (content.type === "binary") { - const dataUrl = `data:${content.mimeType};base64,${content.content}` + // Binary payloads are base64 encoded for JSON transport + const dataUrl = `data:${content.mimeType};${content.encoding},${content.content}` // For images, you can render in a browser context } else { // Text content with optional diff/patch metadata From 3e0d6ecf7574ea0cd267e084b23bd34f1c6699ca Mon Sep 17 00:00:00 2001 From: Kyle Crommett Date: Thu, 16 Oct 2025 12:09:41 -0700 Subject: [PATCH 4/4] fix: improve binary file detection logic in isBinaryFile function --- packages/opencode/src/file/index.ts | 31 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index d06a1a6768..27378b0a90 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,6 +1,6 @@ import z from "zod/v4" import { Bus } from "../bus" -import { $ } from "bun" +import { $, BunFile } from "bun" import { formatPatch, structuredPatch } from "diff" import path from "path" import fs from "fs" @@ -76,26 +76,25 @@ export namespace File { }) export type Content = z.infer - async function isBinaryFile(filepath: string, file: Bun.BunFile): Promise { - const ext = path.extname(filepath).toLowerCase() - - if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".ico"].includes(ext)) { - return true - } - - if ([".zip", ".tar", ".gz", ".exe", ".dll", ".so", ".pdf", ".wasm"].includes(ext)) { - return true + async function isBinaryFile(file: BunFile): Promise { + const type = file.type + if (type) { + if (type.startsWith("text/")) return false + if (type.includes("charset=")) return false + if (type === "application/json") return false + if (type === "application/javascript") return false + if (type === "application/xml") return false + if (type !== "application/octet-stream") return true } const stat = await file.stat() if (stat.size === 0) return false - const bufferSize = Math.min(512, stat.size) - const buffer = await file.arrayBuffer() - const bytes = new Uint8Array(buffer.slice(0, bufferSize)) + const size = Math.min(512, stat.size) + const view = new Uint8Array(await file.slice(0, size).arrayBuffer()) - for (let i = 0; i < bytes.length; i++) { - if (bytes[i] === 0) return true + for (const byte of view) { + if (byte === 0) return true } return false @@ -228,7 +227,7 @@ export namespace File { const full = path.join(Instance.directory, file) const bunFile = Bun.file(full) - const isBinary = await isBinaryFile(full, bunFile) + const isBinary = await isBinaryFile(bunFile) if (isBinary) { const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))