feat(tui): render session tool images
This commit is contained in:
@@ -107,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
|
||||
@@ -116,6 +117,7 @@ const context = createContext<{
|
||||
markdownMode: () => "source" | "rendered"
|
||||
groupExploration: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
imageKeys: () => ReadonlySet<string>
|
||||
models: () => ModelInfo[]
|
||||
config: ReturnType<typeof useConfig>["data"]
|
||||
}>()
|
||||
@@ -145,6 +147,7 @@ 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 location = createMemo(() => session()?.location)
|
||||
const currentLocation = useLocation()
|
||||
|
||||
@@ -995,6 +998,7 @@ export function Session() {
|
||||
markdownMode,
|
||||
groupExploration,
|
||||
diffWrapMode,
|
||||
imageKeys,
|
||||
models,
|
||||
config,
|
||||
}}
|
||||
@@ -1384,7 +1388,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>
|
||||
)}
|
||||
@@ -1531,13 +1535,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
|
||||
@@ -1551,7 +1555,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
|
||||
@@ -1571,9 +1579,12 @@ 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()} />
|
||||
<For each={pending()}>
|
||||
{(item) => <ToolPartWithImages messageID={item.messageID} part={item.part} />}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
)
|
||||
@@ -2028,7 +2039,9 @@ 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={<ToolPart part={content as SessionMessageAssistantTool} />}
|
||||
fallback={
|
||||
<ToolPartWithImages messageID={props.message.id} part={content as SessionMessageAssistantTool} />
|
||||
}
|
||||
>
|
||||
{(summary) => <ExplorationSummary {...summary()} />}
|
||||
</Show>
|
||||
@@ -2343,6 +2356,88 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPartWithImages(props: { messageID: string; part: SessionMessageAssistantTool }) {
|
||||
const ctx = use()
|
||||
return (
|
||||
<>
|
||||
<ToolPart part={props.part} />
|
||||
<ToolImages parts={[props]} visible={ctx.imageKeys()} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolImages(props: {
|
||||
parts: readonly { messageID: string; part: SessionMessageAssistantTool }[]
|
||||
visible: ReadonlySet<string>
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const images = createMemo(() =>
|
||||
props.parts
|
||||
.flatMap((item) => inlineToolImages(item.messageID, item.part))
|
||||
.filter((image) => props.visible.has(image.key)),
|
||||
)
|
||||
const height = createMemo(() => Math.max(6, Math.min(18, Math.floor((dimensions().width - 6) / 4))))
|
||||
|
||||
return (
|
||||
<Show when={images().length > 0}>
|
||||
<box flexDirection="column" flexShrink={0} paddingLeft={3} paddingRight={2} gap={1}>
|
||||
<For each={images().slice(0, 3)}>
|
||||
{(image) => {
|
||||
const [failed, setFailed] = createSignal(false)
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
maxWidth={70}
|
||||
height={height()}
|
||||
flexShrink={0}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Show when={!failed()} fallback={<text>No preview</text>}>
|
||||
<image
|
||||
id={`session-tool-image-${image.key}`}
|
||||
source={image.uri}
|
||||
fit="fit"
|
||||
protocol="auto"
|
||||
width="100%"
|
||||
height="100%"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={images().length > 3}>
|
||||
<text>+{images().length - 3} more images</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export function sessionImageKeys(messages: readonly SessionMessageInfo[], revertBoundary?: string) {
|
||||
return new Set(
|
||||
messages
|
||||
.filter((message) => !revertBoundary || message.id < revertBoundary)
|
||||
.flatMap((message) =>
|
||||
message.type === "assistant"
|
||||
? message.content.flatMap((part) => (part.type === "tool" ? inlineToolImages(message.id, part) : []))
|
||||
: [],
|
||||
)
|
||||
.map((image) => image.key)
|
||||
.slice(-SESSION_IMAGE_LIMIT),
|
||||
)
|
||||
}
|
||||
|
||||
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}` }]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
type ToolProps = {
|
||||
input: Record<string, unknown>
|
||||
metadata: Record<string, unknown>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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"
|
||||
|
||||
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 () => {
|
||||
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-tool-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.captureCharFrame()).toContain("+3 more images")
|
||||
})
|
||||
|
||||
test("does not fetch image tool content from external sources", async () => {
|
||||
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 },
|
||||
)
|
||||
await setup.renderOnce()
|
||||
|
||||
expect(setup.renderer.root.findDescendantById("session-tool-image-message-1:call-1:0")).toBeUndefined()
|
||||
})
|
||||
|
||||
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-tool-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"]) {
|
||||
const part = tool(content)
|
||||
const message = assistant("message-1", [part])
|
||||
return <ToolImages parts={[{ messageID: message.id, part }]} visible={sessionImageKeys([message])} />
|
||||
}
|
||||
|
||||
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 },
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user