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
35 changes: 34 additions & 1 deletion bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.15.1",
"@napi-rs/image": "^1.10.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
Expand Down Expand Up @@ -1097,7 +1098,35 @@

"@motionone/utils": ["@motionone/utils@10.18.0", "", { "dependencies": { "@motionone/types": "^10.17.1", "hey-listen": "^1.0.8", "tslib": "^2.3.1" } }, "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw=="],

"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],
"@napi-rs/image": ["@napi-rs/image@1.12.0", "", { "optionalDependencies": { "@napi-rs/image-android-arm64": "1.12.0", "@napi-rs/image-darwin-arm64": "1.12.0", "@napi-rs/image-darwin-x64": "1.12.0", "@napi-rs/image-freebsd-x64": "1.12.0", "@napi-rs/image-linux-arm-gnueabihf": "1.12.0", "@napi-rs/image-linux-arm64-gnu": "1.12.0", "@napi-rs/image-linux-arm64-musl": "1.12.0", "@napi-rs/image-linux-x64-gnu": "1.12.0", "@napi-rs/image-linux-x64-musl": "1.12.0", "@napi-rs/image-wasm32-wasi": "1.12.0", "@napi-rs/image-win32-arm64-msvc": "1.12.0", "@napi-rs/image-win32-ia32-msvc": "1.12.0", "@napi-rs/image-win32-x64-msvc": "1.12.0" } }, "sha512-hfW9EvlUj4R+RYu5Cgcm9/d/2eMTj3Dhypj0BD+snasawBpYwSfkPZx/6C1Hm2cVCbRT6zovGdSHUXwPLb29dw=="],

"@napi-rs/image-android-arm64": ["@napi-rs/image-android-arm64@1.12.0", "", { "os": "android", "cpu": "arm64" }, "sha512-MAm8EHmtO47OZYsHgiMuP+nYZOEbNWbHjkoNfRS9wFJiRQ5p/pIlvdeWL9DqkSrjcgHjIJXLcrt94MMF1jXOuw=="],

"@napi-rs/image-darwin-arm64": ["@napi-rs/image-darwin-arm64@1.12.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NXDXy9qNmNdesKCTWMcKa9QHP74Ut75Lwi4psUzo5e7ptOeK6ACIanVPynnfWGMUXY4pTIXvGooLf5mbn6r7iQ=="],

"@napi-rs/image-darwin-x64": ["@napi-rs/image-darwin-x64@1.12.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-577Ysv/m60Pmv2rahxf2c6ChfNwGyAs3Pyoopao6YxknXnq+M8/x8PW3f9Gxg9+a/nxxnFIYoMDkD1GeQRecXg=="],

"@napi-rs/image-freebsd-x64": ["@napi-rs/image-freebsd-x64@1.12.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-WwQvpQ8FCpFjEHuI5nGKl67oPj4Yc5clbNrSy2wBzIcmuuOjfzGFi96MDZU6gQvRYZz+jbGa71+Fk3bMtbp8ew=="],

"@napi-rs/image-linux-arm-gnueabihf": ["@napi-rs/image-linux-arm-gnueabihf@1.12.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UaIVUREI7q9NpR6mBNyGJ7D8S4vdQ65X2RoUmAv7f89ILvKobyyn8LFx8MqjPU5UpmWQhoU5nATQzKsYF70HoQ=="],

"@napi-rs/image-linux-arm64-gnu": ["@napi-rs/image-linux-arm64-gnu@1.12.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-QwZRPAJjtRAPtrtvn8slmtcMtAWLd15kRL6BxBaAA/VHR/sOzKxCbXbapzf/nHUJUsO7Gy3wQu0SKr7Wjp5K9w=="],

"@napi-rs/image-linux-arm64-musl": ["@napi-rs/image-linux-arm64-musl@1.12.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1laZq0DdtuI6DBFkGRB0tSv/STo11qjGKfCVUndt3JbP4nidO4enRDjofBTu5ROFcxqSdIq/t7hYhlS+BeJRnw=="],

"@napi-rs/image-linux-x64-gnu": ["@napi-rs/image-linux-x64-gnu@1.12.0", "", { "os": "linux", "cpu": "x64" }, "sha512-TaaITRqJXnRip1kV2o+M6dMFyQdKs8zlPHyxZU6sfbjyPfgtkliKXR6kxxug2MVCr7jXuAFdrJRCdfyAeK4MIQ=="],

"@napi-rs/image-linux-x64-musl": ["@napi-rs/image-linux-x64-musl@1.12.0", "", { "os": "linux", "cpu": "x64" }, "sha512-QdvbOh+lekT+gKpYhINOHd4ruEYQHzEVhNqswZ2V3mWH5tbXS9ypjzzfZ1UVeHNfce3NooZ+XsZVGITX/KyUwQ=="],

