Compare commits

..

3 Commits

Author SHA1 Message Date
Simon Klee 73dfbe323e 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.
2026-07-31 18:52:58 +02:00
Simon Klee 8c3b64b130 fix(tui): gate transcript image previews 2026-07-31 18:52:58 +02:00
Simon Klee 913d9d20e8 feat(tui): render session tool images 2026-07-31 18:52:58 +02:00
8 changed files with 336 additions and 64 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",
@@ -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"
+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",
}),
+168 -61
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"
@@ -106,6 +107,7 @@ const NAVIGATION_SLACK_ID = "session-navigation-slack"
const TRANSCRIPT_TAIL_ROWS = 40
const TRANSCRIPT_BACKFILL_CHUNK = 60
const TRANSCRIPT_BACKFILL_DELAY = 120
const SESSION_IMAGE_LIMIT = 6
const context = createContext<{
width: number
@@ -115,6 +117,7 @@ const context = createContext<{
markdownMode: () => "source" | "rendered"
groupExploration: () => boolean
diffWrapMode: () => "word" | "none"
imageKeys: () => ReadonlySet<string>
models: () => ModelInfo[]
config: ReturnType<typeof useConfig>["data"]
}>()
@@ -144,6 +147,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(() =>
config.session?.image_preview ? sessionImageKeys(messages(), session()?.revert?.messageID) : new Set<string>(),
)
const currentLocation = useLocation()
const location = createMemo(() => session()?.location ?? currentLocation.ref)
@@ -968,6 +974,7 @@ export function Session() {
markdownMode,
groupExploration,
diffWrapMode,
imageKeys,
models,
config,
}}
@@ -1131,11 +1138,7 @@ function SessionRowView(props: SessionRowViewProps) {
</Match>
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
{(row) => (
<TurnTokenUsage
messageIDs={row().messageIDs}
previousCache={row().previousCache}
message={props.message}
/>
<TurnTokenUsage messageIDs={row().messageIDs} previousCache={row().previousCache} message={props.message} />
)}
</Match>
</Switch>
@@ -1242,21 +1245,10 @@ function TurnTokenToolCalls(props: { tools: SessionMessageAssistantTool[] }) {
<For each={props.tools}>
{(tool) => (
<box flexDirection="row">
<text
width={nameWidth()}
flexShrink={0}
fg={theme.text.subdued}
attributes={TextAttributes.BOLD}
>
<text width={nameWidth()} flexShrink={0} fg={theme.text.subdued} attributes={TextAttributes.BOLD}>
{tool.name}
</text>
<text
fg={theme.text.subdued}
attributes={TextAttributes.DIM}
wrapMode="word"
flexGrow={1}
minWidth={0}
>
<text fg={theme.text.subdued} attributes={TextAttributes.DIM} wrapMode="word" flexGrow={1} minWidth={0}>
{turnTokenToolSummary(tool)}
</text>
</box>
@@ -1273,9 +1265,7 @@ function turnTokenToolSummary(tool: SessionMessageAssistantTool) {
const primaryKey = ["command", "id", "pattern", "url", "query", "path", "description", "code"].find(
(key) => key in data,
)
const input = Object.entries(data).filter(([, value]) =>
["string", "number", "boolean"].includes(typeof value),
)
const input = Object.entries(data).filter(([, value]) => ["string", "number", "boolean"].includes(typeof value))
const primary = input.find(([key]) => key === primaryKey)?.[1]
const details = input.filter(([key]) => key !== primaryKey).map(([key, value]) => `${key}: ${String(value)}`)
return [primary === undefined ? "" : String(primary), ...details].filter(Boolean).join(" ")
@@ -1357,7 +1347,7 @@ function SessionPartView(props: { partRef: PartRef; message: (messageID: string)
/>
</Match>
<Match when={item().type === "tool"}>
<ToolPart part={item() as SessionMessageAssistantTool} />
<ToolPartWithImages messageID={props.partRef.messageID} part={item() as SessionMessageAssistantTool} />
</Match>
</Switch>
)}
@@ -1495,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)
@@ -1504,13 +1495,13 @@ function SessionGroupView(props: {
if (message?.type !== "assistant") return []
const part = resolvePart(message, ref.partID)
if (part?.type !== "tool") return []
return [part]
return [{ messageID: ref.messageID, part }]
})
const grouped = createMemo(() => parts(props.refs))
const pending = createMemo(() => parts(props.pending))
const label = createMemo(() => {
const counts = grouped().reduce<Record<string, number>>((result, part) => {
const tool = toolDisplay(part.name)
const counts = grouped().reduce<Record<string, number>>((result, item) => {
const tool = toolDisplay(item.part.name)
const name = tool === "grep" || tool === "glob" ? "search" : tool
result[name] = (result[name] ?? 0) + 1
return result
@@ -1524,7 +1515,11 @@ function SessionGroupView(props: {
<Show when={grouped().length > 0 || pending().length > 0}>
<Show
when={ctx.groupExploration()}
fallback={<For each={[...grouped(), ...pending()]}>{(part) => <ToolPart part={part} />}</For>}
fallback={
<For each={[...grouped(), ...pending()]}>
{(item) => <ToolPartWithImages messageID={item.messageID} part={item.part} />}
</For>
}
>
<Show when={grouped().length > 0}>
<InlineToolRow
@@ -1544,9 +1539,14 @@ function SessionGroupView(props: {
</InlineToolRow>
</Show>
<Show when={expanded() && grouped().length > 0}>
<For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
<For each={grouped()}>{(item) => <ToolPart part={item.part} />}</For>
</Show>
<For each={pending()}>{(part) => <ToolPart part={part} />}</For>
<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>
)
@@ -1680,8 +1680,7 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
const text = () =>
props.message.status === "failed" ? (cancelled() ? "" : props.message.error.message) : props.message.summary
const content = createMemo(() => text().trim())
const color = () =>
status() === "failed" && !cancelled() ? theme.text.feedback.error.default : theme.text.subdued
const color = () => (status() === "failed" && !cancelled() ? theme.text.feedback.error.default : theme.text.subdued)
return (
<box>
<box flexDirection="row" alignItems="center">
@@ -1852,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
@@ -1870,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">
@@ -2316,6 +2312,122 @@ 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()}
onOpen={(images, index) => dialog.replace(() => <DialogImagePreview images={images} initial={index} />)}
/>
</>
)
}
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} 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(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={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={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="cover"
protocol="auto"
width="100%"
height="100%"
onError={() => setFailed(true)}
/>
</Show>
</box>
)
}}
</For>
<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>
)
}
export function sessionImageKeys(messages: readonly SessionMessageInfo[], revertBoundary?: string) {
return new Set(
messages
.filter((message) => !revertBoundary || message.id < revertBoundary)
.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/")
? [{ key: `${messageID}:${part.id}:${index}`, uri: content.uri }]
: [],
)
}
type ToolProps = {
input: Record<string, unknown>
metadata: Record<string, unknown>
@@ -2361,10 +2473,7 @@ function GenericTool(props: ToolProps) {
{(value) => (
<box gap={1}>
<text>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}>
{" "}
Output{" "}
</span>
<span style={{ bg: theme.raise(theme.background.default), fg: theme.text.subdued }}> Output </span>
</text>
<box paddingLeft={1}>
<text fg={theme.text.default} wrapMode="word">
@@ -2616,9 +2725,7 @@ function BlockToolContent(props: BlockToolProps & { borderColor: RGBA }) {
<Show
when={props.spinner}
fallback={
<text fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
{title()}
</text>
<text fg={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>{title()}</text>
}
>
<Spinner color={permission() ? theme.text.feedback.warning.default : theme.text.subdued}>
@@ -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,6 +14,10 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
test("searches settings globally and opens the matching setting", async () => {
let current: Info = {}
expect(settings.find((setting) => settingID(setting) === "session.image_preview")).toMatchObject({
title: "Transcript images",
default: false,
})
const service: Interface = {
get: async () => current,
update: async (update) => {
@@ -0,0 +1,147 @@
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"
const PNG_1X1_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4AWP4z8DwHwAFAAH/e+m+7wAAAABJRU5ErkJggg=="
const image = { type: "file" as const, uri: `data:image/png;base64,${PNG_1X1_BASE64}`, mime: "image/png" }
let setup: Awaited<ReturnType<typeof testRender>> | undefined
afterEach(() => {
setup?.renderer.destroy()
setup = undefined
})
test("renders bounded inline images from completed tool content", async () => {
let opened = -1
setup = await testRender(
() => 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 () => {
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("cover")
})
test("does not reserve image slots for reverted messages", () => {
const visible = assistant("message-1", [tool([image])])
const reverted = assistant("message-2", [tool([image, image, image, image, image, image])])
expect([...sessionImageKeys([visible, reverted], reverted.id)]).toEqual(["message-1:call-1:0"])
})
test("falls back when inline image content is malformed", async () => {
setup = await testRender(
() => toolImages([{ type: "file", uri: "data:image/png;base64,aW52YWxpZA==", mime: "image/png" }]),
{ width: 80, height: 24 },
)
await setup.renderOnce()
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
expect(await setup.waitForFrame((frame) => frame.includes("No preview"))).toContain("No preview")
})
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])} onOpen={onOpen} />
}
function assistant(id: string, content: SessionMessageAssistant["content"]): SessionMessageAssistant {
return {
type: "assistant",
id,
agent: "build",
model: { id: "model", providerID: "provider" },
content,
time: { created: 1 },
}
}
function tool(
content: Extract<SessionMessageAssistantTool["state"], { status: "completed" }>["content"],
): SessionMessageAssistantTool {
return {
type: "tool",
id: "call-1",
name: "image",
state: { status: "completed", input: {}, content },
time: { created: 0, completed: 1 },
}
}
+1
View File
@@ -21,6 +21,7 @@ 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 } })
})
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)