From b82ed35d962abca0dbebd3c3cffd1f6ba26841f7 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 30 Jul 2026 09:44:07 +0200 Subject: [PATCH] tui: bound and guard attachment pastes Local drops could read unbounded files and still insert after the prompt changed during async I/O. Cap staged bytes, share the budget across multi-file drops, and cancel if the prompt moves first. --- .../src/component/dialog-image-preview.tsx | 15 +- packages/tui/src/component/prompt/index.tsx | 78 +++++----- .../src/component/prompt/local-attachment.ts | 92 ++++++++---- packages/tui/test/prompt/clipboard.test.ts | 135 ++++-------------- .../tui/test/prompt/local-attachment.test.ts | 17 ++- 5 files changed, 161 insertions(+), 176 deletions(-) diff --git a/packages/tui/src/component/dialog-image-preview.tsx b/packages/tui/src/component/dialog-image-preview.tsx index f4fa65e529..a17127346c 100644 --- a/packages/tui/src/component/dialog-image-preview.tsx +++ b/packages/tui/src/component/dialog-image-preview.tsx @@ -5,9 +5,9 @@ import { Keymap } from "../context/keymap" import { useTheme } from "../context/theme" import { useDialog } from "../ui/dialog" -export type ImagePreviewItem = Readonly<{ +type ImagePreviewItem = Readonly<{ uri: string - label: string + mention?: Readonly<{ text: string }> }> export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; initial: number }) { @@ -27,15 +27,18 @@ export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; setIndex((value) => (value + direction + props.images.length) % props.images.length) } - createEffect(on(() => current()?.uri, () => setFailed(false))) + 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() }, ], })) @@ -63,7 +66,7 @@ export function DialogImagePreview(props: { images: readonly ImagePreviewItem[]; {props.images.length > 1 ? "← previous" : ""} - {failed() ? "No preview" : current().label} + {failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)} 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 b5f22e1a02..b7ae0f331d 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -3,7 +3,6 @@ import { RGBA, TextareaRenderable, MouseEvent, - MouseButton, PasteEvent, decodePasteBytes, type KeyEvent, @@ -49,10 +48,10 @@ import { useArgs } from "../../context/args" import { useConfig } from "../../config" import { usePromptMove } from "./move" import { - isSupportedLocalAttachmentPath, normalizePastedFilepath, parsePastedFilepaths, readLocalAttachment, + MAX_LOCAL_ATTACHMENT_BYTES, type LocalAttachment, } from "./local-attachment" import { useData } from "../../context/data" @@ -312,31 +311,26 @@ export function Prompt(props: PromptProps) { const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4)))) const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2) const imagePreviewLimit = createMemo(() => - Math.max( - 1, - Math.min(3, Math.floor((Math.min(70, dimensions().width - 9) - 8) / (imagePreviewWidth() + 1))), - ), + Math.max(1, Math.min(3, Math.floor((Math.min(70, dimensions().width - 9) - 8) / (imagePreviewWidth() + 1)))), ) 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}`, - })) + const images = imageAttachments() if (images.length === 0) return dialog.replace(() => ) } - function imagePreviewMouseIndex(event: MouseEvent) { - if (!config.prompt?.image_preview || imageAttachments().length === 0) return + function imagePreviewMouseIndex(event: MouseEvent): number | undefined { + if (!config.prompt?.image_preview || imageAttachments().length === 0) return undefined const x = event.x - anchor.x - 2 const y = event.y - anchor.y - 1 - if (x < 0 || y < 0 || y >= imagePreviewHeight()) return + if (x < 0 || y < 0 || y >= imagePreviewHeight()) return undefined 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() + return undefined } createEffect( @@ -413,7 +407,7 @@ export function Prompt(props: PromptProps) { try { const content = await clipboard.read() if (content?.mime.startsWith("image/")) { - await pasteAttachment({ + pasteAttachment({ filename: "clipboard", uri: `data:${content.mime};base64,${content.data}`, }) @@ -1213,22 +1207,46 @@ export function Prompt(props: PromptProps) { const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform) const isUrl = /^(https?):\/\//.test(filepath) if (!isUrl) { + const promptBefore = { + sessionID: props.sessionID, + text: input.plainText, + cursor: input.cursorOffset, + files: store.prompt.files && unwrap(store.prompt.files), + agents: store.prompt.agents && unwrap(store.prompt.agents), + pasted: unwrap(store.prompt.pasted), + } + const promptChanged = () => + props.sessionID !== promptBefore.sessionID || + input.plainText !== promptBefore.text || + input.cursorOffset !== promptBefore.cursor || + (store.prompt.files && unwrap(store.prompt.files)) !== promptBefore.files || + (store.prompt.agents && unwrap(store.prompt.agents)) !== promptBefore.agents || + unwrap(store.prompt.pasted) !== promptBefore.pasted + const cancelChangedPrompt = () => { + if (!promptChanged()) return false + toast.show({ message: "Attachment drop canceled because the prompt changed", variant: "warning" }) + return true + } const attachment = await readLocalAttachment(filepath) if (attachment) { - await pasteLocalAttachment(filepath, attachment) + if (cancelChangedPrompt()) return + pasteLocalAttachment(filepath, attachment) return } const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform) - if (filepaths.length > 1 && filepaths.every(isSupportedLocalAttachmentPath)) { + if (filepaths.length > 1) { + let remaining = MAX_LOCAL_ATTACHMENT_BYTES const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = [] for (const candidate of filepaths) { - const next = await readLocalAttachment(candidate) + const next = await readLocalAttachment(candidate, remaining) if (!next) break + remaining -= typeof next.content === "string" ? Buffer.byteLength(next.content) : next.content.byteLength attachments.push({ filepath: candidate, attachment: next }) } if (attachments.length === filepaths.length) { - for (const item of attachments) await pasteLocalAttachment(item.filepath, item.attachment) + if (cancelChangedPrompt()) return + for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment) return } } @@ -1249,27 +1267,27 @@ export function Prompt(props: PromptProps) { }, 0) } - async function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) { + function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) { const filename = path.basename(filepath) if (attachment.type === "text") { pasteText(attachment.content, `[SVG: ${filename || "image"}]`) return } - await pasteAttachment({ + pasteAttachment({ filename, uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`, }) } - async function pasteAttachment(file: { filename?: string; uri: string }) { + function pasteAttachment(file: { filename?: string; uri: string }) { const currentOffset = input.cursorOffset const extmarkStart = currentOffset const pdf = file.uri.startsWith("data:application/pdf;") - const prefix = pdf ? "data:application/pdf;" : "data:image/" - const count = - store.prompt.files?.filter( - (attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith(prefix), - ).length ?? 0 + const count = pdf + ? (store.prompt.files?.filter( + (attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith("data:application/pdf;"), + ).length ?? 0) + : imageAttachments().length const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]` const extmarkEnd = extmarkStart + virtualText.length const textToInsert = virtualText + " " @@ -1301,7 +1319,6 @@ export function Prompt(props: PromptProps) { draft.extmarkToPart.set(extmarkId, { type: "file", index }) }), ) - return } function clearPrompt() { @@ -1416,11 +1433,11 @@ export function Prompt(props: PromptProps) { flexGrow={1} width="100%" onMouseDown={(event: MouseEvent) => { - if (event.button !== MouseButton.LEFT || imagePreviewMouseIndex(event) === undefined) return + if (event.button !== 0 || imagePreviewMouseIndex(event) === undefined) return event.preventDefault() }} onMouseUp={(event: MouseEvent) => { - if (event.button !== MouseButton.LEFT) return + if (event.button !== 0) return const index = imagePreviewMouseIndex(event) if (index === undefined) return event.preventDefault() @@ -1429,7 +1446,6 @@ export function Prompt(props: PromptProps) { > 0}> 0 && dimensions().width >= 22}> - readBytes(path: string): Promise + readText(path: string, maxBytes: number): Promise + readBytes(path: string, maxBytes: number): Promise mime(path: string): Promise }> @@ -14,14 +16,15 @@ export type LocalAttachment = | Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }> | Readonly<{ type: "binary"; mime: string; content: Uint8Array }> -export function readLocalAttachment(file: string) { +export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) { return readLocalAttachmentWith( { - readText: (value) => readFile(value, "utf8"), - readBytes: (value) => readFile(value), + readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"), + readBytes: readFileBounded, mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream", }, file, + maxBytes, ) } @@ -36,17 +39,42 @@ const mimeTypes: Record = { ".webp": "image/webp", } +async function readFileBounded(file: string, maxBytes: number) { + const handle = await open(file, "r") + try { + const info = await handle.stat() + if (!info.isFile() || info.size > maxBytes) throw new Error("Attachment exceeds the local file limit") + const content = Buffer.allocUnsafe(info.size + 1) + let offset = 0 + while (offset < content.byteLength) { + const { bytesRead } = await handle.read(content, offset, content.byteLength - offset, offset) + if (bytesRead === 0) break + offset += bytesRead + } + if (offset !== info.size) throw new Error("Attachment changed while being read") + return content.subarray(0, offset) + } finally { + await handle.close() + } +} + export function normalizePastedFilepath(value: string, platform: string) { const raw = value.replace(/^['"]+|['"]+$/g, "") - if (raw.startsWith("file://")) { - try { - return fileURLToPath(raw) - } catch {} - } + const url = decodeFileURL(raw) + if (url) return url if (platform === "win32") return raw return raw.replace(/\\(.)/g, "$1") } +function decodeFileURL(value: string): string | undefined { + if (!value.startsWith("file://")) return undefined + try { + return fileURLToPath(value) + } catch { + return undefined + } +} + export function parsePastedFilepaths(value: string, platform: string) { const result: string[] = [] let current = "" @@ -54,19 +82,25 @@ export function parsePastedFilepaths(value: string, platform: string) { function push() { if (!current) return - result.push(normalizePastedFilepath(current, platform)) + result.push(decodeFileURL(current) ?? current) current = "" } - for (let index = 0; index < value.length; index++) { - const character = value[index] + const input = value.includes("file://") + ? value + .split(/\r?\n/) + .filter((line) => !line.trimStart().startsWith("#")) + .join("\n") + : value + for (let index = 0; index < input.length; index++) { + const character = input[index] if (quote) { if (character === quote) { quote = "" continue } - if (character === "\\" && platform !== "win32" && quote === '"' && index + 1 < value.length) { - current += value[++index] + if (character === "\\" && platform !== "win32" && quote === '"' && index + 1 < input.length) { + current += input[++index] continue } current += character @@ -76,8 +110,8 @@ export function parsePastedFilepaths(value: string, platform: string) { quote = character continue } - if (character === "\\" && platform !== "win32" && index + 1 < value.length) { - current += value[++index] + if (character === "\\" && platform !== "win32" && index + 1 < input.length) { + current += input[++index] continue } if (/\s/.test(character)) { @@ -94,20 +128,20 @@ export function parsePastedFilepaths(value: string, platform: string) { return result } -export function isSupportedLocalAttachmentPath(file: string) { - return path.extname(file).toLowerCase() in mimeTypes -} - -export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise { +export async function readLocalAttachmentWith( + files: LocalFiles, + path: string, + maxBytes = MAX_LOCAL_ATTACHMENT_BYTES, +): Promise { const mime = await files.mime(path).catch(() => undefined) - if (!mime) return + if (!mime) return undefined + if (!mime.startsWith("image/") && mime !== "application/pdf") return undefined if (mime === "image/svg+xml") { - const content = await files.readText(path).catch(() => undefined) - if (!content) return + const content = await files.readText(path, maxBytes).catch(() => undefined) + if (!content || Buffer.byteLength(content) > maxBytes) return undefined return { type: "text", mime, content } } - if (!mime.startsWith("image/") && mime !== "application/pdf") return - const content = await files.readBytes(path).catch(() => undefined) - if (!content) return + const content = await files.readBytes(path, maxBytes).catch(() => undefined) + if (!content || content.byteLength > maxBytes) return undefined return { type: "binary", mime, content } } diff --git a/packages/tui/test/prompt/clipboard.test.ts b/packages/tui/test/prompt/clipboard.test.ts index 24f95d5882..ed29f33d9b 100644 --- a/packages/tui/test/prompt/clipboard.test.ts +++ b/packages/tui/test/prompt/clipboard.test.ts @@ -2,12 +2,7 @@ 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, - TextareaRenderable, - type ClipboardReadResult, - type HostClipboardService, -} from "@opentui/core" +import { ImageRenderable, TextareaRenderable, type ClipboardReadResult, type HostClipboardService } from "@opentui/core" import { createTestRenderer, MouseButtons } from "@opentui/core/testing" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Global } from "@opencode-ai/util/global" @@ -21,6 +16,10 @@ const openTui = { ...(await import("@opentui/core")) } const PNG_1X1_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg==" const PNG_1X1 = Buffer.from(PNG_1X1_BASE64, "base64") +const readPngClipboard = async (): Promise => ({ + status: "read", + representation: { mimeType: "image/png", bytes: PNG_1X1 }, +}) let activeSetup: Awaited> | undefined let activeHost: HostClipboardService | undefined let activePromptRef: PromptRef | undefined @@ -144,26 +143,24 @@ async function mountPrompt(read: () => Promise, imagePrevie } } -test("inserts nonempty whitespace-only terminal paste without reading the host clipboard", async () => { +async function pasteImages(prompt: Awaited>, count: number) { + for (let index = 0; index < count; index++) { + prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) + await prompt.setup.waitFor(() => prompt.reads === index + 1) + } +} + +test("distinguishes whitespace-only terminal paste from empty clipboard fallback", async () => { const prompt = await mountPrompt(async () => ({ status: "empty" })) try { await prompt.setup.mockInput.pasteBracketedText(" \t\n") await prompt.setup.waitFor(() => prompt.input.plainText === " \t\n") - - expect(prompt.input.plainText).toBe(" \t\n") expect(prompt.reads).toBe(0) - } finally { - await prompt.dispose() - } -}) -test("uses one host clipboard read for a zero-byte terminal paste", async () => { - const prompt = await mountPrompt(async () => ({ status: "empty" })) - try { prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) await prompt.setup.waitFor(() => prompt.reads === 1) - expect(prompt.input.plainText).toBe("") + expect(prompt.input.plainText).toBe(" \t\n") expect(prompt.reads).toBe(1) } finally { await prompt.dispose() @@ -185,10 +182,7 @@ test("normalizes host clipboard text once before inserting it", async () => { }) test("creates one image mention from PNG clipboard bytes", async () => { - const prompt = await mountPrompt(async () => ({ - status: "read", - representation: { mimeType: "image/png", bytes: PNG_1X1 }, - })) + const prompt = await mountPrompt(readPngClipboard) try { prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) await prompt.setup.waitFor(() => prompt.input.plainText === "[Image 1] ") @@ -209,63 +203,39 @@ test("creates one image mention from PNG clipboard bytes", async () => { } }) -test("renders at most three left-aligned square image crops", async () => { - const prompt = await mountPrompt( - async () => ({ - status: "read", - representation: { mimeType: "image/png", bytes: PNG_1X1 }, - }), - true, - ) +test("renders at most three left-aligned cropped thumbnails", async () => { + const prompt = await mountPrompt(readPngClipboard, true) try { - for (let index = 0; index < 4; index++) { - prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) - await prompt.setup.waitFor(() => prompt.reads === index + 1) - } + await pasteImages(prompt, 4) 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 = 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(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")) + const frame = await prompt.setup.waitForFrame((frame) => frame.includes("+1 more")) + expect(frame).toMatch(/^┃ █/m) } 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, - ) +test("opens the large image viewer by mouse and command palette", async () => { + const prompt = await mountPrompt(readPngClipboard, true) try { - for (let index = 0; index < 2; index++) { - prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) - await prompt.setup.waitFor(() => prompt.reads === index + 1) - } + await pasteImages(prompt, 2) 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) + expect(large.height).toBeGreaterThan(thumbnail.height) prompt.setup.mockInput.pressArrow("left") await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2")) @@ -273,29 +243,13 @@ test("opens a clicked thumbnail in a keyboard-navigable large preview", async () 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")) + await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2")) } finally { await prompt.dispose() } @@ -313,8 +267,6 @@ test("attaches multiple images from one terminal drop", async () => { 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 }) @@ -322,43 +274,20 @@ test("attaches multiple images from one terminal drop", async () => { }) test("reduces the preview count to fit a narrow terminal", async () => { - const prompt = await mountPrompt( - async () => ({ - status: "read", - representation: { mimeType: "image/png", bytes: PNG_1X1 }, - }), - true, - 32, - ) + const prompt = await mountPrompt(readPngClipboard, true, 32) try { - for (let index = 0; index < 4; index++) { - prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) - await prompt.setup.waitFor(() => prompt.reads === index + 1) - } + await pasteImages(prompt, 4) 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 = 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()) { - expect(preview.x + preview.width).toBeLessThanOrEqual(previews.x + previews.width) - } } finally { await prompt.dispose() } }) test("removes an image preview when its mention is deleted", async () => { - const prompt = await mountPrompt( - async () => ({ - status: "read", - representation: { mimeType: "image/png", bytes: PNG_1X1 }, - }), - true, - ) + const prompt = await mountPrompt(readPngClipboard, true) try { prompt.setup.renderer.keyInput.processPaste(new Uint8Array()) await prompt.setup.waitFor( @@ -407,13 +336,7 @@ test("keeps an attachment when its opt-in preview cannot be decoded", async () = }) test("ignores malformed attachment URIs restored into the prompt", async () => { - const prompt = await mountPrompt( - async () => ({ - status: "read", - representation: { mimeType: "image/png", bytes: PNG_1X1 }, - }), - true, - ) + const prompt = await mountPrompt(readPngClipboard, true) try { const restored = parsePromptInfo({ text: "[Image 1] ", diff --git a/packages/tui/test/prompt/local-attachment.test.ts b/packages/tui/test/prompt/local-attachment.test.ts index 0bb3169761..f547e84eb0 100644 --- a/packages/tui/test/prompt/local-attachment.test.ts +++ b/packages/tui/test/prompt/local-attachment.test.ts @@ -20,6 +20,14 @@ describe("prompt local attachments", () => { "/tmp/one image.png", "/tmp/two.webp", ]) + expect(parsePastedFilepaths("# dropped files\nfile:///tmp/one.png\nfile:///tmp/two.webp", "linux")).toEqual([ + "/tmp/one.png", + "/tmp/two.webp", + ]) + expect(parsePastedFilepaths("/tmp/one\\\\image.png /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", @@ -28,9 +36,9 @@ describe("prompt local attachments", () => { 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( - [], - ) + expect( + parsePastedFilepaths(Array.from({ length: 33 }, (_, index) => `/tmp/${index}.png`).join(" "), "linux"), + ).toEqual([]) }) test("reads SVG attachments as text", async () => { @@ -61,5 +69,8 @@ describe("prompt local attachments", () => { "/tmp/missing.png", ), ).toBeUndefined() + expect( + await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1), + ).toBeUndefined() }) })