"@napi-rs/image-wasm32-wasi": ["@napi-rs/image-wasm32-wasi@1.12.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.0" }, "cpu": "none" }, "sha512-tlLe2+4RdJfkT8DOGJ7KpUGYxlhvqaYCKuPoyO2x/LPnfF2kSKO9nLIdBhCNw7v61gfxIEpr2ccz01Mw8FeSjA=="],

"@napi-rs/image-win32-arm64-msvc": ["@napi-rs/image-win32-arm64-msvc@1.12.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-JlyIKiwhMRq0MUGIILaHeqS0Rc2kazdN9t0cjv7DrN5FLwTPmXFQyDsgGEW8oFJ08DKpbn6Dfy/5UogjII+hKg=="],

"@napi-rs/image-win32-ia32-msvc": ["@napi-rs/image-win32-ia32-msvc@1.12.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-nzg7bUJWkVz05zbsBmu4fZ5wj1KFyBbJwEV4OuV5qdz0IFAl37NEannO0yg0Yi9Amavgqmr5y7OmA2TiandpPA=="],

"@napi-rs/image-win32-x64-msvc": ["@napi-rs/image-win32-x64-msvc@1.12.0", "", { "os": "win32", "cpu": "x64" }, "sha512-tTS4k9Bep2bJbymuu523sp8MZDdUaTkjORWkiwOHPFULt2e+pgnCCQ6+gXJ7WAWXvdwKn06gfdMsC2DM8k5EtA=="],

"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="],

"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],

Expand Down Expand Up @@ -4121,6 +4150,10 @@

"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],

"@oxc-minify/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],

"@oxc-transform/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.0.7", "", { "dependencies": { "@emnapi/core": "^1.5.0", "@emnapi/runtime": "^1.5.0", "@tybys/wasm-util": "^0.10.1" } }, "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="],

"@pierre/diffs/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],

