tui: open transcript images on click

Make inline transcript images clickable and show the full preview. Use
bounded thumbnails so image attachments remain usable without crowding the
session view.
This commit is contained in:
Simon Klee
2026-07-31 10:45:57 +02:00
parent 8c3b64b130
commit 73dfbe323e
6 changed files with 117 additions and 89 deletions
@@ -43,7 +43,7 @@ export function DialogImagePreview(props: { images: readonly ImagePreviewItem[];
}))
return (
<box id="prompt-image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box id="image-viewer" paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
Image {index() + 1} of {props.images.length}
@@ -53,7 +53,7 @@ export function DialogImagePreview(props: { images: readonly ImagePreviewItem[];
</text>
</box>
<image
id="prompt-image-viewer-image"
id="image-viewer-image"
source={current().uri}
fit="fit"
protocol="auto"
+63 -42
View File
@@ -23,7 +23,7 @@ import { SplitBorder } from "../../ui/border"
import { useTuiPaths, useTuiTerminalEnvironment } from "../../context/runtime"
import { Spinner, SPINNER_FRAMES } from "../../component/spinner"
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,
@@ -52,6 +52,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"
@@ -1484,6 +1485,7 @@ function SessionGroupView(props: {
}) {
const theme = useTheme()
const ctx = use()
const dialog = useDialog()
const renderer = useRenderer()
const [expanded, setExpanded] = createSignal(false)
const [hover, setHover] = createSignal(false)
@@ -1539,7 +1541,11 @@ function SessionGroupView(props: {
<Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(item) => <ToolPart part={item.part} />}</For>
</Show>
<ToolImages parts={grouped()} visible={ctx.imageKeys()} />
<ToolImages
parts={grouped()}
visible={ctx.imageKeys()}
onOpen={(images, index) => dialog.replace(() => <DialogImagePreview images={images} initial={index} />)}
/>
<For each={pending()}>{(item) => <ToolPartWithImages messageID={item.messageID} part={item.part} />}</For>
</Show>
</Show>
@@ -1845,6 +1851,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
const data = useData()
const local = useLocal()
const files = createMemo(() => props.message.files ?? [])
const images = createMemo(() => sessionMessageImages(props.message))
const themes = useThemes()
const theme = useTheme("elevated")
const mode = themes.mode
@@ -1863,30 +1870,26 @@ function UserMessage(props: { message: SessionMessageUser }) {
border={["left"]}
borderColor={queued() ? theme.border.default : color()}
customBorderChars={SplitBorder.customBorderChars}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
onMouseOver={() => setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={props.message.id}
sessionID={ctx.sessionID}
setPrompt={(value) => promptRef.current?.set(value)}
/>
))
}}
>
<box
onMouseOver={() => {
setHover(true)
}}
onMouseOut={() => {
setHover(false)
}}
onMouseUp={() => {
if (renderer.getSelection()?.getSelectedText()) return
dialog.replace(() => (
<DialogMessage
messageID={props.message.id}
sessionID={ctx.sessionID}
setPrompt={(value) => promptRef.current?.set(value)}
/>
))
}}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={hover() ? theme.raise(theme.background.default) : theme.background.default}
flexShrink={0}
>
<SessionImages
images={images()}
visible={ctx.imageKeys()}
onOpen={(images, index) => dialog.replace(() => <DialogImagePreview images={images} initial={index} />)}
/>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} flexShrink={0}>
<text fg={theme.text.default}>{props.message.text}</text>
<Show when={files().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
@@ -1914,7 +1917,6 @@ function UserMessage(props: { message: SessionMessageUser }) {
</For>
</box>
</Show>
<SessionImages images={sessionMessageImages(props.message)} visible={ctx.imageKeys()} />
</box>
</box>
</Show>
@@ -1995,9 +1997,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
<Show when={exploration().get((content as SessionMessageAssistantTool).id)?.first !== false}>
<Show
when={exploration().get((content as SessionMessageAssistantTool).id)}
fallback={
<ToolPartWithImages messageID={props.message.id} part={content as SessionMessageAssistantTool} />
}
fallback={<ToolPart part={content as SessionMessageAssistantTool} />}
>
{(summary) => <ExplorationSummary {...summary()} />}
</Show>
@@ -2314,10 +2314,15 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
function ToolPartWithImages(props: { messageID: string; part: SessionMessageAssistantTool }) {
const ctx = use()
const dialog = useDialog()
return (
<>
<ToolPart part={props.part} />
<ToolImages parts={[props]} visible={ctx.imageKeys()} />
<ToolImages
parts={[props]}
visible={ctx.imageKeys()}
onOpen={(images, index) => dialog.replace(() => <DialogImagePreview images={images} initial={index} />)}
/>
</>
)
}
@@ -2325,40 +2330,52 @@ function ToolPartWithImages(props: { messageID: string; part: SessionMessageAssi
export function ToolImages(props: {
parts: readonly { messageID: string; part: SessionMessageAssistantTool }[]
visible: ReadonlySet<string>
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void
}) {
const images = createMemo(() => props.parts.flatMap((item) => inlineToolImages(item.messageID, item.part)))
return <SessionImages images={images()} visible={props.visible} />
return <SessionImages images={images()} visible={props.visible} onOpen={props.onOpen} />
}
export function SessionImages(props: {
images: readonly { key: string; uri: string }[]
visible: ReadonlySet<string>
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void
}) {
const dimensions = useTerminalDimensions()
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))))
const height = createMemo(() => Math.max(4, Math.min(8, Math.floor(dimensions().height / 4))))
const width = createMemo(() => height() * 2)
const limit = createMemo(() =>
Math.max(0, Math.min(3, Math.floor((Math.max(0, dimensions().width - 6) + 1) / (width() + 1)))),
)
const count = createMemo(() => Math.min(limit(), images().length))
return (
<Show when={images().length > 0}>
<box flexDirection="column" flexShrink={0} paddingLeft={3} paddingRight={2} gap={1}>
<For each={images().slice(0, 3)}>
{(image) => {
<Show when={count() > 0}>
<box flexDirection="row" flexShrink={0} paddingTop={1} paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}>
<For each={images().slice(0, count())}>
{(image, index) => {
const [failed, setFailed] = createSignal(false)
return (
<box
width="100%"
maxWidth={70}
width={width()}
height={height()}
flexBasis={width()}
flexShrink={0}
alignItems="center"
justifyContent="center"
onMouseUp={(event: MouseEvent) => {
if (event.button !== 0) return
event.stopPropagation()
props.onOpen?.(images(), index())
}}
>
<Show when={!failed()} fallback={<text>No preview</text>}>
<image
id={`session-image-${image.key}`}
source={image.uri}
fit="fit"
fit="cover"
protocol="auto"
width="100%"
height="100%"
@@ -2369,8 +2386,12 @@ export function SessionImages(props: {
)
}}
</For>
<Show when={images().length > 3}>
<text>+{images().length - 3} more images</text>
<Show when={images().length > count()}>
<box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
<text wrapMode="none" truncate>
+{images().length - count()} more
</text>
</box>
</Show>
</box>
</Show>
@@ -2402,7 +2423,7 @@ export function sessionMessageImages(message: SessionMessageInfo) {
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/")
? [{ ...content, key: `${messageID}:${part.id}:${index}` }]
? [{ key: `${messageID}:${part.id}:${index}`, uri: content.uri }]
: [],
)
}
@@ -5,6 +5,7 @@ import { expect, test } from "bun:test"
import { onMount } from "solid-js"
import { ConfigProvider, resolve, type Info, type Interface } from "../../../src/config"
import { CommandPaletteDialog } from "../../../src/component/command-palette"
import { settingID, settings } from "../../../src/component/dialog-config"
import { Keymap } from "../../../src/context/keymap"
import { ThemeProvider } from "../../../src/context/theme"
import { DialogProvider, useDialog } from "../../../src/ui/dialog"
@@ -13,7 +14,10 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
test("searches settings globally and opens the matching setting", async () => {
let current: Info = {}
let open!: () => void
expect(settings.find((setting) => settingID(setting) === "session.image_preview")).toMatchObject({
title: "Transcript images",
default: false,
})
const service: Interface = {
get: async () => current,
update: async (update) => {
@@ -26,7 +30,6 @@ test("searches settings globally and opens the matching setting", async () => {
function Fixture() {
const dialog = useDialog()
open = () => dialog.replace(() => <CommandPaletteDialog />)
Keymap.createLayer(() => ({
mode: "global",
commands: [
@@ -46,7 +49,7 @@ test("searches settings globally and opens the matching setting", async () => {
},
],
}))
onMount(open)
onMount(() => dialog.replace(() => <CommandPaletteDialog />))
return null
}
@@ -81,16 +84,6 @@ 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,5 +1,6 @@
import { afterEach, expect, test } from "bun:test"
import { ImageRenderable } from "@opentui/core"
import { MouseButtons } from "@opentui/core/testing"
import { testRender } from "@opentui/solid"
import type { SessionMessageAssistant, SessionMessageAssistantTool, SessionMessageUser } from "@opencode-ai/client"
import { sessionImageKeys, sessionMessageImages, SessionImages, ToolImages } from "../../../src/routes/session"
@@ -15,38 +16,49 @@ afterEach(() => {
})
test("renders bounded inline images from completed tool content", async () => {
setup = await testRender(() => toolImages([image, image, image, image, image, image, image]), {
width: 80,
height: 70,
})
await setup.renderOnce()
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-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")
})
test("does not fetch image tool content from external sources", async () => {
let opened = -1
setup = await testRender(
() =>
toolImages([
{ type: "file", uri: "https://example.test/image.png", mime: "image/png" },
{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" },
{ type: "file", uri: "data:text/plain;base64,SGVsbG8=", mime: "text/plain" },
]),
{ width: 80, height: 24 },
() => toolImages([image, image, image, image, image, image, image], (_, index) => (opened = index)),
{
width: 80,
height: 70,
},
)
await setup.renderOnce()
const first = setup.renderer.root.findDescendantById("session-image-message-1:call-1:1")
const second = setup.renderer.root.findDescendantById("session-image-message-1:call-1:2")
if (!(first instanceof ImageRenderable)) throw new Error("Tool image did not render")
if (!(second instanceof ImageRenderable)) throw new Error("Second tool image did not render")
await first.loadPromise
expect(first.fit).toBe("cover")
expect(first.protocol).toBe("auto")
expect(first.width).toBe(16)
expect(first.height).toBe(8)
expect(second.y).toBe(first.y)
expect(second.x).toBe(first.x + first.width + 1)
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")
await setup.mockMouse.click(first.x, first.y, MouseButtons.LEFT)
expect(opened).toBe(0)
})
test("does not expose external image tool content to the renderer", () => {
expect(
sessionMessageImages(
assistant("message-1", [
tool([
{ type: "file", uri: "https://example.test/image.png", mime: "image/png" },
{ type: "file", uri: "file:///tmp/image.png", mime: "image/png" },
{ type: "file", uri: "data:text/plain;base64,SGVsbG8=", mime: "text/plain" },
]),
]),
),
).toEqual([])
})
test("does not render session images without the opt-in setting", async () => {
@@ -78,7 +90,7 @@ test("renders images submitted in user prompts", async () => {
if (!(preview instanceof ImageRenderable)) throw new Error("User image did not render")
await preview.loadPromise
expect(preview.fit).toBe("fit")
expect(preview.fit).toBe("cover")
})
test("does not reserve image slots for reverted messages", () => {
@@ -102,10 +114,13 @@ test("falls back when inline image content is malformed", async () => {
expect(await setup.waitForFrame((frame) => frame.includes("No preview"))).toContain("No preview")
})
function toolImages(content: Extract<SessionMessageAssistantTool["state"], { status: "completed" }>["content"]) {
function toolImages(
content: Extract<SessionMessageAssistantTool["state"], { status: "completed" }>["content"],
onOpen?: (images: readonly { key: string; uri: string }[], index: number) => void,
) {
const part = tool(content)
const message = assistant("message-1", [part])
return <ToolImages parts={[{ messageID: message.id, part }]} visible={sessionImageKeys([message])} />
return <ToolImages parts={[{ messageID: message.id, part }]} visible={sessionImageKeys([message])} onOpen={onOpen} />
}
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
-1
View File
@@ -22,7 +22,6 @@ test("validates boolean settings", () => {
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", () => {
+1 -1
View File
@@ -364,7 +364,7 @@ test("opens image attachments by keyboard, mouse, and command palette", async ()
await prompt.setup.mockMouse.click(16, 1, MouseButtons.LEFT)
await prompt.setup.waitForFrame((frame) => frame.includes("Image 2 of 2"))
const large = prompt.setup.renderer.root.findDescendantById("prompt-image-viewer-image")
const large = prompt.setup.renderer.root.findDescendantById("image-viewer-image")
if (!(large instanceof ImageRenderable)) throw new Error("Large image preview did not render")
expect(large.fit).toBe("fit")
expect(large.height).toBeGreaterThan(thumbnail.height)