feat(tui): use canonical prompt attachments

This commit is contained in:
Dax Raad 2026-07-06 14:34:57 -04:00
commit c13f06c30c
24 changed files with 611 additions and 466 deletions

88
packages/core/src/mime.ts Normal file
View file

@ -0,0 +1,88 @@
export * as Mime from "./mime.js"
import { Effect, FileSystem, Option } from "effect"
import { fileURLToPath } from "url"
import { FSUtil } from "./fs-util"
const SAMPLE_BYTES = 8192
export const resolve = Effect.fn("Mime.resolve")(function* (uri: string) {
const data = dataSample(uri)
if (data) return detect(data)
const target = yield* Effect.try({
try: () => localPath(uri),
catch: () => new Error("Invalid file URI"),
}).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!target) return "application/octet-stream"
const fs = yield* FSUtil.Service
const local = yield* Effect.scoped(
Effect.gen(function* () {
const info = yield* fs.stat(target)
if (info.type === "Directory") return { type: "directory" as const }
if (info.type !== "File") return
const file = yield* fs.open(target)
return {
type: "file" as const,
sample: Option.getOrElse(yield* file.readAlloc(FileSystem.Size(SAMPLE_BYTES)), () => new Uint8Array()),
}
}),
).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (local?.type === "directory") return "application/x-directory"
if (local?.type === "file") return detect(local.sample)
return "application/octet-stream"
})
function detect(bytes: Uint8Array) {
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp"
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50]))
return "image/webp"
if (
startsWith(bytes.subarray(4), [0x66, 0x74, 0x79, 0x70]) &&
(startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x66]) ||
startsWith(bytes.subarray(8), [0x61, 0x76, 0x69, 0x73]))
)
return "image/avif"
return isText(bytes) ? "text/plain" : "application/octet-stream"
}
function dataSample(uri: string) {
if (!uri.startsWith("data:")) return
const comma = uri.indexOf(",")
if (comma === -1) return new Uint8Array()
const metadata = uri.slice(5, comma)
const payload = uri.slice(comma + 1)
if (metadata.split(";").some((part) => part.toLowerCase() === "base64")) {
return Buffer.from(payload.slice(0, Math.ceil((SAMPLE_BYTES * 4) / 3) + 4), "base64").subarray(0, SAMPLE_BYTES)
}
return new TextEncoder().encode(payload.slice(0, SAMPLE_BYTES))
}
function localPath(uri: string) {
if (!URL.canParse(uri)) return
const url = new URL(uri)
if (url.protocol !== "file:") return
return fileURLToPath(url)
}
function startsWith(bytes: Uint8Array, prefix: number[]) {
return prefix.every((value, index) => bytes[index] === value)
}
function isText(bytes: Uint8Array) {
if (bytes.length === 0) return true
if (bytes.includes(0)) return false
try {
new TextDecoder("utf-8", { fatal: true }).decode(bytes, { stream: true })
} catch {
return false
}
const controls = bytes.reduce((count, byte) => count + Number(byte < 9 || (byte > 13 && byte < 32)), 0)
return controls / bytes.length <= 0.3
}

View file