"@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ=="],
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.15.1",
"@napi-rs/image": "^1.10.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
Expand Down
88 changes: 66 additions & 22 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
import { ImageOptimizer } from "@/util/image-optimizer"
import { DialogSelect } from "../../ui/dialog-select"
import type { FilePart } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
Expand Down Expand Up @@ -142,6 +144,16 @@ export function Prompt(props: PromptProps) {
const textareaKeybindings = createMemo(() => {
const keybinds = keybind.all

// When a dialog is open (e.g., image compression confirmation), don't bind
// return to submit. This prevents the prompt from being submitted when the
// user presses Enter to confirm the dialog action.
if (dialog.stack.length > 0) {
return [
{ name: "return", meta: true, action: "newline" },
...TEXTAREA_ACTIONS.flatMap((action) => mapTextareaKeybindings(keybinds, action)),
] satisfies KeyBinding[]
}

return [
{ name: "return", action: "submit" },
{ name: "return", meta: true, action: "newline" },
Expand Down Expand Up @@ -219,11 +231,7 @@ export function Prompt(props: PromptProps) {
onSelect: async () => {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
await pasteImage({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
await handleImagePaste(content.data, content.mime, "clipboard")
}
},
},
Expand Down Expand Up @@ -670,6 +678,55 @@ export function Prompt(props: PromptProps) {
)
}

async function handleImagePaste(base64: string, mime: string, filename?: string) {
const buffer = Buffer.from(base64, "base64")
const imageSize = buffer.byteLength

let content = base64
let targetMime = mime

if (ImageOptimizer.needsOptimization(imageSize)) {
const confirmed = await new Promise<boolean>((resolve) => {
dialog.replace(
() => (
<DialogSelect
title={`Large Image (${ImageOptimizer.formatBytes(imageSize)}) — exceeds ${ImageOptimizer.formatBytes(ImageOptimizer.SIZE_LIMIT)} limit`}
options={[
{ value: "compress", title: "Compress" },
{ value: "cancel", title: "Cancel" },
]}
onSelect={(option) => {
resolve(option.value === "compress")
dialog.clear()
}}
/>
),
() => resolve(false),
)
})
if (!confirmed) return

try {
const result = await ImageOptimizer.optimize(buffer)
content = result.data
targetMime = result.mime
} catch (error) {
console.error("Image compression failed:", error)
toast.show({
variant: "error",
message: "Failed to compress image",
})
return
}
}

await pasteImage({
filename,
mime: targetMime,
content,
})
}

async function pasteImage(file: { filename?: string; content: string; mime: string }) {
const currentOffset = input.visualCursor.offset
const extmarkStart = currentOffset
Expand Down Expand Up @@ -805,11 +862,7 @@ export function Prompt(props: PromptProps) {
const content = await Clipboard.read()
if (content?.mime.startsWith("image/")) {
e.preventDefault()
await pasteImage({
filename: "clipboard",
mime: content.mime,
content: content.data,
})
await handleImagePaste(content.data, content.mime, "clipboard")
return
}
// If no image, let the default paste behavior continue
Expand Down Expand Up @@ -905,18 +958,9 @@ export function Prompt(props: PromptProps) {
}
if (file.type.startsWith("image/")) {
event.preventDefault()
const content = await file
.arrayBuffer()
.then((buffer) => Buffer.from(buffer).toString("base64"))
.catch(() => {})
if (content) {
await pasteImage({
filename: file.name,
mime: file.type,
content,
})
return
}
const buffer = await file.arrayBuffer()
await handleImagePaste(Buffer.from(buffer).toString("base64"), file.type, file.name)
return
}
} catch {}
}
Expand Down
83 changes: 79 additions & 4 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { ListTool } from "../tool/ls"
import { FileTime } from "../file/time"
import { ImageOptimizer } from "../util/image-optimizer"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
import { spawn } from "child_process"
Expand All @@ -51,6 +52,34 @@ export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000

/** Compress image if it exceeds size limit, returns original data if compression fails */
function compressImageIfNeeded(
buffer: Buffer,
mime: string,
data: string,
source: string,
): Promise<{ mime: string; data: string }> {
if (!ImageOptimizer.needsOptimization(buffer.length)) {
return Promise.resolve({ mime, data })
}

log.info(`Compressing ${source} image`, {
originalSize: ImageOptimizer.formatBytes(buffer.length),
})

return ImageOptimizer.optimize(buffer)
.then((result) => {
log.info("Compression successful", {
newSize: ImageOptimizer.formatBytes(Buffer.from(result.data, "base64").length),
})
return { mime: result.mime, data: result.data }
})
.catch((error) => {
log.error("Compression failed, using original", { error })
return { mime, data }
})
}

const state = Instance.state(
() => {
const data: Record<
Expand Down Expand Up @@ -683,13 +712,21 @@ export namespace SessionPrompt {
if (contentItem.type === "text") {
textParts.push(contentItem.text)
} else if (contentItem.type === "image") {
const buffer = Buffer.from(contentItem.data, "base64")
const compressed = await compressImageIfNeeded(
buffer,
contentItem.mimeType,
contentItem.data,
"tool-returned",
)

attachments.push({
id: Identifier.ascending("part"),
sessionID: input.sessionID,
messageID: input.processor.message.id,
type: "file",
mime: contentItem.mimeType,
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
mime: compressed.mime,
url: `data:${compressed.mime};base64,${compressed.data}`,
})
}
// Add support for other types if needed
Expand Down Expand Up @@ -921,6 +958,18 @@ export namespace SessionPrompt {

const file = Bun.file(filepath)
FileTime.read(input.sessionID, filepath)
const fileBuffer = Buffer.from(await file.bytes())

let fileMime = part.mime
let fileData = fileBuffer.toString("base64")

// Compress images if needed
if (part.mime?.startsWith("image/")) {
const compressed = await compressImageIfNeeded(fileBuffer, part.mime, fileData, "file-provided")
fileMime = compressed.mime
fileData = compressed.data
}

return [
{
id: Identifier.ascending("part"),
Expand All @@ -935,8 +984,8 @@ export namespace SessionPrompt {
messageID: info.id,
sessionID: input.sessionID,
type: "file",
url: `data:${part.mime};base64,` + Buffer.from(await file.bytes()).toString("base64"),
mime: part.mime,
url: `data:${fileMime};base64,${fileData}`,
mime: fileMime,
filename: part.filename!,
source: part.source,
},
Expand Down Expand Up @@ -965,6 +1014,32 @@ export namespace SessionPrompt {
]
}

// Handle image compression for file parts with data: URLs
if (part.type === "file" && part.mime?.startsWith("image/")) {
const match = part.url.match(/^data:image\/[^;]+;base64,(.+)$/)
if (match) {
const fileMime = part.mime
const buffer = Buffer.from(match[1], "base64")
const compressed = await compressImageIfNeeded(
buffer,
fileMime,
match[1],
"user-provided",
)

return [
{
id: part.id ?? Identifier.ascending("part"),
...part,
mime: compressed.mime,
url: `data:${compressed.mime};base64,${compressed.data}`,
messageID: info.id,
sessionID: input.sessionID,
},
]
}
}

return [
{
id: Identifier.ascending("part"),
Expand Down
Loading