diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index 258c6c2645..f8cd4646ab 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -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", diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 142ce1deb1..b404f3182d 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -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", }), diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 9b18ac5199..c04f5f4fba 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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(), + ) const currentLocation = useLocation() const location = createMemo(() => session()?.location ?? currentLocation.ref) @@ -1912,6 +1914,7 @@ function UserMessage(props: { message: SessionMessageUser }) { + @@ -2322,13 +2325,18 @@ function ToolPartWithImages(props: { messageID: string; part: SessionMessageAssi export function ToolImages(props: { parts: readonly { messageID: string; part: SessionMessageAssistantTool }[] visible: ReadonlySet +}) { + const images = createMemo(() => props.parts.flatMap((item) => inlineToolImages(item.messageID, item.part))) + + return +} + +export function SessionImages(props: { + images: readonly { key: string; uri: string }[] + visible: ReadonlySet }) { 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: { > No preview}> !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/") diff --git a/packages/tui/test/cli/tui/command-palette.test.tsx b/packages/tui/test/cli/tui/command-palette.test.tsx index 3e3fa0e2a1..8782236c77 100644 --- a/packages/tui/test/cli/tui/command-palette.test.tsx +++ b/packages/tui/test/cli/tui/command-palette.test.tsx @@ -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(() => ) Keymap.createLayer(() => ({ mode: "global", commands: [ @@ -44,7 +46,7 @@ test("searches settings globally and opens the matching setting", async () => { }, ], })) - onMount(() => dialog.replace(() => )) + 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() } diff --git a/packages/tui/test/cli/tui/session-images.test.tsx b/packages/tui/test/cli/tui/session-images.test.tsx index 852c243398..3f9e5be9f2 100644 --- a/packages/tui/test/cli/tui/session-images.test.tsx +++ b/packages/tui/test/cli/tui/session-images.test.tsx @@ -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(() => , { + 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( + () => , + { 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 diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 3a885ae985..5b962e879d 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -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", () => {