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:
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user