From f89ac17f174ba6a78c7a4e0fdb551fcce24c460a Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 3 Aug 2026 08:20:05 +0200 Subject: [PATCH] feat(tui): render interactive transcript images --- packages/tui/src/component/dialog-config.tsx | 9 ++ packages/tui/src/config/index.tsx | 3 + packages/tui/src/routes/session/index.tsx | 89 +++++++++++++++++++- packages/tui/test/config-v2.test.tsx | 1 + 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index e07c1e54f14..9f23aafd088 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -84,6 +84,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 ff61f4e5995..817c0064b72 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 e4b59b396c8..ec37bc3a312 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -24,7 +24,7 @@ import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime" import { Spinner, SPINNER_FRAMES } from "../../component/spinner" import { PatchDiff } from "../../component/patch-diff" import { ThemeContextProvider, useTheme, useThemes } from "../../context/theme" -import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core" +import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA, MouseEvent } from "@opentui/core" import { Prompt, type PromptRef } from "../../component/prompt" import type { ModelInfo, @@ -53,6 +53,7 @@ import { useEditorContext } from "../../context/editor" import { openEditor } from "../../editor" import { useDialog } from "../../ui/dialog" import { DialogSessionRename } from "../../component/dialog-session-rename" +import { DialogImagePreview } from "../../component/dialog-image-preview" import { DialogMessage } from "./dialog-message" import { DialogFork } from "./dialog-fork" import { DialogTimeline } from "./dialog-timeline" @@ -1531,8 +1532,9 @@ function SessionGroupView(props: { 0}> - {(part) => } + {(part) => } + {(part) => } @@ -1842,6 +1844,11 @@ function UserMessage(props: { message: SessionMessageUser }) { const data = useData() const local = useLocal() const files = createMemo(() => props.message.files ?? []) + const images = createMemo(() => + files().flatMap((file) => + file.mime.startsWith("image/") ? [{ uri: `data:${file.mime};base64,${file.data}` }] : [], + ), + ) const themes = useThemes() const theme = useTheme("elevated") const mode = themes.mode @@ -1861,6 +1868,7 @@ function UserMessage(props: { message: SessionMessageUser }) { borderColor={queued() ? theme.border.default : color()} customBorderChars={SplitBorder.customBorderChars} > + { setHover(true) @@ -2084,7 +2092,7 @@ function TextPart(props: { last: boolean; part: SessionMessageAssistantText }) { // Pending messages moved to individual tool pending functions -function ToolPart(props: { part: SessionMessageAssistantTool }) { +function ToolPart(props: { part: SessionMessageAssistantTool; images?: boolean }) { const display = createMemo(() => toolDisplay(props.part.name)) const toolprops = { @@ -2108,7 +2116,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { }, } - return ( + const content = ( @@ -2154,6 +2162,79 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) { ) + return [ + content, + + + , + ] +} + +function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) { + const images = createMemo(() => props.parts.flatMap(inlineToolImages)) + return +} + +function SessionImages(props: { images: readonly { uri: string }[] }) { + const ctx = use() + const dialog = useDialog() + const dimensions = useTerminalDimensions() + const images = createMemo(() => (ctx.config.session?.image_preview ? props.images : [])) + const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4)))) + const visible = createMemo(() => images().slice(0, 3)) + + return ( + 0}> + + + {(image, index) => { + const [failed, setFailed] = createSignal(false) + return ( + { + if (event.button !== 0) return + event.stopPropagation() + dialog.replace(() => ) + }} + > + No preview}> + setFailed(true)} + /> + + + ) + }} + + visible().length}> + + + +{images().length - visible().length} more + + + + + + ) +} + +function inlineToolImages(part: SessionMessageAssistantTool) { + return toolDisplayContent(part.state).flatMap((content) => + content.type === "file" && content.mime.startsWith("image/") && content.uri.startsWith("data:image/") + ? [{ uri: content.uri }] + : [], + ) } type ToolProps = { diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index e1c3518d92f..635a93dfe51 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -20,6 +20,7 @@ test("validates the session tabs setting", () => { expect(decode({ tabs: { enabled: true, vertical: true } })).toEqual({ tabs: { enabled: true, vertical: true } }) expect(() => decode({ tabs: { enabled: "on" } })).toThrow() expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } }) + expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } }) }) test("resolves nested config and keybind defaults", () => {