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",