fix(tui): gate transcript image previews

This commit is contained in:
Simon Klee
2026-07-31 07:14:15 +02:00
parent 913d9d20e8
commit 8c3b64b130
6 changed files with 96 additions and 26 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",
+3
View File
@@ -122,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",
}),
+28 -12
View File
@@ -146,7 +146,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(() => sessionImageKeys(messages(), session()?.revert?.messageID))
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)
@@ -1912,6 +1914,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
</For>
</box>
</Show>
<SessionImages images={sessionMessageImages(props.message)} visible={ctx.imageKeys()} />
</box>
</box>
</Show>
@@ -2322,13 +2325,18 @@ function ToolPartWithImages(props: { messageID: string; part: SessionMessageAssi
export function ToolImages(props: {
parts: readonly { messageID: string; part: SessionMessageAssistantTool }[]
visible: ReadonlySet<string>
}) {
const images = createMemo(() => props.parts.flatMap((item) => inlineToolImages(item.messageID, item.part)))
return <SessionImages images={images()} visible={props.visible} />
}
export function SessionImages(props: {
images: readonly { key: string; uri: string }[]
visible: ReadonlySet<string>
}) {
const dimensions = useTerminalDimensions()
const images = createMemo(() =>
props.parts
.flatMap((item) => inlineToolImages(item.messageID, item.part))
.filter((image) => props.visible.has(image.key)),
)
const images = createMemo(() => props.images.filter((image) => props.visible.has(image.key)))
const height = createMemo(() => Math.max(6, Math.min(18, Math.floor((dimensions().width - 6) / 4))))
return (
@@ -2348,7 +2356,7 @@ export function ToolImages(props: {
>
<Show when={!failed()} fallback={<text>No preview</text>}>
<image
id={`session-tool-image-${image.key}`}
id={`session-image-${image.key}`}
source={image.uri}
fit="fit"
protocol="auto"
@@ -2373,16 +2381,24 @@ export function sessionImageKeys(messages: readonly SessionMessageInfo[], revert
return new Set(
messages
.filter((message) => !revertBoundary || message.id < revertBoundary)
.flatMap((message) =>
message.type === "assistant"
? message.content.flatMap((part) => (part.type === "tool" ? inlineToolImages(message.id, part) : []))
: [],
)
.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/")
@@ -13,6 +13,7 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
test("searches settings globally and opens the matching setting", async () => {
let current: Info = {}
let open!: () => void
const service: Interface = {
get: async () => current,
update: async (update) => {
@@ -25,6 +26,7 @@ test("searches settings globally and opens the matching setting", async () => {
function Fixture() {
const dialog = useDialog()
open = () => dialog.replace(() => <CommandPaletteDialog />)
Keymap.createLayer(() => ({
mode: "global",
commands: [
@@ -44,7 +46,7 @@ test("searches settings globally and opens the matching setting", async () => {
},
],
}))
onMount(() => dialog.replace(() => <CommandPaletteDialog />))
onMount(open)
return null
}
@@ -79,6 +81,16 @@ test("searches settings globally and opens the matching setting", async () => {
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Image previews"))
app.mockInput.pressEnter()
await app.waitFor(() => current.prompt?.image_preview === true)
open()
await app.waitForFrame((frame) => frame.includes("New session"))
await app.waitFor(() => app.renderer.currentFocusedEditor instanceof InputRenderable)
for (const key of "transcript images") app.mockInput.pressKey(key)
await app.waitForFrame((frame) => frame.includes("Transcript images"))
app.mockInput.pressEnter()
await app.waitForFrame((frame) => frame.includes("Settings") && frame.includes("Transcript images"))
app.mockInput.pressEnter()
await app.waitFor(() => current.session?.image_preview === true)
} finally {
app.renderer.destroy()
}
@@ -1,8 +1,8 @@
import { afterEach, expect, test } from "bun:test"
import { ImageRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { SessionMessageAssistant, SessionMessageAssistantTool } from "@opencode-ai/client"
import { sessionImageKeys, ToolImages } from "../../../src/routes/session"
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=="
@@ -21,20 +21,16 @@ test("renders bounded inline images from completed tool content", async () => {
})
await setup.renderOnce()
const first = setup.renderer.root.findDescendantById("session-tool-image-message-1:call-1:1")
const first = setup.renderer.root.findDescendantById("session-image-message-1:call-1:1")
if (!(first instanceof ImageRenderable)) throw new Error("Tool image did not render")
await first.loadPromise
expect(first.fit).toBe("fit")
expect(first.height).toBe(18)
expect(setup.renderer.root.findDescendantById("session-tool-image-message-1:call-1:2")).toBeInstanceOf(
ImageRenderable,
)
expect(setup.renderer.root.findDescendantById("session-tool-image-message-1:call-1:3")).toBeInstanceOf(
ImageRenderable,
)
expect(setup.renderer.root.findDescendantById("session-tool-image-message-1:call-1:0")).toBeUndefined()
expect(setup.renderer.root.findDescendantById("session-tool-image-message-1:call-1:4")).toBeUndefined()
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:2")).toBeInstanceOf(ImageRenderable)
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 images")
})
@@ -50,7 +46,39 @@ test("does not fetch image tool content from external sources", async () => {
)
await setup.renderOnce()
expect(setup.renderer.root.findDescendantById("session-tool-image-message-1:call-1:0")).toBeUndefined()
expect(setup.renderer.root.findDescendantById("session-image-message-1:call-1:0")).toBeUndefined()
})
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("fit")
})
test("does not reserve image slots for reverted messages", () => {
@@ -67,7 +95,7 @@ test("falls back when inline image content is malformed", async () => {
)
await setup.renderOnce()
const preview = setup.renderer.root.findDescendantById("session-tool-image-message-1:call-1:0")
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
+2
View File
@@ -21,6 +21,8 @@ test("validates boolean settings", () => {
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 } })
expect(() => decode({ session: { image_preview: "on" } })).toThrow()
})
test("resolves nested config and keybind defaults", () => {