Compare commits

...

10 Commits

Author SHA1 Message Date
Simon Klee 73dfbe323e tui: open transcript images on click
Make inline transcript images clickable and show the full preview. Use
bounded thumbnails so image attachments remain usable without crowding the
session view.
2026-07-31 18:52:58 +02:00
Simon Klee 8c3b64b130 fix(tui): gate transcript image previews 2026-07-31 18:52:58 +02:00
Simon Klee 913d9d20e8 feat(tui): render session tool images 2026-07-31 18:52:58 +02:00
Simon Klee efa898ba2a test(tui): simplify prompt preload fixture 2026-07-31 18:52:41 +02:00
Simon Klee bfddb33cbb test(tui): update prompt fixture preload 2026-07-31 18:52:41 +02:00
Simon Klee a5cc34b2ec tui: bind leader+i to image viewer
Default <leader>i opens the prompt image attachment
viewer from the keyboard. Drop thumbnail captions so
previews stay compact and fill their allotted height.
2026-07-31 18:52:41 +02:00
Simon Klee bbb7298d90 tui: serialize clipboard and paste queue
Concurrent clipboard and terminal pastes raced the prompt, so later
work could clobber earlier attachments or touch a destroyed input.
Queue pastes FIFO, drop stale work after prompt mutations, and keep
hit testing aligned with thumbnail geometry so overflow stays usable.
2026-07-31 18:52:41 +02:00
Simon Klee a89944497c 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.
2026-07-31 18:52:41 +02:00
Simon Klee ec28ef49b8 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.
2026-07-31 18:52:41 +02:00
Simon Klee 9e00ec4bd6 tui: add opt-in prompt image previews 2026-07-31 18:52:41 +02:00
13 changed files with 1194 additions and 152 deletions
@@ -93,6 +93,15 @@ export const settings: Setting[] = [
values: ["none", "auto"],
keywords: ["transcript", "messages"],
},
{
title: "Transcript images",
category: "Session",
path: ["session", "image_preview"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["attachments", "images", "tool output"],
},
{
title: "Enabled",
category: "Tabs",
@@ -189,6 +198,15 @@ export const settings: Setting[] = [
values: ["compact", "full"],
keywords: ["paste summary", "clipboard", "pasted content"],
},
{
title: "Image previews",
category: "Input",
path: ["prompt", "image_preview"],
default: false,
values: [false, true],
labels: ["off", "on"],
keywords: ["attachments", "clipboard", "images", "prompt"],
},
{
title: "Leader timeout",
category: "Input",
@@ -0,0 +1,77 @@
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"
type ImagePreviewItem = Readonly<{
uri: string
mention?: Readonly<{ text: 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) },
],
}))
return (
<box id="image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Image {index() + 1} of {props.images.length}
</text>
<text fg={theme.text.subdued} onMouseUp={() => dialog.clear()}>
esc
</text>
</box>
<image
id="image-viewer-image"
source={current().uri}
fit="fit"
protocol="auto"
width="100%"
height={imageHeight()}
onError={() => setFailed(true)}
/>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text.subdued} onMouseUp={() => move(-1)}>
{props.images.length > 1 ? "← previous" : ""}
</text>
<text fg={failed() ? theme.text.feedback.error.default : theme.text.subdued} wrapMode="none" truncate>
{failed() ? "No preview" : (current().mention?.text ?? `Image ${index() + 1}`)}
</text>
<text fg={theme.text.subdued} onMouseUp={() => move(1)}>
{props.images.length > 1 ? "next →" : ""}
</text>
</box>
</box>
)
}
+261 -51
View File
@@ -7,10 +7,9 @@ import {
decodePasteBytes,
type KeyEvent,
} from "@opentui/core"
import { createEffect, createMemo, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js"
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 +47,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 {
normalizePastedFilepath,
parsePastedFilepaths,
readLocalAttachment,
MAX_LOCAL_ATTACHMENT_BYTES,
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 +78,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
@@ -310,6 +305,114 @@ export function Prompt(props: PromptProps) {
extmarkToPart: new Map(),
interrupt: 0,
})
let disposed = false
let pasteQueue = Promise.resolve()
let pasteEpoch = 0
let pasteMutating = false
let pasteMutation = 0
function capturePrompt() {
return {
epoch: pasteEpoch,
sessionID: props.sessionID,
mode: store.mode,
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),
}
}
function promptChanged(before: ReturnType<typeof capturePrompt>) {
if (disposed || input.isDestroyed) return true
return (
pasteEpoch !== before.epoch ||
props.sessionID !== before.sessionID ||
store.mode !== before.mode ||
input.plainText !== before.text ||
input.cursorOffset !== before.cursor ||
(store.prompt.files && unwrap(store.prompt.files)) !== before.files ||
(store.prompt.agents && unwrap(store.prompt.agents)) !== before.agents ||
unwrap(store.prompt.pasted) !== before.pasted
)
}
function cancelChangedPrompt(before: ReturnType<typeof capturePrompt>) {
if (!promptChanged(before)) return false
pasteEpoch = Math.max(pasteEpoch, before.epoch + 1)
if (!disposed && !input.isDestroyed) {
toast.show({ message: "Attachment paste canceled because the prompt changed", variant: "warning" })
}
return true
}
function enqueuePaste(run: (before: ReturnType<typeof capturePrompt>) => Promise<void>) {
const epoch = pasteEpoch
pasteQueue = pasteQueue
.then(async () => {
if (disposed || epoch !== pasteEpoch) return
await run(capturePrompt())
})
.catch((error) => {
if (!disposed) toast.error(error)
})
return pasteQueue
}
function setPromptMode(mode: "normal" | "shell") {
if (store.mode === mode) return
pasteEpoch++
setStore("mode", mode)
}
function applyPaste(run: () => void) {
const mutation = ++pasteMutation
pasteMutating = true
try {
run()
} finally {
queueMicrotask(() => {
if (pasteMutation === mutation) pasteMutating = false
})
}
}
const imageAttachments = createMemo(() =>
(store.prompt.files ?? []).filter((file) => typeof file.uri === "string" && file.uri.startsWith("data:image/")),
)
const imagePreviewHeight = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const imagePreviewWidth = createMemo(() => imagePreviewHeight() * 2)
const imagePreviewAvailableWidth = createMemo(() => Math.min(70, Math.max(0, dimensions().width - 9)))
const imagePreviewLimit = createMemo(() =>
Math.max(1, Math.min(3, Math.floor((imagePreviewAvailableWidth() - 8) / (imagePreviewWidth() + 1)))),
)
const visibleImageCount = createMemo(() => Math.min(imagePreviewLimit(), imageAttachments().length))
const hiddenImageAttachmentCount = createMemo(() => imageAttachments().length - visibleImageCount())
const imagePreviewsVisible = createMemo(
() => imageAttachments().length > 0 && imagePreviewAvailableWidth() >= imagePreviewWidth(),
)
const imageOverflowVisible = createMemo(
() => hiddenImageAttachmentCount() > 0 && imagePreviewAvailableWidth() >= imagePreviewWidth() + 9,
)
function openImagePreview(initial: number) {
const images = imageAttachments()
if (images.length === 0) return
dialog.replace(() => <DialogImagePreview images={images} initial={initial} />)
}
function imagePreviewMouseIndex(event: MouseEvent): number | undefined {
if (!config.prompt?.image_preview || !imagePreviewsVisible()) return undefined
const x = event.x - anchor.x - 3
const y = event.y - anchor.y - 1
if (x < 0 || y < 0 || y >= imagePreviewHeight()) return undefined
const stride = imagePreviewWidth() + 1
const index = Math.floor(x / stride)
if (index < visibleImageCount() && x % stride < imagePreviewWidth()) return index
if (index === visibleImageCount() && imageOverflowVisible() && x % stride < 8) return visibleImageCount()
return undefined
}
createEffect(
on(
@@ -379,26 +482,32 @@ export function Prompt(props: PromptProps) {
name: "prompt.paste",
category: "Prompt",
palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => {
run: (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault()
event?.stopPropagation()
try {
return enqueuePaste(async (before) => {
const content = await clipboard.read()
if (cancelChangedPrompt(before)) return
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
pasteAttachment({
filename: "clipboard",
uri: `data:${content.mime};base64,${content.data}`,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data)
await pasteInputText(content.data, before)
}
} catch (error) {
toast.error(error)
}
})
},
},
{
title: "View image attachments",
name: "prompt.images.view",
category: "Prompt",
enabled: imageAttachments().length > 0,
run: () => openImagePreview(0),
},
{
title: "Interrupt session",
name: "session.interrupt",
@@ -410,7 +519,7 @@ export function Prompt(props: PromptProps) {
if (!input.focused) return
// TODO: this should be its own command
if (store.mode === "shell") {
setStore("mode", "normal")
setPromptMode("normal")
return
}
if (!props.sessionID) return
@@ -531,6 +640,7 @@ export function Prompt(props: PromptProps) {
"prompt.submit",
"prompt.editor",
"prompt.editor_context.clear",
"prompt.images.view",
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
@@ -555,12 +665,14 @@ export function Prompt(props: PromptProps) {
input.blur()
},
set(prompt) {
pasteEpoch++
input.setText(prompt.text)
setStore("prompt", prompt)
restoreExtmarksFromPrompt(prompt)
input.gotoBufferEnd()
},
reset() {
pasteEpoch++
input.clear()
input.extmarks.clear()
setStore("prompt", emptyPrompt())
@@ -584,6 +696,7 @@ export function Prompt(props: PromptProps) {
})
onCleanup(() => {
disposed = true
if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
}
@@ -807,7 +920,7 @@ export function Prompt(props: PromptProps) {
group: "Prompt",
run: () => {
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
setPromptMode("shell")
},
},
],
@@ -818,7 +931,7 @@ export function Prompt(props: PromptProps) {
return {
target: inputTarget,
enabled: inputTarget() !== undefined && store.mode === "shell",
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") }],
commands: [{ bind: "escape", title: "Exit shell mode", group: "Prompt", run: () => setPromptMode("normal") }],
}
})
@@ -829,9 +942,7 @@ export function Prompt(props: PromptProps) {
cursorVersion()
return inputTarget() !== undefined && store.mode === "shell" && input?.visualCursor.offset === 0
})(),
commands: [
{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setStore("mode", "normal") },
],
commands: [{ bind: "backspace", title: "Exit shell mode", group: "Prompt", run: () => setPromptMode("normal") }],
}
})
@@ -862,7 +973,7 @@ export function Prompt(props: PromptProps) {
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
setPromptMode(item.mode ?? "normal")
restoreExtmarksFromPrompt(item)
input.cursorOffset = 0
},
@@ -901,7 +1012,7 @@ export function Prompt(props: PromptProps) {
if (!item) return false
input.setText(item.text)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
setPromptMode(item.mode ?? "normal")
restoreExtmarksFromPrompt(item)
input.cursorOffset = input.plainText.length
},
@@ -1019,7 +1130,7 @@ export function Prompt(props: PromptProps) {
sessionID,
command: inputText,
})
setStore("mode", "normal")
setPromptMode("normal")
} else if (
inputText.startsWith("/") &&
(data.location.command.list(currentLocation.current) ?? []).some(
@@ -1150,7 +1261,7 @@ export function Prompt(props: PromptProps) {
const extmarkStart = currentOffset
const extmarkEnd = extmarkStart + promptOffsetWidth(virtualText)
input.insertText(virtualText + " ")
applyPaste(() => input.insertText(virtualText + " "))
const extmarkId = input.extmarks.create({
start: extmarkStart,
@@ -1172,34 +1283,46 @@ export function Prompt(props: PromptProps) {
)
}
async function pasteInputText(text: string) {
async function pasteInputText(text: string, before: ReturnType<typeof capturePrompt>) {
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) {
if (cancelChangedPrompt(before)) return
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) {
let remaining = MAX_LOCAL_ATTACHMENT_BYTES
const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
for (const candidate of filepaths) {
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) {
if (cancelChangedPrompt(before)) return
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
return
}
}
}
if (cancelChangedPrompt(before)) return
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
input.insertText(normalizedText)
applyPaste(() => input.insertText(normalizedText))
setTimeout(() => {
if (!input || input.isDestroyed) return
@@ -1208,17 +1331,32 @@ export function Prompt(props: PromptProps) {
}, 0)
}
async function pasteAttachment(file: { filename?: string; uri: string }) {
function pasteLocalAttachment(filepath: string, attachment: LocalAttachment) {
const filename = path.basename(filepath)
if (attachment.type === "text") {
pasteText(attachment.content, `[SVG: ${filename || "image"}]`)
return
}
pasteAttachment({
filename,
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`,
})
}
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) => 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 + " "
input.insertText(textToInsert)
applyPaste(() => input.insertText(textToInsert))
const extmarkId = input.extmarks.create({
start: extmarkStart,
@@ -1245,7 +1383,6 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index })
}),
)
return
}
function clearPrompt() {
@@ -1359,7 +1496,76 @@ export function Prompt(props: PromptProps) {
backgroundColor={promptBg()}
flexGrow={1}
width="100%"
onMouseDown={(event: MouseEvent) => {
if (event.button !== 0 || imagePreviewMouseIndex(event) === undefined) return
event.preventDefault()
}}
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
const index = imagePreviewMouseIndex(event)
if (index === undefined) return
event.preventDefault()
openImagePreview(index)
}}
>
<Show when={config.prompt?.image_preview && imagePreviewsVisible()}>
<box
width="100%"
height={imagePreviewHeight() + 1}
flexDirection="row"
flexShrink={0}
justifyContent="flex-start"
gap={1}
paddingBottom={1}
>
<For each={imageAttachments().slice(0, visibleImageCount())}>
{(file, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={imagePreviewWidth()}
height={imagePreviewHeight()}
flexBasis={imagePreviewWidth()}
flexShrink={1}
>
<Show
when={!failed()}
fallback={
<box width="100%" height="100%" alignItems="center" justifyContent="center">
<text fg={theme.text.subdued}>No preview</text>
</box>
}
>
<image
id={`prompt-image-preview-${index()}`}
source={file.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={imageOverflowVisible()}>
<box
width={8}
height={imagePreviewHeight()}
flexBasis={8}
flexShrink={1}
alignItems="center"
justifyContent="center"
>
<text fg={theme.text.subdued} wrapMode="none" truncate>
+{hiddenImageAttachmentCount()} more
</text>
</box>
</Show>
</box>
</Show>
<textarea
width="100%"
placeholder={placeholderText()}
@@ -1369,13 +1575,17 @@ export function Prompt(props: PromptProps) {
minHeight={1}
maxHeight={maxHeight()}
onContentChange={() => {
if (!pasteMutating) pasteEpoch++
const value = input.plainText
setStore("prompt", "text", value)
auto()?.onInput(value)
syncExtmarksWithPromptParts()
setCursorVersion((value) => value + 1)
}}
onCursorChange={() => setCursorVersion((value) => value + 1)}
onCursorChange={() => {
if (!pasteMutating) pasteEpoch++
setCursorVersion((value) => value + 1)
}}
onKeyDown={(e: { preventDefault(): void }) => {
if (props.disabled) {
e.preventDefault()
@@ -1387,7 +1597,7 @@ export function Prompt(props: PromptProps) {
// hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0)
}}
onPaste={async (event: PasteEvent) => {
onPaste={(event: PasteEvent) => {
if (props.disabled) {
event.preventDefault()
return
@@ -1409,7 +1619,7 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault()
await pasteInputText(normalizedText)
void enqueuePaste((before) => pasteInputText(normalizedText, before))
}}
ref={(r: TextareaRenderable) => {
input = r
@@ -1,9 +1,15 @@
import { readFile } from "node:fs/promises"
import { constants } from "node:fs"
import { open } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
// Bound filesystem work per terminal paste; the byte budget also bounds staged data.
const MAX_PASTED_FILEPATHS = 32
export const MAX_LOCAL_ATTACHMENT_BYTES = 20 * 1024 * 1024
export type LocalFiles = Readonly<{
readText(path: string): Promise<string>
readBytes(path: string): Promise<Uint8Array>
readText(path: string, maxBytes: number): Promise<string>
readBytes(path: string, maxBytes: number): Promise<Uint8Array>
mime(path: string): Promise<string>
}>
@@ -11,14 +17,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,
)
}
@@ -33,16 +40,109 @@ const mimeTypes: Record<string, string> = {
".webp": "image/webp",
}
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> {
async function readFileBounded(file: string, maxBytes: number) {
const handle = await open(file, constants.O_RDONLY | constants.O_NONBLOCK)
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, "")
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 = ""
let quote = ""
function push() {
if (!current) return
result.push(decodeFileURL(current) ?? current)
current = ""
}
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 < input.length) {
current += input[++index]
continue
}
current += character
continue
}
if (character === "'" || character === '"') {
quote = character
continue
}
if (character === "\\" && platform !== "win32" && index + 1 < input.length) {
current += input[++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 async function readLocalAttachmentWith(
files: LocalFiles,
path: string,
maxBytes = MAX_LOCAL_ATTACHMENT_BYTES,
): Promise<LocalAttachment | undefined> {
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 }
}
+6
View File
@@ -105,6 +105,9 @@ export const Info = Schema.Struct({
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
description: "Display large pastes as compact placeholders or full text",
}),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show image attachment previews above the prompt input",
}),
}),
).annotate({ description: "Prompt input behavior" }),
session: Schema.optional(
@@ -119,6 +122,9 @@ export const Info = Schema.Struct({
grouping: Schema.optional(Schema.Literals(["auto", "none"])).annotate({
description: "Group related transcript items automatically or render each item separately",
}),
image_preview: Schema.optional(Schema.Boolean).annotate({
description: "Show user attachment and tool-result images in the session transcript",
}),
markdown: Schema.optional(Schema.Literals(["source", "rendered"])).annotate({
description: "Show Markdown syntax markers or conceal them in rendered transcript content",
}),
+2
View File
@@ -164,6 +164,7 @@ export const Definitions = {
prompt_submit: keybind("none", "Submit prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"),
prompt_images_view: keybind("<leader>i", "View image attachments"),
prompt_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"),
prompt_stash_pop: keybind("none", "Pop stashed prompt"),
@@ -364,6 +365,7 @@ export const CommandMap = {
display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit",
prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_images_view: "prompt.images.view",
prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash",
prompt_stash_pop: "prompt.stash.pop",
+168 -61
View File
@@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core"
import { Prompt, type PromptRef } from "../../component/prompt"
import type {
ModelInfo,
@@ -52,6 +52,7 @@ import { useEditorContext } from "../../context/editor"
import { openEditor } from "../../editor"
import { useDialog } from "../../ui/dialog"
import { DialogSessionRename } from "../../component/dialog-session-rename"
import { DialogImagePreview } from "../../component/dialog-image-preview"
import { DialogMessage } from "./dialog-message"
import { DialogFork } from "./dialog-fork"
import { DialogTimeline } from "./dialog-timeline"
@@ -106,6 +107,7 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120
const SESSION_IMAGE_LIMIT = 6
const context = createContext<{
width: number
@@ -115,6 +117,7 @@ const context = createContext<{
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
imageKeys: () => ReadonlySet<string>
models: () => ModelInfo[]
config: ReturnType<typeof useConfig>["data"]
}>()
@@ -144,6 +147,9 @@ export function Session() {
const promptRef = usePromptRef()
const session = createMemo(() => data.session.get(route.sessionID))
const messages = () => data.session.message.list(route.sessionID)
const imageKeys = createMemo(() =>
config.session?.image_preview ? sessionImageKeys(messages(), session()?.revert?.messageID) : new Set<string>(),
)
const currentLocation = useLocation()
const location = createMemo(() => session()?.location ?? currentLocation.ref)
@@ -968,6 +974,7 @@ export function Session() {
markdownMode,
groupExploration,
diffWrapMode,
imageKeys,
models,
config,
}}
@@ -1131,11 +1138,7 @@ function SessionRowView(props: SessionRowViewProps) {
</Match>
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
{(row) => (
<TurnTokenUsage
messageIDs={row().messageIDs}
previousCache={row().previousCache}
message={props.message}
/>
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
)}
</Match>
</Switch>
@@ -1242,21 +1245,10 @@ function TurnTokenToolCalls(props: { tools: SessionMessageAssistantTool[] }) {
<For each={props.tools}>
{(tool) => (
<box flexDirection="row">
<text
width={nameWidth()}
flexShrink={0}
fg={theme.text.subdued}
attributes={TextAttributes.BOLD}
>
<text width={nameWidth()} flexShrink={0} fg={theme.text.subdued} attributes={TextAttributes.BOLD}>
{tool.name}
</text>
<text
fg={theme.text.subdued}
attributes={TextAttributes.DIM}
wrapMode="word"
flexGrow={1}
minWidth={0}
>
<text fg={theme.text.subdued} attributes={TextAttributes.DIM} wrapMode="word" flexGrow={1} minWidth={0}>
{turnTokenToolSummary(tool)}
</text>
</box>
@@ -1273,9 +1265,7 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
const primaryKey = ["command", "id", "pattern", "url", "query", "path", "description", "code"].find(
(key) => key in data,
)
const input = Object.entries(data).filter(([, value]) =>
["string", "number", "boolean"].includes(typeof value),
)
const input = Object.entries(data).filter(([, value]) => ["string", "number", "boolean"].includes(typeof value))
const primary = input.find(([key]) => key === primaryKey)?.[1]
const details = input.filter(([key]) => key !== primaryKey).map(([key, value]) => `${key}: ${String(value)}`)
return [primary === undefined ? "" : String(primary), ...details].filter(Boolean).join(" ")
@@ -1357,7 +1347,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
/>
</Match>
<Match when={item().type === "tool"}>
<ToolPart part={item() as SessionMessageAssistantTool} />
<ToolPartWithImages messageID={props.partRef.messageID} part={item() as SessionMessageAssistantTool} />
</Match>
</Switch>
)}
@@ -1495,6 +1485,7 @@ function SessionGroupView(props: {
}) {
const theme = useTheme()
const ctx = use()
const dialog = useDialog()
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
@@ -1504,13 +1495,13 @@ function SessionGroupView(props: {
if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID)
if (part?.type !== "tool") return []
return [part]
return [{ messageID: ref.messageID, part }]
})
const grouped = createMemo(() => parts(props.refs))
const pending = createMemo(() => parts(props.pending))
const label = createMemo(() => {
const counts = grouped().reduce<Record<string, number>>((result, part) => {
const tool = toolDisplay(part.name)
const counts = grouped().reduce<Record<string, number>>((result, item) => {
const tool = toolDisplay(item.part.name)
const name = tool === "grep" || tool === "glob" ? "search" : tool
result[name] = (result[name] ?? 0) + 1
return result
@@ -1524,7 +1515,11 @@ function SessionGroupView(props: {
<Show when={grouped().length > 0 || pending().length > 0}>
<Show
when={ctx.groupExploration()}
fallback={<For each={[...grouped(), ...pending()]}>{(part) => <ToolPart part={part} />}</For>}
fallback={
<For each={[...grouped(), ...pending()]}>
{(item) => <ToolPartWithImages messageID={item.messageID} part={item.part} />}
</For>
}
>
<Show when={grouped().length > 0}>
<InlineToolRow
@@ -1544,9 +1539,14 @@ function SessionGroupView(props: {
</InlineToolRow>
</Show>
<Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
<For each={grouped()}>{(item) => <ToolPart part={item.part} />}</For>
</Show>
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
<ToolImages
parts={grouped()}
visible={ctx.imageKeys()}
onOpen={(images, index) => dialog.replace(() => <DialogImagePreview images={images} initial={index} />)}
/>
<For each={pending()}>{(item) => <ToolPartWithImages messageID={item.messageID} part={item.part} />}</For>
</Show>
</Show>
)
@@ -1680,8 +1680,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
const text = () =>
props.message.status === "failed" ? (cancelled() ? "" : props.message.error.message) : props.message.summary
const content = createMemo(() => text().trim())
const color = () =>
status() === "failed" && !cancelled() ? theme.text.feedback.error.default : theme.text.subdued
const color = () => (status() === "failed" && !cancelled() ? theme.text.feedback.error.default : theme.text.subdued)
return (
<box>
<box flexDirection="row" alignItems="center">
@@ -1852,6 +1851,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
const data = useData()
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const images = createMemo(() => sessionMessageImages(props.message))
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
@@ -1870,30 +1870,26 @@ function UserMessage(props: { message: SessionMessageUser }) {
border={["left"]}
borderColor={queued() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={props.message.id}
sessionID={ctx.sessionID}
setPrompt={(value) => promptRef.current?.set(value)}
/>
))
}}
>
<box
onMouseOver={() => {
setHover(true)
}}
onMouseOut={() => {
setHover(false)
}}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={props.message.id}
sessionID={ctx.sessionID}
setPrompt={(value) => promptRef.current?.set(value)}
/>
))
}}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
flexShrink={0}
>
<SessionImages
images={images()}
visible={ctx.imageKeys()}
onOpen={(images, index) => dialog.replace(() => <DialogImagePreview images={images} initial={index} />)}
/>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.text.default}>{props.message.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
@@ -2316,6 +2312,122 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
)
}
function ToolPartWithImages(props: { messageID: string; part: SessionMessageAssistantTool }) {
const ctx = use()
const dialog = useDialog()
return (
<>
<ToolPart part={props.part} />
<ToolImages
parts={[props]}
visible={ctx.imageKeys()}
onOpen={(images, index) => dialog.replace(() => <DialogImagePreview images={images} initial={index} />)}
/>
</>
)
}
export function ToolImages(props: {
parts: readonly { messageID: string; part: SessionMessageAssistantTool }[]
visible: ReadonlySet<string>
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void
}) {
const images = createMemo(() => props.parts.flatMap((item) => inlineToolImages(item.messageID, item.part)))
return <SessionImages images={images()} visible={props.visible} onOpen={props.onOpen} />
}
export function SessionImages(props: {
images: readonly { key: string; uri: string }[]
visible: ReadonlySet<string>
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void
}) {
const dimensions = useTerminalDimensions()
const images = createMemo(() => props.images.filter((image) => props.visible.has(image.key)))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const width = createMemo(() => height() * 2)
const limit = createMemo(() =>
Math.max(0, Math.min(3, Math.floor((Math.max(0, dimensions().width - 6) + 1) / (width() + 1)))),
)
const count = createMemo(() => Math.min(limit(), images().length))
return (
<Show when={count() > 0}>
<box flexDirection="row" flexShrink={0} paddingTop={1} paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<For each={images().slice(0, count())}>
{(image, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={width()}
height={height()}
flexBasis={width()}
flexShrink={0}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
props.onOpen?.(images(), index())
}}
>
<Show when={!failed()} fallback={<text>No preview</text>}>
<image
id={`session-image-${image.key}`}
source={image.uri}
fit="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<Show when={images().length > count()}>
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
<text wrapMode="none" truncate>
+{images().length - count()} more
</text>
</box>
</Show>
</box>
</Show>
)
}
export function sessionImageKeys(messages: readonly SessionMessageInfo[], revertBoundary?: string) {
return new Set(
messages
.filter((message) => !revertBoundary || message.id < revertBoundary)
.flatMap(sessionMessageImages)
.map((image) => image.key)
.slice(-SESSION_IMAGE_LIMIT),
)
}
export function sessionMessageImages(message: SessionMessageInfo) {
if (message.type === "user") {
return (message.files ?? []).flatMap((file, index) =>
file.mime.startsWith("image/")
? [{ key: `${message.id}:file:${index}`, uri: `data:${file.mime};base64,${file.data}` }]
: [],
)
}
if (message.type !== "assistant") return []
return message.content.flatMap((part) => (part.type === "tool" ? inlineToolImages(message.id, part) : []))
}
function inlineToolImages(messageID: string, part: SessionMessageAssistantTool) {
return toolDisplayContent(part.state).flatMap((content, index) =>
content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/")
? [{ key: `${messageID}:${part.id}:${index}`, uri: content.uri }]
: [],
)
}
type ToolProps = {
input: Record<string, unknown>
metadata: Record<string, unknown>
@@ -2361,10 +2473,7 @@ function GenericTool(props: ToolProps) {
{(value) => (
<box gap={1}>
<text>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
{" "}
Output{" "}
</span>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}> Output </span>
</text>
<box paddingLeft={1}>
<text fg={theme.text.default} wrapMode="word">
@@ -2616,9 +2725,7 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<Show
when={props.spinner}
fallback={
<text fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
{title()}
</text>
<text fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>{title()}</text>
}
>
<Spinner color={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
@@ -5,6 +5,7 @@ import { expect, test } from "bun:test"
import { onMount } from "solid-js"
import { ConfigProvider, resolve, type Info, type Interface } from "../../../src/config"
import { CommandPaletteDialog } from "../../../src/component/command-palette"
import { settingID, settings } from "../../../src/component/dialog-config"
import { Keymap } from "../../../src/context/keymap"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
@@ -13,6 +14,10 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
test("searches settings globally and opens the matching setting", async () => {
let current: Info = {}
expect(settings.find((setting) => settingID(setting) === "session.image_preview")).toMatchObject({
title: "Transcript images",
default: false,
})
const service: Interface = {
get: async () => current,
update: async (update) => {
@@ -74,11 +79,11 @@ test("searches settings globally and opens the matching setting", async () => {
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
app.mockInput.pressArrow("down")
for (const key of "sounds") app.mockInput.pressKey(key)
for (const key of "image preview") app.mockInput.pressKey(key)
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Sounds"))
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Image previews"))
app.mockInput.pressEnter()
await app.waitFor(() => current.attention?.sound === false)
await app.waitFor(() => current.prompt?.image_preview === true)
} finally {
app.renderer.destroy()
}
@@ -0,0 +1,147 @@
import { afterEach, expect, test } from "bun:test"
import { ImageRenderable } from "@opentui/core"
import { MouseButtons } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageUser } from "@opencode-ai/client"
import { sessionImageKeys, sessionMessageImages, SessionImages, ToolImages } from "../../../src/routes/session"
const PNG_1X1_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg=="
const image = { type: "file" as const, uri: `data:image/png;base64,${PNG_1X1_BASE64}`, mime: "image/png" }
let setup: Awaited<ReturnType<typeof testRender>> | undefined
afterEach(() => {
setup?.renderer.destroy()
setup = undefined
})
test("renders bounded inline images from completed tool content", async () => {
let opened = -1
setup = await testRender(
() => toolImages([image, image, image, image, image, image, image], (_, index) => (opened = index)),
{
width: 80,
height: 70,
},
)
await setup.renderOnce()
const first = setup.renderer.root.findDescendantById("session-image-message-1:call-1:1")
const second = setup.renderer.root.findDescendantById("session-image-message-1:call-1:2")
if (!(first instanceof ImageRenderable)) throw new Error("Tool image did not render")
if (!(second instanceof ImageRenderable)) throw new Error("Second tool image did not render")
await first.loadPromise
expect(first.fit).toBe("cover")
expect(first.protocol).toBe("auto")
expect(first.width).toBe(16)
expect(first.height).toBe(8)
expect(second.y).toBe(first.y)
expect(second.x).toBe(first.x + first.width + 1)
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:3")).toBeInstanceOf(ImageRenderable)
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:0")).toBeUndefined()
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:4")).toBeUndefined()
expect(setup.captureCharFrame()).toContain("+3 more")
await setup.mockMouse.click(first.x, first.y, MouseButtons.LEFT)
expect(opened).toBe(0)
})
test("does not expose external image tool content to the renderer", () => {
expect(
sessionMessageImages(
assistant("message-1", [
tool([
{ type: "file", uri: "https://example.test/image.png", mime: "image/png" },
{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" },
{ type: "file", uri: "data:text/plain;base64,SGVsbG8=", mime: "text/plain" },
]),
]),
),
).toEqual([])
})
test("does not render session images without the opt-in setting", async () => {
const part = tool([image])
setup = await testRender(() => <ToolImages parts={[{ messageID: "message-1", part }]} visible={new Set()} />, {
width: 80,
height: 24,
})
await setup.renderOnce()
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:0")).toBeUndefined()
})
test("renders images submitted in user prompts", async () => {
const message: SessionMessageUser = {
type: "user",
id: "message-user",
text: "What is in this image?",
files: [{ data: PNG_1X1_BASE64, mime: "image/png", source: { type: "inline" }, name: "prompt.png" }],
time: { created: 1 },
}
setup = await testRender(
() => <SessionImages images={sessionMessageImages(message)} visible={sessionImageKeys([message])} />,
{ width: 80, height: 24 },
)
await setup.renderOnce()
const preview = setup.renderer.root.findDescendantById("session-image-message-user:file:0")
if (!(preview instanceof ImageRenderable)) throw new Error("User image did not render")
await preview.loadPromise
expect(preview.fit).toBe("cover")
})
test("does not reserve image slots for reverted messages", () => {
const visible = assistant("message-1", [tool([image])])
const reverted = assistant("message-2", [tool([image, image, image, image, image, image])])
expect([...sessionImageKeys([visible, reverted], reverted.id)]).toEqual(["message-1:call-1:0"])
})
test("falls back when inline image content is malformed", async () => {
setup = await testRender(
() => toolImages([{ type: "file", uri: "data:image/png;base64,aW52YWxpZA==", mime: "image/png" }]),
{ width: 80, height: 24 },
)
await setup.renderOnce()
const preview = setup.renderer.root.findDescendantById("session-image-message-1:call-1:0")
if (!(preview instanceof ImageRenderable)) throw new Error("Tool image did not render")
await preview.loadPromise
expect(await setup.waitForFrame((frame) => frame.includes("No preview"))).toContain("No preview")
})
function toolImages(
content: Extract<SessionMessageAssistantTool["state"], { status: "completed" }>["content"],
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void,
) {
const part = tool(content)
const message = assistant("message-1", [part])
return <ToolImages parts={[{ messageID: message.id, part }]} visible={sessionImageKeys([message])} onOpen={onOpen} />
}
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
type: "assistant",
id,
agent: "build",
model: { id: "model", providerID: "provider" },
content,
time: { created: 1 },
}
}
function tool(
content: Extract<SessionMessageAssistantTool["state"], { status: "completed" }>["content"],
): SessionMessageAssistantTool {
return {
type: "tool",
id: "call-1",
name: "image",
state: { status: "completed", input: {}, content },
time: { created: 0, completed: 1 },
}
}
+4 -1
View File
@@ -14,11 +14,14 @@ 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()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(() => decode({ prompt: { image_preview: "on" } })).toThrow()
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
})
test("resolves nested config and keybind defaults", () => {
+8
View File
@@ -86,6 +86,14 @@ test("resolves a session move keybind", () => {
expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
})
test("resolves the image viewer keybind", () => {
const defaults = resolve({}, { terminalSuspend: true })
const overridden = resolve({ keybinds: { prompt_images_view: "ctrl+shift+i" } }, { terminalSuspend: true })
expect(defaults.keybinds.get("prompt.images.view")).toMatchObject([{ key: "<leader>i" }])
expect(overridden.keybinds.get("prompt.images.view")).toMatchObject([{ key: "ctrl+shift+i" }])
})
test("resolves message navigation defaults", () => {
const config = resolve({}, { terminalSuspend: true })
+348 -22
View File
@@ -1,14 +1,25 @@
import { afterAll, expect, mock, test } from "bun:test"
import { TextareaRenderable, type ClipboardReadResult, type HostClipboardService } from "@opentui/core"
import { createTestRenderer } from "@opentui/core/testing"
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 { 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"
import { createComponent } from "solid-js"
import { Prompt, type PromptRef } from "../../src/component/prompt"
import { parsePromptInfo } from "../../src/prompt/history"
import { createEventStream, createFetch } from "../fixture/tui-client"
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<ClipboardReadResult> => ({
status: "read",
representation: { mimeType: "image/png", bytes: PNG_1X1 },
})
let activeSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined
let activeHost: HostClipboardService | undefined
let activePromptRef: PromptRef | undefined
@@ -35,8 +46,8 @@ const { run } = await import("../../src/app")
afterAll(() => mock.restore())
async function mountPrompt(read: () => Promise<ClipboardReadResult>) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
async function mountPrompt(read: () => Promise<ClipboardReadResult>, imagePreview = false, width = 80) {
const setup = await createTestRenderer({ width, height: 24, useThread: false })
let reads = 0
let ready!: () => void
const mounted = new Promise<void>((resolve) => (ready = resolve))
@@ -73,7 +84,7 @@ async function mountPrompt(read: () => Promise<ClipboardReadResult>) {
fetch: async (request) => {
const response = await calls.fetch(request)
const url = new URL(request.url)
if (url.pathname === "/api/session" && url.searchParams.has("project")) preloaded()
if (url.pathname === "/api/session/active") preloaded()
return response
},
})
@@ -83,7 +94,10 @@ async function mountPrompt(read: () => Promise<ClipboardReadResult>) {
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ prompt: { paste: "full" as const } }), update: async () => ({}) },
config: {
get: async () => ({ prompt: { paste: "full" as const, image_preview: imagePreview } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
args: {},
log: () => {},
@@ -129,26 +143,24 @@ async function mountPrompt(read: () => Promise<ClipboardReadResult>) {
}
}
test("inserts nonempty whitespace-only terminal paste without reading the host clipboard", async () => {
async function pasteImages(prompt: Awaited<ReturnType<typeof mountPrompt>>, 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()
@@ -169,17 +181,308 @@ test("normalizes host clipboard text once before inserting it", async () => {
}
})
test("serializes overlapping local image pastes", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "opencode-overlap-"))
const file = path.join(directory, "image.png")
await writeFile(file, PNG_1X1)
const prompt = await mountPrompt(async () => ({ status: "empty" }))
try {
await Promise.all([
prompt.setup.mockInput.pasteBracketedText(`'${file}'`),
prompt.setup.mockInput.pasteBracketedText(`'${file}'`),
])
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 2)
expect(prompt.input.plainText).toBe("[Image 1] [Image 2] ")
} finally {
await prompt.dispose()
await rm(directory, { recursive: true, force: true })
}
})
test("continues queued paste work after a clipboard failure", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "opencode-recovery-"))
const file = path.join(directory, "image.png")
await writeFile(file, PNG_1X1)
const result = Promise.withResolvers<ClipboardReadResult>()
const prompt = await mountPrompt(() => result.promise)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1)
await prompt.setup.mockInput.pasteBracketedText(`'${file}'`)
result.reject(new Error("clipboard failed"))
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 1)
expect(prompt.input.plainText).toBe("[Image 1] ")
} finally {
await prompt.dispose()
await rm(directory, { recursive: true, force: true })
}
})
test("cancels delayed and queued pastes after a reversible prompt change", async () => {
const result = Promise.withResolvers<ClipboardReadResult>()
const prompt = await mountPrompt(() => result.promise)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1)
await prompt.setup.mockInput.pasteBracketedText("queued")
prompt.setup.mockInput.pressKey("x")
await prompt.setup.waitFor(() => prompt.input.plainText === "x")
prompt.setup.mockInput.pressBackspace()
await prompt.setup.waitFor(() => prompt.input.plainText === "")
prompt.prompt.set({ text: "temporary", files: [], agents: [], pasted: [] })
prompt.prompt.reset()
await prompt.setup.renderOnce()
await prompt.setup.mockInput.pasteBracketedText("new")
result.resolve({
status: "read",
representation: { mimeType: "text/plain", bytes: new TextEncoder().encode("/missing-one.png /missing-two.png") },
})
await prompt.setup.waitForFrame((frame) => frame.includes("Attachment paste canceled"))
await prompt.setup.waitFor(() => prompt.input.plainText === "new")
expect(prompt.prompt.current.files).toEqual([])
} finally {
await prompt.dispose()
}
})
test("does not insert a delayed image after entering shell mode", async () => {
const result = Promise.withResolvers<ClipboardReadResult>()
const prompt = await mountPrompt(() => result.promise)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1)
prompt.setup.mockInput.pressKey("!")
await prompt.setup.waitForFrame((frame) => frame.includes("Shell"))
prompt.setup.mockInput.pressBackspace()
await prompt.setup.waitForFrame((frame) => !frame.includes("Shell"))
result.resolve(await readPngClipboard())
await prompt.setup.waitForFrame((frame) => frame.includes("Attachment paste canceled"))
expect(prompt.input.plainText).toBe("")
expect(prompt.prompt.current.files).toEqual([])
} finally {
await prompt.dispose()
}
})
test("ignores a clipboard read that completes after prompt teardown", async () => {
const result = Promise.withResolvers<ClipboardReadResult>()
const prompt = await mountPrompt(() => result.promise)
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1)
prompt.setup.renderer.destroy()
result.resolve(await readPngClipboard())
await Bun.sleep(0)
await prompt.dispose()
})
test("creates one image mention from PNG clipboard bytes", async () => {
const prompt = await mountPrompt(async () => ({
status: "read",
representation: { mimeType: "image/png", bytes: new Uint8Array([137, 80, 78, 71]) },
}))
const prompt = await mountPrompt(readPngClipboard)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.input.plainText === "[Image 1] ")
expect(prompt.input.plainText).toBe("[Image 1] ")
expect(prompt.input.extmarks.getVirtual()).toHaveLength(1)
expect(prompt.prompt.current.files).toEqual([
{
uri: `data:image/png;base64,${PNG_1X1_BASE64}`,
name: "clipboard",
mention: { start: 0, end: 9, text: "[Image 1]" },
},
])
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
test("renders at most three left-aligned cropped thumbnails", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
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
expect(first.fit).toBe("cover")
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()
const frame = await prompt.setup.waitForFrame((frame) => frame.includes("+1 more"))
expect(frame).toMatch(/^┃ █/m)
expect(frame.match(/\[Image 1\]/g)).toHaveLength(1)
expect(frame.match(/\[Image 2\]/g)).toHaveLength(1)
expect(frame.match(/\[Image 3\]/g)).toHaveLength(1)
await prompt.setup.mockMouse.click(49, 1, MouseButtons.LEFT)
await prompt.setup.waitForFrame((frame) => frame.includes("Image 4 of 4"))
prompt.setup.mockInput.pressCtrlC()
await prompt.setup.waitForFrame((frame) => !frame.includes("Image 4 of 4"))
await prompt.setup.mockMouse.click(50, 1, MouseButtons.LEFT)
await prompt.setup.renderOnce()
expect(prompt.setup.captureCharFrame()).not.toContain("Image 4 of 4")
} finally {
await prompt.dispose()
}
})
test("opens image attachments by keyboard, mouse, and command palette", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
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")
prompt.setup.mockInput.pressKey("x", { ctrl: true })
prompt.setup.mockInput.pressKey("i")
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"))
await prompt.setup.mockMouse.click(14, 1, MouseButtons.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"))
await prompt.setup.mockMouse.click(15, 1, MouseButtons.LEFT)
await prompt.setup.renderOnce()
expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2")
await prompt.setup.mockMouse.click(29, 1, MouseButtons.LEFT)
await prompt.setup.renderOnce()
expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2")
await prompt.setup.mockMouse.click(16, 1, MouseButtons.LEFT)
await prompt.setup.waitForFrame((frame) => frame.includes("Image 2 of 2"))
const large = prompt.setup.renderer.root.findDescendantById("image-viewer-image")
if (!(large instanceof ImageRenderable)) throw new Error("Large image preview did not render")
expect(large.fit).toBe("fit")
expect(large.height).toBeGreaterThan(thumbnail.height)
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)
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)
const palette = await prompt.setup.waitForFrame((frame) => frame.includes("View image attachments"))
expect(palette).toContain("ctrl+x i")
prompt.setup.mockInput.pressEnter()
await prompt.setup.waitForFrame((frame) => frame.includes("Image 1 of 2"))
} 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"])
} 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(readPngClipboard, true, 32)
try {
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"))
} finally {
await prompt.dispose()
}
})
test("does not click a hidden overflow control", async () => {
const prompt = await mountPrompt(readPngClipboard, true, 22)
try {
await pasteImages(prompt, 2)
await prompt.setup.mockMouse.click(16, 1, MouseButtons.LEFT)
await prompt.setup.renderOnce()
expect(prompt.setup.captureCharFrame()).not.toContain("Image 2 of 2")
} finally {
await prompt.dispose()
}
})
test("hides thumbnails when their minimum width does not fit", async () => {
const prompt = await mountPrompt(readPngClipboard, true, 16)
try {
await pasteImages(prompt, 1)
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
await prompt.setup.mockMouse.click(12, 1, MouseButtons.LEFT)
await prompt.setup.renderOnce()
expect(prompt.setup.captureCharFrame()).not.toContain("Image 1 of 1")
} finally {
await prompt.dispose()
}
})
test("removes an image preview when its mention is deleted", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(
() => prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0") instanceof ImageRenderable,
)
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
expect(image).not.toBeNull()
prompt.input.cursorOffset = prompt.input.plainText.length
prompt.setup.mockInput.pressBackspace()
prompt.setup.mockInput.pressBackspace()
await prompt.setup.waitFor(() => prompt.prompt.current.files?.length === 0)
expect(prompt.input.plainText).toBe("")
expect(prompt.setup.renderer.root.findDescendantById("prompt-image-preview-0")).toBeUndefined()
expect(() => image!.info()).toThrow("NativeImage is disposed")
} finally {
await prompt.dispose()
}
})
test("keeps an attachment when its opt-in preview cannot be decoded", async () => {
const bytes = new Uint8Array([137, 80, 78, 71])
const prompt = await mountPrompt(
async () => ({ status: "read", representation: { mimeType: "image/png", bytes } }),
true,
)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitForFrame((frame) => frame.includes("No preview"))
expect(prompt.input.plainText).toBe("[Image 1] ")
expect(prompt.prompt.current.files).toEqual([
{
uri: "data:image/png;base64,iVBORw==",
@@ -187,7 +490,30 @@ test("creates one image mention from PNG clipboard bytes", async () => {
mention: { start: 0, end: 9, text: "[Image 1]" },
},
])
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
test("ignores malformed attachment URIs restored into the prompt", async () => {
const prompt = await mountPrompt(readPngClipboard, true)
try {
const restored = parsePromptInfo({
text: "[Image 1] ",
files: [{ uri: 42 }],
agents: [],
pasted: [],
})
if (!restored) throw new Error("Malformed prompt fixture was not restored")
prompt.prompt.set(restored)
await prompt.setup.renderOnce()
expect(prompt.prompt.current.text).toBe("[Image 1] ")
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,${PNG_1X1_BASE64}`)
} finally {
await prompt.dispose()
}
@@ -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,36 @@ 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("# 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",
])
})
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: "<svg />" }), "/tmp/image.svg")).toEqual({
type: "text",
@@ -39,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()
})
})