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