tui: add opt-in prompt image previews
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user