@ -37,6 +37,7 @@ import { SessionCompaction } from "./session/compaction"
import { SessionRevert } from "./session/revert"
import { Revert } from "@opencode-ai/schema/revert"
import { FSUtil } from "./fs-util"
import { Mime } from "./mime"
import type { EventLog } from "@opencode-ai/schema/event-log"
import { SkillV2 } from "./skill"
import { Job } from "./job"
@ -44,6 +45,7 @@ import { CommandV2 } from "./command"
import { Shell } from "./shell"
import { Shell as ShellSchema } from "@opencode-ai/schema/shell"
import { KeyedMutex } from "./effect/keyed-mutex"
import { fileURLToPath } from "url"
export const RevertState = Revert.State
export type RevertState = Revert.State
@ -251,6 +253,7 @@ const layer = Layer.effect(
const execution = yield* SessionExecution.Service
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const fs = yield* FSUtil.Service
const jobs = yield* Job.Service
const scope = yield* Scope.Scope
const activeShells = new Set<SessionSchema.ID>()
@ -456,7 +459,7 @@ const layer = Layer.effect(
// continues from the reverted boundary rather than stale post-boundary history.
if (session.revert)
yield* SessionRevert.commit(session).pipe(Effect.provideService(EventV2.Service, events))
const prompt = resolvePrompt(input.prompt)
const prompt = yield* resolvePrompt(input.prompt).pipe(Effect.provideService(FSUtil.Service, fs))
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
@ -713,19 +716,52 @@ function synthesizeTerminalShellInfo(started: ShellSchema.Info): ShellSchema.Inf
}
}
const resolvePrompt = (input: PromptInput.Prompt) =>
Prompt.make({
text: input.text,
agents: input.agents,
files: input.files?.map((file) => {
const dataMime = file.uri.match(/^data:([^;,]+)[;,]/i)?.[1]
const target = URL.canParse(file.uri) ? new URL(file.uri).pathname : (file.name ?? file.uri)
return {
...file,
mime: dataMime ?? (target.endsWith("/") ? "application/x-directory" : FSUtil.mimeType(target)),
}
const resolvePrompt = Effect.fn("V2Session.resolvePrompt")(function* (input: PromptInput.Prompt) {
const fs = yield* FSUtil.Service
const files = input.files
? yield* Effect.forEach(
input.files,
(file) =>
Effect.gen(function* () {
const mime = yield* Mime.resolve(file.uri)
const content = mime === "text/plain" ? yield* readTextAttachment(fs, file.uri) : undefined
return { ...file, mime, ...(content === undefined ? {} : { content }) }
}),
{ concurrency: 8 },
)
: undefined
return Prompt.make({ text: input.text, agents: input.agents, files })
})
function readTextAttachment(fs: FSUtil.Interface, uri: string) {
if (uri.startsWith("data:")) return Effect.succeed(undefined)
return Effect.try({
try: () => new URL(uri),
catch: () => new Error("Invalid attachment URI"),
}).pipe(
Effect.flatMap((url) => {
if (url.protocol !== "file:") return Effect.succeed(undefined)
const start = positiveInt(url.searchParams.get("start"))
const end = positiveInt(url.searchParams.get("end"))
url.search = ""
url.hash = ""
return Effect.try({
try: () => fileURLToPath(url),
catch: () => new Error("Invalid file URI"),
}).pipe(
Effect.flatMap((target) => fs.readFileString(target)),
Effect.map((content) => (start === undefined ? content : content.split("\n").slice(start - 1, end).join("\n"))),
)
}),
})
Effect.catch(() => Effect.succeed(undefined)),
)
}
function positiveInt(value: string | null) {
if (value === null) return
const parsed = Number(value)
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
}
// Mirrors the shell tool's in-memory preview safety limit.
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
@ -742,5 +778,6 @@ export const node = makeGlobalNode({
SessionStore.node,
LocationServiceMap.node,
SessionProjector.node,
FSUtil.node,
],
})

View file

@ -19,6 +19,41 @@ const media = (file: FileAttachment): ContentPart => ({
metadata: file.description === undefined ? undefined : { description: file.description },
})
const textAttachment = (file: FileAttachment) =>
Message.make({
role: "user",
content: [
`Attached file: ${file.name ?? file.uri}`,
`Source: ${file.uri}`,
file.description === undefined ? undefined : `Description: ${file.description}`,
"",
file.content ?? readTextData(file.uri) ?? "[Attachment content unavailable]",
]
.filter((line): line is string => line !== undefined)
.join("\n"),
metadata: {
attachment: {
uri: file.uri,
name: file.name,
description: file.description,
},
},
})
function readTextData(uri: string) {
if (!uri.startsWith("data:")) return
const comma = uri.indexOf(",")
if (comma === -1) return
const metadata = uri.slice(5, comma)
const payload = uri.slice(comma + 1)
if (metadata.split(";").some((part) => part.toLowerCase() === "base64")) return Buffer.from(payload, "base64").toString("utf8")
try {
return decodeURIComponent(payload)
} catch {
return
}
}
const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const toolInput = (tool: SessionMessage.AssistantTool) =>
@ -117,11 +152,15 @@ function toLLMMessage(message: SessionMessage.Message, model: Model): Message[]
case "model-switched":
return []
case "user":
const files = message.files ?? []
return [
...files
.filter((file) => file.mime === "text/plain")
.map(textAttachment),
Message.make({
id: message.id,
role: "user",
content: [{ type: "text", text: message.text }, ...(message.files ?? []).map(media)],
content: [{ type: "text", text: message.text }, ...files.filter((file) => file.mime !== "text/plain").map(media)],
metadata: {
...message.metadata,
...(message.agents?.length ? { agents: message.agents } : {}),