Просмотр исходного кода

feat(tui): render interactive transcript images

Simon Klee 1 неделя назад
Родитель
Сommit
dedf933b3e

+ 9 - 0
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",

+ 3 - 0
packages/tui/src/config/index.tsx

@@ -131,6 +131,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",
       }),

+ 93 - 4
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 { createSyntaxStyleMemo, 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,
@@ -54,6 +54,7 @@ import { openEditor } from "../../editor"
 import { useDialog } from "../../ui/dialog"
 import { DialogSelect } from "../../ui/dialog-select"
 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"
@@ -1592,8 +1593,9 @@ function SessionGroupView(props: {
           </InlineToolRow>
         </Show>
         <Show when={expanded() && grouped().length > 0}>
-          <For each={grouped()}>{(part) => <ToolPart part={part} />}</For>
+          <For each={grouped()}>{(part) => <ToolPart part={part} images={false} />}</For>
         </Show>
+        <ToolImages parts={grouped()} />
         <For each={pending()}>{(part) => <ToolPart part={part} />}</For>
       </Show>
     </Show>
@@ -1897,6 +1899,11 @@ function UserMessage(props: { message: SessionMessageUser }) {
   const local = useLocal()
   const files = createMemo(() => props.message.files ?? [])
   const skills = createMemo(() => props.message.skills ?? [])
+  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
@@ -1918,6 +1925,7 @@ function UserMessage(props: { message: SessionMessageUser }) {
         borderColor={delivery() ? theme.border.default : color()}
         customBorderChars={SplitBorder.customBorderChars}
       >
+        <SessionImages images={images()} paddingLeft={2} />
         <box
           onMouseOver={() => {
             setHover(true)
@@ -2210,7 +2218,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 = {
@@ -2234,7 +2242,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
     },
   }
 
-  return (
+  const content = (
     <Switch>
       <Match when={display() === "shell"}>
         <Shell {...toolprops} />
@@ -2280,6 +2288,87 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
       </Match>
     </Switch>
   )
+  return [
+    content,
+    <Show when={props.images !== false}>
+      <ToolImages parts={[props.part]} />
+    </Show>,
+  ]
+}
+
+function ToolImages(props: { parts: readonly SessionMessageAssistantTool[] }) {
+  const images = createMemo(() => props.parts.flatMap(inlineToolImages))
+  return <SessionImages images={images()} />
+}
+
+function SessionImages(props: { images: readonly { uri: string }[]; paddingLeft?: number }) {
+  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 (
+    <Show when={visible().length > 0}>
+      <box
+        flexDirection="row"
+        flexShrink={0}
+        paddingTop={1}
+        paddingLeft={props.paddingLeft ?? 3}
+        paddingRight={2}
+        paddingBottom={1}
+        gap={1}
+      >
+        <For each={visible()}>
+          {(image, index) => {
+            const [failed, setFailed] = createSignal(false)
+            return (
+              <box
+                width={height() * 2}
+                height={height()}
+                flexBasis={height() * 2}
+                flexShrink={1}
+                alignItems="center"
+                justifyContent="center"
+                onMouseUp={(event: MouseEvent) => {
+                  if (event.button !== 0) return
+                  event.stopPropagation()
+                  dialog.replace(() => <DialogImagePreview images={images()} initial={index()} />)
+                }}
+              >
+                <Show when={!failed()} fallback={<text>No preview</text>}>
+                  <image
+                    source={image.uri}
+                    fit="cover"
+                    protocol="auto"
+                    width="100%"
+                    height="100%"
+                    onError={() => setFailed(true)}
+                  />
+                </Show>
+              </box>
+            )
+          }}
+        </For>
+        <Show when={images().length > visible().length}>
+          <box width={8} height={height()} flexShrink={1} alignItems="center" justifyContent="center">
+            <text wrapMode="none" truncate>
+              +{images().length - visible().length} more
+            </text>
+          </box>
+        </Show>
+      </box>
+    </Show>
+  )
+}
+
+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 = {

+ 1 - 0
packages/tui/test/config-v2.test.tsx

@@ -24,6 +24,7 @@ test("validates the session tabs setting", () => {
   expect(() => decode({ tabs: { layout: true } })).toThrow()
   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", () => {