From 16fbb7447f81b111b040b64228a3db8c6b4017d8 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 30 Jul 2026 09:19:34 +0200 Subject: [PATCH] tui: add image preview and multi-drop Thumbnails only showed a few crops with no way to inspect them, and terminal drops could attach only one path. Open a navigable full preview on click or from the command palette, and accept multi-file drops so dragged image sets attach in one paste. --- .../src/component/dialog-image-preview.tsx | 74 ++++++++++ packages/tui/src/component/prompt/index.tsx | 110 ++++++++++---- .../src/component/prompt/local-attachment.ts | 65 ++++++++ packages/tui/test/config-v2.test.tsx | 7 +- packages/tui/test/prompt/clipboard.test.ts | 139 +++++++++++++----- .../tui/test/prompt/local-attachment.test.ts | 24 ++- 6 files changed, 346 insertions(+), 73 deletions(-) create mode 100644 packages/tui/src/component/dialog-image-preview.tsx diff --git a/packages/tui/src/component/dialog-image-preview.tsx b/packages/tui/src/component/dialog-image-preview.tsx new file mode 100644 index 0000000000..f4fa65e529 --- /dev/null +++ b/packages/tui/src/component/dialog-image-preview.tsx @@ -0,0 +1,74 @@ +import { TextAttributes } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { createEffect, createMemo, createSignal, on } from "solid-js" +import { Keymap } from "../context/keymap" +import { useTheme } from "../context/theme" +import { useDialog } from "../ui/dialog" + +export type ImagePreviewItem = Readonly<{ + uri: string + label: string +}> + +export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) { + const dialog = useDialog() + const dimensions = useTerminalDimensions() + const theme = useTheme("elevated") + const [index, setIndex] = createSignal(Math.max(0, Math.min(props.images.length - 1, props.initial))) + const [failed, setFailed] = createSignal(false) + const current = createMemo(() => props.images[index()]) + const imageHeight = createMemo(() => Math.max(3, dimensions().height - 8)) + + dialog.setSize("xlarge") + dialog.setCentered(true) + + function move(direction: number) { + if (props.images.length < 2) return + setIndex((value) => (value + direction + props.images.length) % props.images.length) + } + + createEffect(on(() => current()?.uri, () => setFailed(false))) + + Keymap.createLayer(() => ({ + mode: "modal", + commands: [ + { bind: "left", title: "Previous image", group: "Dialog", run: () => move(-1) }, + { bind: "right", title: "Next image", group: "Dialog", run: () => move(1) }, + { bind: "escape", title: "Close image preview", group: "Dialog", run: () => dialog.clear() }, + { bind: "ctrl+c", title: "Close image preview", group: "Dialog", run: () => dialog.clear() }, + ], + })) + + return ( + + + + Image {index() + 1} of {props.images.length} + + dialog.clear()}> + esc + + + setFailed(true)} + /> + + move(-1)}> + {props.images.length > 1 ? "← previous" : ""} + + + {failed() ? "No preview" : current().label} + + move(1)}> + {props.images.length > 1 ? "next →" : ""} + + + + ) +} diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 0ed15db9ea..fbfb504d6a 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -3,6 +3,7 @@ import { RGBA, TextareaRenderable, MouseEvent, + MouseButton, PasteEvent, decodePasteBytes, type KeyEvent, @@ -10,7 +11,6 @@ import { import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match, For } from "solid-js" import { registerOpencodeSpinner } from "../register-spinner" import path from "path" -import { fileURLToPath } from "url" import { useLocal } from "../../context/local" import { useTheme, useThemes } from "../../context/theme" import { tint } from "../../theme/color" @@ -48,12 +48,19 @@ import { DialogSkill } from "../dialog-skill" import { useArgs } from "../../context/args" import { useConfig } from "../../config" import { usePromptMove } from "./move" -import { readLocalAttachment } from "./local-attachment" +import { + isSupportedLocalAttachmentPath, + normalizePastedFilepath, + parsePastedFilepaths, + readLocalAttachment, + type LocalAttachment, +} from "./local-attachment" import { useData } from "../../context/data" import { useLocation } from "../../context/location" import { Keymap, type KeymapCommand } from "../../context/keymap" import { abbreviateHome } from "../../runtime" import { PluginSlot } from "../../plugin/context" +import { DialogImagePreview } from "../dialog-image-preview" registerOpencodeSpinner() @@ -72,17 +79,6 @@ export type PromptProps = { } } -function pastedFilepath(value: string, platform: string) { - const raw = value.replace(/^['"]+|['"]+$/g, "") - if (raw.startsWith("file://")) { - try { - return fileURLToPath(raw) - } catch {} - } - if (platform === "win32") return raw - return raw.replace(/\\(.)/g, "$1") -} - export type PromptRef = { focused: boolean current: PromptInfo @@ -94,7 +90,6 @@ export type PromptRef = { } const DRAFT_RETENTION_MIN_CHARS = 20 -const MAX_IMAGE_PREVIEWS = 3 function randomIndex(count: number) { if (count <= 0) return 0 @@ -320,15 +315,31 @@ export function Prompt(props: PromptProps) { const imagePreviewLimit = createMemo(() => Math.max( 1, - Math.min( - MAX_IMAGE_PREVIEWS, - Math.floor((Math.min(70, Math.max(1, dimensions().width - 9)) - 8) / (imagePreviewWidth() + 1)), - ), + Math.min(3, Math.floor((Math.min(70, dimensions().width - 9) - 8) / (imagePreviewWidth() + 1))), ), ) - const visibleImageAttachments = createMemo(() => imageAttachments().slice(0, imagePreviewLimit())) const hiddenImageAttachmentCount = createMemo(() => Math.max(0, imageAttachments().length - imagePreviewLimit())) + function openImagePreview(initial: number) { + const images = imageAttachments().map((file, index) => ({ + uri: file.uri, + label: file.mention?.text ?? `Image ${index + 1}`, + })) + if (images.length === 0) return + dialog.replace(() => ) + } + + function imagePreviewMouseIndex(event: MouseEvent) { + if (!config.prompt?.image_preview || imageAttachments().length === 0) return + const x = event.x - anchor.x - 2 + const y = event.y - anchor.y - 1 + if (x < 0 || y < 0 || y >= imagePreviewHeight()) return + const stride = imagePreviewWidth() + 1 + const index = Math.floor(x / stride) + if (index < imagePreviewLimit() && x % stride < imagePreviewWidth()) return index + if (index === imagePreviewLimit() && hiddenImageAttachmentCount() > 0) return imagePreviewLimit() + } + createEffect( on( () => props.sessionID, @@ -417,6 +428,13 @@ export function Prompt(props: PromptProps) { } }, }, + { + title: "View image attachments", + name: "prompt.images.view", + category: "Prompt", + enabled: imageAttachments().length > 0, + run: () => openImagePreview(0), + }, { title: "Interrupt session", name: "session.interrupt", @@ -1190,21 +1208,27 @@ export function Prompt(props: PromptProps) { async function pasteInputText(text: string) { const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const pastedContent = normalizedText.trim() - const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform) + const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform) const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { const attachment = await readLocalAttachment(filepath) - const filename = path.basename(filepath) - if (attachment?.type === "text") { - pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`) + if (attachment) { + await pasteLocalAttachment(filepath, attachment) return } - if (attachment?.type === "binary") { - await pasteAttachment({ - filename, - uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`, - }) - return + + const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform) + if (filepaths.length > 1 && filepaths.every(isSupportedLocalAttachmentPath)) { + const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = [] + for (const candidate of filepaths) { + const next = await readLocalAttachment(candidate) + if (!next) break + attachments.push({ filepath: candidate, attachment: next }) + } + if (attachments.length === filepaths.length) { + for (const item of attachments) await pasteLocalAttachment(item.filepath, item.attachment) + return + } } } @@ -1223,6 +1247,18 @@ export function Prompt(props: PromptProps) { }, 0) } + async function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) { + const filename = path.basename(filepath) + if (attachment.type === "text") { + pasteText(attachment.content, `[SVG: ${filename || "image"}]`) + return + } + await pasteAttachment({ + filename, + uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`, + }) + } + async function pasteAttachment(file: { filename?: string; uri: string }) { const currentOffset = input.cursorOffset const extmarkStart = currentOffset @@ -1372,8 +1408,19 @@ export function Prompt(props: PromptProps) { backgroundColor={promptBg()} flexGrow={1} width="100%" + onMouseDown={(event: MouseEvent) => { + if (event.button !== MouseButton.LEFT || imagePreviewMouseIndex(event) === undefined) return + event.preventDefault() + }} + onMouseUp={(event: MouseEvent) => { + if (event.button !== MouseButton.LEFT) return + const index = imagePreviewMouseIndex(event) + if (index === undefined) return + event.preventDefault() + openImagePreview(index) + }} > - 0}> + 0}> - + {(file, index) => { const [failed, setFailed] = createSignal(false) return ( @@ -33,6 +36,68 @@ const mimeTypes: Record = { ".webp": "image/webp", } +export function normalizePastedFilepath(value: string, platform: string) { + const raw = value.replace(/^['"]+|['"]+$/g, "") + if (raw.startsWith("file://")) { + try { + return fileURLToPath(raw) + } catch {} + } + if (platform === "win32") return raw + return raw.replace(/\\(.)/g, "$1") +} + +export function parsePastedFilepaths(value: string, platform: string) { + const result: string[] = [] + let current = "" + let quote = "" + + function push() { + if (!current) return + result.push(normalizePastedFilepath(current, platform)) + current = "" + } + + for (let index = 0; index < value.length; index++) { + const character = value[index] + if (quote) { + if (character === quote) { + quote = "" + continue + } + if (character === "\\" && platform !== "win32" && quote === '"' && index + 1 < value.length) { + current += value[++index] + continue + } + current += character + continue + } + if (character === "'" || character === '"') { + quote = character + continue + } + if (character === "\\" && platform !== "win32" && index + 1 < value.length) { + current += value[++index] + continue + } + if (/\s/.test(character)) { + push() + if (result.length > MAX_PASTED_FILEPATHS) return [] + continue + } + current += character + } + + if (quote) return [] + push() + if (result.length > MAX_PASTED_FILEPATHS) return [] + return result +} + +export function isSupportedLocalAttachmentPath(file: string) { + return path.extname(file).toLowerCase() in mimeTypes +} + export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise { const mime = await files.mime(path).catch(() => undefined) if (!mime) return diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 8cc2f04c50..3a885ae985 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -14,16 +14,11 @@ test("validates mini replay settings", () => { expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow() }) -test("validates the session tabs setting", () => { +test("validates boolean settings", () => { const decode = Schema.decodeUnknownSync(Info) expect(decode({ tabs: { enabled: true } })).toEqual({ tabs: { enabled: true } }) expect(() => decode({ tabs: { enabled: "on" } })).toThrow() -}) - -test("validates the prompt image preview setting", () => { - const decode = Schema.decodeUnknownSync(Info) - expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } }) expect(() => decode({ prompt: { image_preview: "on" } })).toThrow() }) diff --git a/packages/tui/test/prompt/clipboard.test.ts b/packages/tui/test/prompt/clipboard.test.ts index 49b1291517..24f95d5882 100644 --- a/packages/tui/test/prompt/clipboard.test.ts +++ b/packages/tui/test/prompt/clipboard.test.ts @@ -1,12 +1,14 @@ import { afterAll, expect, mock, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" import { ImageRenderable, - Renderable, TextareaRenderable, type ClipboardReadResult, type HostClipboardService, } from "@opentui/core" -import { createTestRenderer } from "@opentui/core/testing" +import { createTestRenderer, MouseButtons } from "@opentui/core/testing" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/util/global" import { Effect, FileSystem } from "effect" @@ -16,20 +18,9 @@ import { parsePromptInfo } from "../../src/prompt/history" import { createEventStream, createFetch } from "../fixture/tui-client" const openTui = { ...(await import("@opentui/core")) } -const PNG_1X1 = Uint8Array.from( - Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg==", - "base64", - ), -) - -function findRenderable(root: Renderable, id: string): Renderable | undefined { - if (root.id === id) return root - return root - .getChildren() - .map((child) => findRenderable(child, id)) - .find(Boolean) -} +const PNG_1X1_BASE64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg==" +const PNG_1X1 = Buffer.from(PNG_1X1_BASE64, "base64") let activeSetup: Awaited> | undefined let activeHost: HostClipboardService | undefined let activePromptRef: PromptRef | undefined @@ -206,12 +197,12 @@ test("creates one image mention from PNG clipboard bytes", async () => { expect(prompt.input.extmarks.getVirtual()).toHaveLength(1) expect(prompt.prompt.current.files).toEqual([ { - uri: `data:image/png;base64,${Buffer.from(PNG_1X1).toString("base64")}`, + uri: `data:image/png;base64,${PNG_1X1_BASE64}`, name: "clipboard", mention: { start: 0, end: 9, text: "[Image 1]" }, }, ]) - expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0")).toBeUndefined() + expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined() expect(prompt.reads).toBe(1) } finally { await prompt.dispose() @@ -232,24 +223,104 @@ test("renders at most three left-aligned square image crops", async () => { await prompt.setup.waitFor(() => prompt.reads === index + 1) } - const first = findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0") - expect(first).toBeInstanceOf(ImageRenderable) + const first = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0") if (!(first instanceof ImageRenderable)) throw new Error("Image preview did not render") await first.loadPromise - const previews = findRenderable(prompt.setup.renderer.root, "prompt-image-previews") + const previews = prompt.setup.renderer.root.findDescendantById("prompt-image-previews") if (!previews) throw new Error("Image preview row did not render") expect(first.fit).toBe("cover") expect(first.x).toBe(previews.x) expect(first.width).toBe(first.height * 2) - expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-1")).toBeInstanceOf(ImageRenderable) - expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-2")).toBeInstanceOf(ImageRenderable) - expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-3")).toBeUndefined() + expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")).toBeInstanceOf(ImageRenderable) + expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-2")).toBeInstanceOf(ImageRenderable) + expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-3")).toBeUndefined() await prompt.setup.waitForFrame((frame) => frame.includes("+1 more")) } finally { await prompt.dispose() } }) +test("opens a clicked thumbnail in a keyboard-navigable large preview", async () => { + const prompt = await mountPrompt( + async () => ({ + status: "read", + representation: { mimeType: "image/png", bytes: PNG_1X1 }, + }), + true, + ) + try { + for (let index = 0; index < 2; index++) { + prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) + await prompt.setup.waitFor(() => prompt.reads === index + 1) + } + + const thumbnail = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1") + if (!(thumbnail instanceof ImageRenderable)) throw new Error("Second image thumbnail did not render") + const card = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-card-1") + if (!card) throw new Error("Second image thumbnail card did not render") + await prompt.setup.mockMouse.click(15, 1, MouseButtons.LEFT) + + await prompt.setup.waitForFrame((frame) => frame.includes("Image 2 of 2")) + const large = prompt.setup.renderer.root.findDescendantById("prompt-image-viewer-image") + expect(large).toBeInstanceOf(ImageRenderable) + if (!(large instanceof ImageRenderable)) throw new Error("Large image preview did not render") + expect(large.fit).toBe("fit") + expect(large.width).toBeGreaterThan(card.width) + + prompt.setup.mockInput.pressArrow("left") + await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2")) + prompt.setup.mockInput.pressCtrlC() + await prompt.setup.waitForFrame((frame) => !frame.includes("Image 1 of 2")) + expect(large.isDestroyed).toBe(true) + await prompt.setup.waitFor(() => prompt.setup.renderer.currentFocusedEditor === prompt.input) + } finally { + await prompt.dispose() + } +}) + +test("opens image attachments from the command palette", async () => { + const prompt = await mountPrompt( + async () => ({ + status: "read", + representation: { mimeType: "image/png", bytes: PNG_1X1 }, + }), + true, + ) + try { + prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) + await prompt.setup.waitFor(() => prompt.reads === 1) + + prompt.setup.mockInput.pressKey("p", { ctrl: true }) + await prompt.setup.waitForFrame((frame) => frame.includes("Commands")) + for (const key of "view image attachments") prompt.setup.mockInput.pressKey(key) + await prompt.setup.waitForFrame((frame) => frame.includes("View image attachments")) + prompt.setup.mockInput.pressEnter() + await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 1")) + } finally { + await prompt.dispose() + } +}) + +test("attaches multiple images from one terminal drop", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "opencode-drop-")) + const first = path.join(directory, "one image.png") + const second = path.join(directory, "two image.png") + await Promise.all([writeFile(first, PNG_1X1), writeFile(second, PNG_1X1)]) + const prompt = await mountPrompt(async () => ({ status: "empty" }), true) + try { + await prompt.setup.mockInput.pasteBracketedText(`'${first}' '${second}'`) + await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 2) + + expect(prompt.input.plainText).toBe("[Image 1] [Image 2] ") + expect(prompt.prompt.current.files?.map((file) => file.name)).toEqual(["one image.png", "two image.png"]) + expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeInstanceOf(ImageRenderable) + expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")).toBeInstanceOf(ImageRenderable) + } finally { + await prompt.dispose() + await rm(directory, { recursive: true, force: true }) + } +}) + test("reduces the preview count to fit a narrow terminal", async () => { const prompt = await mountPrompt( async () => ({ @@ -265,11 +336,11 @@ test("reduces the preview count to fit a narrow terminal", async () => { await prompt.setup.waitFor(() => prompt.reads === index + 1) } - expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0")).toBeInstanceOf(ImageRenderable) - expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-1")).toBeUndefined() + expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeInstanceOf(ImageRenderable) + expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-1")).toBeUndefined() await prompt.setup.waitForFrame((frame) => frame.includes("+3 more")) - const previews = findRenderable(prompt.setup.renderer.root, "prompt-image-previews") - const overflow = findRenderable(prompt.setup.renderer.root, "prompt-image-overflow") + const previews = prompt.setup.renderer.root.findDescendantById("prompt-image-previews") + const overflow = prompt.setup.renderer.root.findDescendantById("prompt-image-overflow") if (!previews || !overflow) throw new Error("Narrow preview layout did not render") expect(overflow.x + overflow.width).toBeLessThanOrEqual(previews.x + previews.width) for (const preview of previews.getChildren()) { @@ -291,9 +362,9 @@ test("removes an image preview when its mention is deleted", async () => { try { prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) await prompt.setup.waitFor( - () => findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0") instanceof ImageRenderable, + () => prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0") instanceof ImageRenderable, ) - const preview = findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0") + const preview = prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0") if (!(preview instanceof ImageRenderable)) throw new Error("Image preview did not render") await preview.loadPromise const image = preview.image @@ -305,7 +376,7 @@ test("removes an image preview when its mention is deleted", async () => { await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 0) expect(prompt.input.plainText).toBe("") - expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0")).toBeUndefined() + expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined() expect(() => image!.info()).toThrow("NativeImage is disposed") } finally { await prompt.dispose() @@ -355,13 +426,11 @@ test("ignores malformed attachment URIs restored into the prompt", async () => { await prompt.setup.renderOnce() expect(prompt.prompt.current.text).toBe("[Image 1] ") - expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0")).toBeUndefined() + expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined() prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) await prompt.setup.waitFor(() => prompt.reads === 1 && prompt.prompt.current.files?.length === 1) - expect(prompt.prompt.current.files?.[0]?.uri).toBe( - `data:image/png;base64,${Buffer.from(PNG_1X1).toString("base64")}`, - ) + expect(prompt.prompt.current.files?.[0]?.uri).toBe(`data:image/png;base64,${PNG_1X1_BASE64}`) } finally { await prompt.dispose() } diff --git a/packages/tui/test/prompt/local-attachment.test.ts b/packages/tui/test/prompt/local-attachment.test.ts index fb86847d0b..0bb3169761 100644 --- a/packages/tui/test/prompt/local-attachment.test.ts +++ b/packages/tui/test/prompt/local-attachment.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { readLocalAttachmentWith } from "../../src/component/prompt/local-attachment" +import { parsePastedFilepaths, readLocalAttachmentWith } from "../../src/component/prompt/local-attachment" import type { LocalFiles } from "../../src/component/prompt/local-attachment" function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles { @@ -11,6 +11,28 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Loca } describe("prompt local attachments", () => { + test("parses multi-file drops from POSIX, URI-list, and Windows terminals", () => { + expect(parsePastedFilepaths("'/tmp/one image.png' /tmp/two\\ image.webp", "linux")).toEqual([ + "/tmp/one image.png", + "/tmp/two image.webp", + ]) + expect(parsePastedFilepaths("file:///tmp/one%20image.png\r\nfile:///tmp/two.webp", "linux")).toEqual([ + "/tmp/one image.png", + "/tmp/two.webp", + ]) + expect(parsePastedFilepaths('"C:\\one image.png" "C:\\two.webp"', "win32")).toEqual([ + "C:\\one image.png", + "C:\\two.webp", + ]) + }) + + test("rejects unbounded and malformed multi-file drops", () => { + expect(parsePastedFilepaths("'/tmp/one.png /tmp/two.png", "linux")).toEqual([]) + expect(parsePastedFilepaths(Array.from({ length: 33 }, (_, index) => `/tmp/${index}.png`).join(" "), "linux")).toEqual( + [], + ) + }) + test("reads SVG attachments as text", async () => { expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "" }), "/tmp/image.svg")).toEqual({ type: "text",