feat(tui): add prompt image previews

This commit is contained in:
Simon Klee 2026-08-03 08:19:46 +02:00
commit 36f607618b
No known key found for this signature in database
GPG key ID: B91696044D47BEA3
10 changed files with 601 additions and 115 deletions

View file

@ -189,6 +189,15 @@ export const settings: Setting[] = [
values: ["compact", "full"], values: ["compact", "full"],
keywords: ["paste summary", "clipboard", "pasted content"], 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", title: "Leader timeout",
category: "Input", category: "Input",

View file

@ -0,0 +1,71 @@
import { TextAttributes } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, createSignal } 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
setFailed(false)
setIndex((value) => (value + direction + props.images.length) % props.images.length)
}
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="prompt-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="prompt-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>
)
}

View file

@ -7,9 +7,8 @@ import {
decodePasteBytes, decodePasteBytes,
type KeyEvent, type KeyEvent,
} from "@opentui/core" } 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 path from "path" import path from "path"
import { fileURLToPath } from "url"
import { useLocal } from "../../context/local" import { useLocal } from "../../context/local"
import { useTheme, useThemes } from "../../context/theme" import { useTheme, useThemes } from "../../context/theme"
import { tint } from "../../theme/color" import { tint } from "../../theme/color"
@ -47,12 +46,19 @@ import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args" import { useArgs } from "../../context/args"
import { useConfig } from "../../config" import { useConfig } from "../../config"
import { usePromptMove } from "./move" 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 { useData } from "../../context/data"
import { useLocation } from "../../context/location" import { useLocation } from "../../context/location"
import { Keymap, type KeymapCommand } from "../../context/keymap" import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime" import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render" import { PluginSlot } from "../../plugin/render"
import { DialogImagePreview } from "../dialog-image-preview"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@ -69,17 +75,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 = { export type PromptRef = {
focused: boolean focused: boolean
current: PromptInfo current: PromptInfo
@ -307,6 +302,41 @@ export function Prompt(props: PromptProps) {
extmarkToPart: new Map(), extmarkToPart: new Map(),
interrupt: 0, interrupt: 0,
}) })
let disposed = false
let pasteQueue = Promise.resolve()
function enqueuePaste(run: (changed: () => boolean) => Promise<void>) {
pasteQueue = pasteQueue
.then(async () => {
if (disposed || input.isDestroyed) return
const before = { sessionID: props.sessionID, mode: store.mode, text: input.plainText }
await run(
() =>
disposed ||
input.isDestroyed ||
props.sessionID !== before.sessionID ||
store.mode !== before.mode ||
input.plainText !== before.text,
)
})
.catch((error) => {
if (!disposed) toast.error(error)
})
return pasteQueue
}
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 visibleImageAttachments = createMemo(() => imageAttachments().slice(0, 3))
function openImagePreview(initial: number) {
const images = imageAttachments()
if (images.length === 0) return
dialog.replace(() => <DialogImagePreview images={images} initial={initial} />)
}
createEffect( createEffect(
on( on(
@ -376,25 +406,32 @@ export function Prompt(props: PromptProps) {
name: "prompt.paste", name: "prompt.paste",
category: "Prompt", category: "Prompt",
palette: undefined, palette: undefined,
run: async (_input: string | undefined, event?: KeyEvent) => { run: (_input: string | undefined, event?: KeyEvent) => {
event?.preventDefault() event?.preventDefault()
event?.stopPropagation() event?.stopPropagation()
const content = await clipboard.read().catch((error) => { return enqueuePaste(async (changed) => {
toast.error(error) const content = await clipboard.read()
return undefined if (changed()) return
if (content?.mime.startsWith("image/")) {
pasteAttachment({
filename: "clipboard",
uri: `data:${content.mime};base64,${content.data}`,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data, changed)
}
}) })
if (content?.mime.startsWith("image/")) {
await pasteAttachment({
filename: "clipboard",
uri: `data:${content.mime};base64,${content.data}`,
})
return
}
if (content?.mime === "text/plain") {
await pasteInputText(content.data)
}
}, },
}, },
{
title: "View image attachments",
name: "prompt.images.view",
category: "Prompt",
enabled: imageAttachments().length > 0,
run: () => openImagePreview(0),
},
{ {
title: "Interrupt session", title: "Interrupt session",
name: "session.interrupt", name: "session.interrupt",
@ -527,6 +564,7 @@ export function Prompt(props: PromptProps) {
"prompt.submit", "prompt.submit",
"prompt.editor", "prompt.editor",
"prompt.editor_context.clear", "prompt.editor_context.clear",
"prompt.images.view",
"prompt.stash", "prompt.stash",
"prompt.stash.pop", "prompt.stash.pop",
"prompt.stash.list", "prompt.stash.list",
@ -580,6 +618,7 @@ export function Prompt(props: PromptProps) {
}) })
onCleanup(() => { onCleanup(() => {
disposed = true
if (store.prompt.text) { if (store.prompt.text) {
stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset } stashed = { prompt: unwrap(store.prompt), cursor: input.cursorOffset }
} }
@ -1181,27 +1220,39 @@ export function Prompt(props: PromptProps) {
return true return true
} }
async function pasteInputText(text: string) { async function pasteInputText(text: string, changed: () => boolean) {
const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n") const normalizedText = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
const pastedContent = normalizedText.trim() const pastedContent = normalizedText.trim()
const filepath = pastedFilepath(pastedContent, terminalEnvironment.platform) const filepath = normalizePastedFilepath(pastedContent, terminalEnvironment.platform)
const isUrl = /^(https?):\/\//.test(filepath) const isUrl = /^(https?):\/\//.test(filepath)
if (!isUrl) { if (!isUrl) {
const attachment = await readLocalAttachment(filepath) const attachment = await readLocalAttachment(filepath)
const filename = path.basename(filepath) if (attachment) {
if (attachment?.type === "text") { if (changed()) return
pasteText(attachment.content, `[SVG: ${filename ?? "image"}]`) pasteLocalAttachment(filepath, attachment)
return return
} }
if (attachment?.type === "binary") {
await pasteAttachment({ const filepaths = parsePastedFilepaths(pastedContent, terminalEnvironment.platform)
filename, if (filepaths.length > 1) {
uri: `data:${attachment.mime};base64,${Buffer.from(attachment.content).toString("base64")}`, let remaining = MAX_LOCAL_ATTACHMENT_BYTES
}) const attachments: Array<{ filepath: string; attachment: LocalAttachment }> = []
return 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 (changed()) return
for (const item of attachments) pasteLocalAttachment(item.filepath, item.attachment)
return
}
} }
} }
if (changed()) return
const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1 const lineCount = (pastedContent.match(/\n/g)?.length ?? 0) + 1
if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") { if ((lineCount >= 3 || pastedContent.length > 150) && config.prompt?.paste !== "full") {
const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => { const extmark = input.extmarks.getAllForTypeId(promptPartTypeId).find((extmark) => {
@ -1226,12 +1277,27 @@ export function Prompt(props: PromptProps) {
}, 0) }, 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 currentOffset = input.cursorOffset
const extmarkStart = currentOffset const extmarkStart = currentOffset
const pdf = file.uri.startsWith("data:application/pdf;") const pdf = file.uri.startsWith("data:application/pdf;")
const prefix = pdf ? "data:application/pdf;" : "data:image/" const count = pdf
const count = store.prompt.files?.filter((attachment) => attachment.uri.startsWith(prefix)).length ?? 0 ? (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 virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " " const textToInsert = virtualText + " "
@ -1263,7 +1329,6 @@ export function Prompt(props: PromptProps) {
draft.extmarkToPart.set(extmarkId, { type: "file", index }) draft.extmarkToPart.set(extmarkId, { type: "file", index })
}), }),
) )
return
} }
function clearPrompt() { function clearPrompt() {
@ -1382,6 +1447,74 @@ export function Prompt(props: PromptProps) {
flexGrow={1} flexGrow={1}
width="100%" width="100%"
> >
<Show when={config.prompt?.image_preview && visibleImageAttachments().length > 0}>
<box
width="100%"
height={imagePreviewHeight() + 1}
flexDirection="row"
flexShrink={0}
justifyContent="flex-start"
gap={1}
paddingBottom={1}
>
<For each={visibleImageAttachments()}>
{(file, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width={imagePreviewWidth()}
height={imagePreviewHeight()}
flexBasis={imagePreviewWidth()}
flexShrink={1}
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
openImagePreview(index())
}}
>
<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={imageAttachments().length > visibleImageAttachments().length}>
<box
width={8}
height={imagePreviewHeight()}
flexBasis={8}
flexShrink={1}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
openImagePreview(visibleImageAttachments().length)
}}
>
<text fg={theme.text.subdued} wrapMode="none" truncate>
+{imageAttachments().length - visibleImageAttachments().length} more
</text>
</box>
</Show>
</box>
</Show>
<textarea <textarea
width="100%" width="100%"
placeholder={placeholderText()} placeholder={placeholderText()}
@ -1409,7 +1542,7 @@ export function Prompt(props: PromptProps) {
// hangul) is flushed to plainText before we read it for submission. // hangul) is flushed to plainText before we read it for submission.
setTimeout(() => setTimeout(() => submit(), 0), 0) setTimeout(() => setTimeout(() => submit(), 0), 0)
}} }}
onPaste={async (event: PasteEvent) => { onPaste={(event: PasteEvent) => {
if (props.disabled) { if (props.disabled) {
event.preventDefault() event.preventDefault()
return return
@ -1431,7 +1564,7 @@ export function Prompt(props: PromptProps) {
// default paste unless we suppress it first and handle insertion ourselves. // default paste unless we suppress it first and handle insertion ourselves.
event.preventDefault() event.preventDefault()
await pasteInputText(normalizedText) void enqueuePaste((changed) => pasteInputText(normalizedText, changed))
}} }}
ref={(r: TextareaRenderable) => { ref={(r: TextareaRenderable) => {
input = r input = r

View file

@ -1,9 +1,13 @@
import { readFile } from "node:fs/promises"
import path from "node:path" 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<{ export type LocalFiles = Readonly<{
readText(path: string): Promise<string> readText(path: string, maxBytes: number): Promise<string>
readBytes(path: string): Promise<Uint8Array> readBytes(path: string, maxBytes: number): Promise<Uint8Array>
mime(path: string): Promise<string> mime(path: string): Promise<string>
}> }>
@ -11,14 +15,15 @@ export type LocalAttachment =
| Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }> | Readonly<{ type: "text"; mime: "image/svg+xml"; content: string }>
| Readonly<{ type: "binary"; mime: string; content: Uint8Array }> | Readonly<{ type: "binary"; mime: string; content: Uint8Array }>
export function readLocalAttachment(file: string) { export function readLocalAttachment(file: string, maxBytes = MAX_LOCAL_ATTACHMENT_BYTES) {
return readLocalAttachmentWith( return readLocalAttachmentWith(
{ {
readText: (value) => readFile(value, "utf8"), readText: async (value, limit) => (await readFileBounded(value, limit)).toString("utf8"),
readBytes: (value) => readFile(value), readBytes: readFileBounded,
mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream", mime: async (value) => mimeTypes[path.extname(value).toLowerCase()] ?? "application/octet-stream",
}, },
file, file,
maxBytes,
) )
} }
@ -33,16 +38,99 @@ const mimeTypes: Record<string, string> = {
".webp": "image/webp", ".webp": "image/webp",
} }
export async function readLocalAttachmentWith(files: LocalFiles, path: string): Promise<LocalAttachment | undefined> { async function readFileBounded(file: string, maxBytes: number) {
const source = Bun.file(file)
if (!(await source.exists())) throw new Error("Attachment does not exist")
if (source.size > maxBytes) throw new Error("Attachment exceeds the local file limit")
const content = Buffer.from(await source.slice(0, maxBytes + 1).arrayBuffer())
if (content.byteLength > maxBytes) throw new Error("Attachment exceeds the local file limit")
return content
}
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) 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") { if (mime === "image/svg+xml") {
const content = await files.readText(path).catch(() => undefined) const content = await files.readText(path, maxBytes).catch(() => undefined)
if (!content) return if (!content || Buffer.byteLength(content) > maxBytes) return undefined
return { type: "text", mime, content } return { type: "text", mime, content }
} }
if (!mime.startsWith("image/") && mime !== "application/pdf") return const content = await files.readBytes(path, maxBytes).catch(() => undefined)
const content = await files.readBytes(path).catch(() => undefined) if (!content || content.byteLength > maxBytes) return undefined
if (!content) return
return { type: "binary", mime, content } return { type: "binary", mime, content }
} }

View file

@ -105,6 +105,9 @@ export const Info = Schema.Struct({
paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({ paste: Schema.optional(Schema.Literals(["compact", "full"])).annotate({
description: "Display large pastes as compact placeholders or full text", 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" }), ).annotate({ description: "Prompt input behavior" }),
session: Schema.optional( session: Schema.optional(

View file

@ -162,6 +162,7 @@ export const Definitions = {
prompt_submit: keybind("none", "Submit prompt"), prompt_submit: keybind("none", "Submit prompt"),
prompt_editor_context_clear: keybind("none", "Clear editor context"), 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_skills: keybind("none", "Open skill selector"),
prompt_stash: keybind("none", "Stash prompt"), prompt_stash: keybind("none", "Stash prompt"),
prompt_stash_pop: keybind("none", "Pop stashed prompt"), prompt_stash_pop: keybind("none", "Pop stashed prompt"),
@ -360,6 +361,7 @@ export const CommandMap = {
display_thinking: "session.toggle.thinking", display_thinking: "session.toggle.thinking",
prompt_submit: "prompt.submit", prompt_submit: "prompt.submit",
prompt_editor_context_clear: "prompt.editor_context.clear", prompt_editor_context_clear: "prompt.editor_context.clear",
prompt_images_view: "prompt.images.view",
prompt_skills: "prompt.skills", prompt_skills: "prompt.skills",
prompt_stash: "prompt.stash", prompt_stash: "prompt.stash",
prompt_stash_pop: "prompt.stash.pop", prompt_stash_pop: "prompt.stash.pop",

View file

@ -74,11 +74,11 @@ test("searches settings globally and opens the matching setting", async () => {
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable) await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
app.mockInput.pressArrow("down") 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() 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() app.mockInput.pressEnter()
await app.waitFor(() => current.attention?.sound === false) await app.waitFor(() => current.prompt?.image_preview === true)
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }

View file

@ -19,6 +19,7 @@ test("validates the session tabs setting", () => {
expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } }) expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } })
expect(() => decode({ tabs: { enabled: "on" } })).toThrow() expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
}) })
test("resolves nested config and keybind defaults", () => { test("resolves nested config and keybind defaults", () => {

View file

@ -1,16 +1,27 @@
import { afterAll, expect, mock, test } from "bun:test" import { afterAll, expect, mock, test } from "bun:test"
import { TextareaRenderable, type HostClipboardService } from "@opentui/core" import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { createTestRenderer } from "@opentui/core/testing" 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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { Global } from "@opencode-ai/util/global" import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem } from "effect" import { Effect, FileSystem } from "effect"
import { createComponent } from "solid-js" import { createComponent } from "solid-js"
import { Prompt } from "../../src/component/prompt" import { Prompt, type PromptRef } from "../../src/component/prompt"
import { createEventStream, createFetch } from "../fixture/tui-client" import { createEventStream, createFetch } from "../fixture/tui-client"
const openTui = { ...(await import("@opentui/core")) } 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 activeSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined
let reads = 0 let activeHost: HostClipboardService | undefined
let activePromptRef: PromptRef | undefined
await mock.module("@opentui/core", () => ({ await mock.module("@opentui/core", () => ({
...openTui, ...openTui,
@ -18,80 +29,211 @@ await mock.module("@opentui/core", () => ({
if (!activeSetup) throw new Error("Prompt renderer is not mounted") if (!activeSetup) throw new Error("Prompt renderer is not mounted")
return activeSetup.renderer return activeSetup.renderer
}, },
createHostClipboard: () => createHostClipboard: () => {
({ if (!activeHost) throw new Error("Prompt clipboard is not mounted")
maxWriteBytes: 8 * 1024 * 1024, return activeHost
async read() { },
reads++
return { status: "empty" }
},
async writeText() {
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}) satisfies HostClipboardService,
})) }))
await mock.module("../../src/routes/home", () => ({ await mock.module("../../src/routes/home", () => ({
Home: () => createComponent(Prompt, { showPlaceholder: false }), Home: () =>
createComponent(Prompt, {
ref: (value) => (activePromptRef = value),
showPlaceholder: false,
}),
})) }))
const { run } = await import("../../src/app") const { run } = await import("../../src/app")
afterAll(() => mock.restore()) afterAll(() => mock.restore())
test("only zero-byte terminal pastes read the host clipboard", async () => { async function mountPrompt(read: () => Promise<ClipboardReadResult>, imagePreview = false) {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false }) const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
activeSetup = setup let reads = 0
reads = 0 let ready!: () => void
const mounted = Promise.withResolvers<void>() const mounted = new Promise<void>((resolve) => (ready = resolve))
const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer) const setTitle = setup.renderer.setTerminalTitle.bind(setup.renderer)
setup.renderer.setTerminalTitle = (title) => { setup.renderer.setTerminalTitle = (title) => {
if (title === "OpenCode") mounted.resolve() if (title === "OpenCode") ready()
setTitle(title) setTitle(title)
} }
const host: HostClipboardService = {
maxWriteBytes: 8 * 1024 * 1024,
async read() {
reads++
return read()
},
async writeText() {
return { status: "written" }
},
async clear() {
return { status: "cleared" }
},
async dispose() {},
}
activeSetup = setup
activeHost = host
activePromptRef = undefined
const events = createEventStream() const events = createEventStream()
const calls = createFetch(undefined, events) const calls = createFetch(undefined, events)
const preloaded = Promise.withResolvers<void>() let preloaded!: () => void
const preload = new Promise<void>((resolve) => (preloaded = resolve))
const server = Bun.serve({ const server = Bun.serve({
port: 0, port: 0,
fetch: async (request) => { fetch: async (request) => {
const response = await calls.fetch(request) const response = await calls.fetch(request)
if (new URL(request.url).pathname === "/api/session/active") preloaded.resolve() const url = new URL(request.url)
if (url.pathname === "/api/session/active") preloaded()
return response return response
}, },
}) })
const task = Effect.runPromise( let task: Promise<unknown> | undefined
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ prompt: { paste: "full" as const } }), update: async () => ({}) },
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
try { try {
await mounted.promise task = Effect.runPromise(
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable) run({
await preloaded.promise app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: {
get: async () => ({ prompt: { paste: "full" as const, image_preview: imagePreview } }),
update: async () => ({}),
},
packages: { resolve: async () => undefined },
args: {},
log: () => {},
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await mounted
await setup.waitFor(() => activePromptRef?.focused === true)
await preload
await Bun.sleep(0) await Bun.sleep(0)
const input = setup.renderer.currentFocusedEditor } catch (error) {
if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused")
await setup.mockInput.pasteBracketedText(" \t\n")
await setup.waitFor(() => input.plainText === " \t\n")
expect(reads).toBe(0)
setup.renderer.keyInput.processPaste(new Uint8Array())
await setup.waitFor(() => reads === 1)
expect(input.plainText).toBe(" \t\n")
} finally {
setup.renderer.destroy() setup.renderer.destroy()
await task await task?.catch(() => undefined)
await server.stop() await server.stop()
activeSetup = undefined activeSetup = undefined
activeHost = undefined
activePromptRef = undefined
throw error
}
return {
setup,
get input() {
const input = setup.renderer.currentFocusedEditor
if (!(input instanceof TextareaRenderable)) throw new Error("Prompt textarea is not focused")
return input
},
get reads() {
return reads
},
get prompt() {
if (!activePromptRef) throw new Error("Prompt ref is not mounted")
return activePromptRef
},
async dispose() {
activePromptRef?.reset()
setup.renderer.destroy()
await task
await server.stop()
activeSetup = undefined
activeHost = undefined
activePromptRef = undefined
},
}
}
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("creates one image mention from PNG clipboard bytes", async () => {
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()
await prompt.setup.waitForFrame((frame) => frame.includes("+1 more"))
} finally {
await prompt.dispose()
}
})
test("opens image attachments by keyboard and mouse", 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(thumbnail.x, thumbnail.y, MouseButtons.LEFT)
await prompt.setup.waitForFrame((frame) => frame.includes("Image 2 of 2"))
const large = prompt.setup.renderer.root.findDescendantById("prompt-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"))
await prompt.setup.waitFor(() => prompt.setup.renderer.currentFocusedEditor === prompt.input)
} 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 })
} }
}) })

View file

@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test" 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" import type { LocalFiles } from "../../src/component/prompt/local-attachment"
function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles { function files(input: { mime: string; text?: string; bytes?: Uint8Array }): LocalFiles {
@ -11,6 +11,40 @@ function files(input: { mime: string; text?: string; bytes?: Uint8Array }): Loca
} }
describe("prompt local attachments", () => { 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",
])
expect(parsePastedFilepaths('"/tmp/O\'Brien.png" /tmp/two.webp', "linux")).toEqual([
"/tmp/O'Brien.png",
"/tmp/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 () => { test("reads SVG attachments as text", async () => {
expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({ expect(await readLocalAttachmentWith(files({ mime: "image/svg+xml", text: "<svg />" }), "/tmp/image.svg")).toEqual({
type: "text", type: "text",
@ -39,5 +73,8 @@ describe("prompt local attachments", () => {
"/tmp/missing.png", "/tmp/missing.png",
), ),
).toBeUndefined() ).toBeUndefined()
expect(
await readLocalAttachmentWith(files({ mime: "image/png", bytes: new Uint8Array(2) }), "/tmp/large.png", 1),
).toBeUndefined()
}) })
}) })