tui: add opt-in prompt image previews

This commit is contained in:
Simon Klee
2026-07-30 08:06:56 +02:00
parent 7325eed4ec
commit 9e00ec4bd6
6 changed files with 279 additions and 11 deletions
@@ -189,6 +189,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",
+77 -2
View File
@@ -7,7 +7,7 @@ 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"
@@ -94,6 +94,7 @@ export type PromptRef = {
}
const DRAFT_RETENTION_MIN_CHARS = 20
const MAX_IMAGE_PREVIEWS = 3
function randomIndex(count: number) {
if (count <= 0) return 0
@@ -310,6 +311,22 @@ export function Prompt(props: PromptProps) {
extmarkToPart: new Map(),
interrupt: 0,
})
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 imagePreviewLimit = createMemo(() =>
Math.max(
1,
Math.min(
MAX_IMAGE_PREVIEWS,
Math.floor((Math.min(70, Math.max(1, dimensions().width - 9)) - 8) / (imagePreviewWidth() + 1)),
),
),
)
const visibleImageAttachments = createMemo(() => imageAttachments().slice(0, imagePreviewLimit()))
const hiddenImageAttachmentCount = createMemo(() => Math.max(0, imageAttachments().length - imagePreviewLimit()))
createEffect(
on(
@@ -1213,7 +1230,10 @@ export function Prompt(props: PromptProps) {
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 =
store.prompt.files?.filter(
(attachment) => typeof attachment.uri === "string" && attachment.uri.startsWith(prefix),
).length ?? 0
const virtualText = pdf ? `[PDF ${count + 1}]` : `[Image ${count + 1}]`
const extmarkEnd = extmarkStart + virtualText.length
const textToInsert = virtualText + " "
@@ -1360,6 +1380,61 @@ export function Prompt(props: PromptProps) {
flexGrow={1}
width="100%"
>
<Show when={(config.prompt?.image_preview ?? false) && imageAttachments().length > 0}>
<box
id="prompt-image-previews"
width="100%"
height={imagePreviewHeight() + 2}
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="100%"
flexBasis={imagePreviewWidth()}
flexShrink={1}
flexDirection="column"
>
<image
id={`prompt-image-preview-${index()}`}
source={file.uri}
fit="cover"
protocol="auto"
width="100%"
height={imagePreviewHeight()}
onError={() => setFailed(true)}
/>
<text fg={theme.text.subdued} wrapMode="none" truncate>
{failed() ? "No preview" : (file.mention?.text ?? `Image ${index() + 1}`)}
</text>
</box>
)
}}
</For>
<Show when={hiddenImageAttachmentCount() > 0 && dimensions().width >= 22}>
<box
id="prompt-image-overflow"
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()}
+3
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(
@@ -74,11 +74,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()
}
+7
View File
@@ -21,6 +21,13 @@ test("validates the session tabs setting", () => {
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
})
test("validates the prompt image preview setting", () => {
const decode = Schema.decodeUnknownSync(Info)
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
expect(() => decode({ prompt: { image_preview: "on" } })).toThrow()
})
test("resolves nested config and keybind defaults", () => {
const config = resolve(
{
+180 -6
View File
@@ -1,14 +1,35 @@
import { afterAll, expect, mock, test } from "bun:test"
import { TextareaRenderable, type ClipboardReadResult, type HostClipboardService } from "@opentui/core"
import {
ImageRenderable,
Renderable,
TextareaRenderable,
type ClipboardReadResult,
type HostClipboardService,
} from "@opentui/core"
import { createTestRenderer } 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 = Uint8Array.from(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg==",
"base64",
),
)
function findRenderable(root: Renderable, id: string): Renderable | undefined {
if (root.id === id) return root
return root
.getChildren()
.map((child) => findRenderable(child, id))
.find(Boolean)
}
let activeSetup: Awaited<ReturnType<typeof createTestRenderer>> | undefined
let activeHost: HostClipboardService | undefined
let activePromptRef: PromptRef | undefined
@@ -35,8 +56,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))
@@ -83,7 +104,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: () => {},
@@ -172,7 +196,7 @@ test("normalizes host clipboard text once before inserting it", async () => {
test("creates one image mention from PNG clipboard bytes", async () => {
const prompt = await mountPrompt(async () => ({
status: "read",
representation: { mimeType: "image/png", bytes: new Uint8Array([137, 80, 78, 71]) },
representation: { mimeType: "image/png", bytes: PNG_1X1 },
}))
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
@@ -180,6 +204,125 @@ test("creates one image mention from PNG clipboard bytes", async () => {
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,${Buffer.from(PNG_1X1).toString("base64")}`,
name: "clipboard",
mention: { start: 0, end: 9, text: "[Image 1]" },
},
])
expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0")).toBeUndefined()
expect(prompt.reads).toBe(1)
} finally {
await prompt.dispose()
}
})
test("renders at most three left-aligned square image crops", async () => {
const prompt = await mountPrompt(
async () => ({
status: "read",
representation: { mimeType: "image/png", bytes: PNG_1X1 },
}),
true,
)
try {
for (let index = 0; index < 4; index++) {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === index + 1)
}
const first = findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0")
expect(first).toBeInstanceOf(ImageRenderable)
if (!(first instanceof ImageRenderable)) throw new Error("Image preview did not render")
await first.loadPromise
const previews = findRenderable(prompt.setup.renderer.root, "prompt-image-previews")
if (!previews) throw new Error("Image preview row did not render")
expect(first.fit).toBe("cover")
expect(first.x).toBe(previews.x)
expect(first.width).toBe(first.height * 2)
expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-1")).toBeInstanceOf(ImageRenderable)
expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-2")).toBeInstanceOf(ImageRenderable)
expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-3")).toBeUndefined()
await prompt.setup.waitForFrame((frame) => frame.includes("+1 more"))
} finally {
await prompt.dispose()
}
})
test("reduces the preview count to fit a narrow terminal", async () => {
const prompt = await mountPrompt(
async () => ({
status: "read",
representation: { mimeType: "image/png", bytes: PNG_1X1 },
}),
true,
32,
)
try {
for (let index = 0; index < 4; index++) {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === index + 1)
}
expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0")).toBeInstanceOf(ImageRenderable)
expect(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-1")).toBeUndefined()
await prompt.setup.waitForFrame((frame) => frame.includes("+3 more"))
const previews = findRenderable(prompt.setup.renderer.root, "prompt-image-previews")
const overflow = findRenderable(prompt.setup.renderer.root, "prompt-image-overflow")
if (!previews || !overflow) throw new Error("Narrow preview layout did not render")
expect(overflow.x + overflow.width).toBeLessThanOrEqual(previews.x + previews.width)
for (const preview of previews.getChildren()) {
expect(preview.x + preview.width).toBeLessThanOrEqual(previews.x + previews.width)
}
} finally {
await prompt.dispose()
}
})
test("removes an image preview when its mention is deleted", async () => {
const prompt = await mountPrompt(
async () => ({
status: "read",
representation: { mimeType: "image/png", bytes: PNG_1X1 },
}),
true,
)
try {
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(
() => findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0") instanceof ImageRenderable,
)
const preview = findRenderable(prompt.setup.renderer.root, "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(findRenderable(prompt.setup.renderer.root, "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 +330,38 @@ 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(
async () => ({
status: "read",
representation: { mimeType: "image/png", bytes: PNG_1X1 },
}),
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(findRenderable(prompt.setup.renderer.root, "prompt-image-preview-0")).toBeUndefined()
prompt.setup.renderer.keyInput.processPaste(new Uint8Array())
await prompt.setup.waitFor(() => prompt.reads === 1 && prompt.prompt.current.files?.length === 1)
expect(prompt.prompt.current.files?.[0]?.uri).toBe(
`data:image/png;base64,${Buffer.from(PNG_1X1).toString("base64")}`,
)
} finally {
await prompt.dispose()
}