diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index e5023f0dc1..d06a1a6768 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -40,33 +40,67 @@ 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(), + encoding: z.literal("base64"), + }), + ]) .meta({ ref: "FileContent", }) 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 + } + + 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 +222,27 @@ 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)) + // Base64 keeps binary payloads safe inside the JSON API response + const content = Buffer.from(buffer).toString("base64") + const mimeType = bunFile.type || "application/octet-stream" + return { type: "binary", content, mimeType, encoding: "base64" } + } + + 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 +253,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) { 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..130d5a63f2 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,17 @@ 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") { + // 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 + console.log(content.content) + if (content.diff) console.log("Has diff available") +} ``` ---