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()
})
})