chenbhao Claude Big Pickle commited on
Commit
67fd3a5
·
1 Parent(s): 43b76b7

feat(ImageShowTool): use timg for full-res Kitty image, dedup placement

Browse files

- Replace Jimp-based image loading with timg binary for complete image
processing (decode, scale, encode Kitty protocol) in one subprocess
- Write Kitty sequence directly to terminal fd via fs.writeSync, bypassing
Ink's stdout buffering
- Fix duplicate image placement with three mechanisms:
1. Position dedup: skip write if component hasn't moved
2. Delete-before-write: remove old image with \x1b_Ga=d,d=I,i=ID
3. Remove 500ms polling interval that caused repeated writes
- ImageDisplay now takes pre-generated kittySequence instead of loading
image in the UI component

Co-Authored-By: Claude Big Pickle <noreply@anthropic.com>

src/tools/ImageShowTool/DirectImageDisplay.tsx DELETED
@@ -1,176 +0,0 @@
1
- import fs from "node:fs";
2
- import React, { useCallback, useEffect, useRef, useState } from "react";
3
- import { Box } from "../../ink.js";
4
- import type { DOMElement } from "../../ink.js";
5
- import { useOnRender } from "../../ink-picture/InkPictureProvider.js";
6
- import { cursorForward } from "../../ink-picture/utils/ansiEscapes.js";
7
- import { fetchImage, getPngBuffer } from "../../ink-picture/utils/image.js";
8
- import generateKittyId from "../../ink-picture/utils/generateKittyId.js";
9
- import {
10
- makeKittyDeletion,
11
- makeKittyPlacement,
12
- makeKittyTransmitChunks,
13
- } from "../../ink-picture/renderers/kitty.js";
14
- import usePosition from "../../ink-picture/hooks/usePosition.js";
15
-
16
- /**
17
- * Direct terminal image display that bypasses Ink's rendering loop.
18
- *
19
- * Follows the approach from commit f6a6fdc (old timg):
20
- * - Writes Kitty protocol directly to process.stdout.fd (not through Ink's stdout)
21
- * - useEffect fires AFTER Ink's frame write (post-commit), so first placement
22
- * runs after Ink's initial output
23
- * - useOnRender + setTimeout(0) handles re-placement on subsequent renders
24
- * (useOnRender fires DURING commit, before Ink writes; setTimeout defers
25
- * placement until after)
26
- * - Periodic re-placement via setInterval combats Ink overwrites from other
27
- * re-renders (where our subtree may not re-render)
28
- * - Renders empty Box placeholder in Ink for correct TUI layout
29
- */
30
- export function DirectImageDisplay({
31
- src,
32
- width,
33
- height,
34
- pixelWidth,
35
- pixelHeight,
36
- }: {
37
- src: string;
38
- width: number;
39
- height: number;
40
- pixelWidth: number;
41
- pixelHeight: number;
42
- }) {
43
- const containerRef = useRef<DOMElement | null>(null);
44
- const position = usePosition(containerRef);
45
-
46
- const [imageData, setImageData] = useState<Buffer | undefined>(undefined);
47
-
48
- // ── Load image via Jimp ──
49
- useEffect(() => {
50
- let cancelled = false;
51
- (async () => {
52
- try {
53
- const image = await fetchImage(src);
54
- if (!image || cancelled) return;
55
- image.cover({ w: pixelWidth, h: pixelHeight });
56
- const png = await getPngBuffer(image);
57
- if (!cancelled) {
58
- setImageData(png.data);
59
- }
60
- } catch {
61
- // Image load failed — nothing to display
62
- }
63
- })();
64
- return () => {
65
- cancelled = true;
66
- };
67
- }, [src, pixelWidth, pixelHeight]);
68
-
69
- // ── Refs for values used in placement callback (stable identity) ──
70
- const imageIdRef = useRef<number | undefined>(undefined);
71
- const positionRef = useRef(position);
72
- positionRef.current = position;
73
- const dimsRef = useRef({ w: width, h: height });
74
- dimsRef.current = { w: width, h: height };
75
-
76
- // ── Transmit image data to terminal GPU memory ──
77
- // Uses fs.writeSync to bypass Ink's stdout buffering.
78
- useEffect(() => {
79
- if (!imageData) return;
80
-
81
- const id = generateKittyId();
82
- const base64Data = imageData.toString("base64");
83
- const chunks = makeKittyTransmitChunks(id, base64Data);
84
- const fd = process.stdout.fd;
85
- for (const chunk of chunks) {
86
- fs.writeSync(fd, chunk);
87
- }
88
- imageIdRef.current = id;
89
- }, [imageData]);
90
-
91
- // ── Place image with position-aware cursor movement ──
92
- const placeImage = useCallback(() => {
93
- const id = imageIdRef.current;
94
- if (!id) return;
95
-
96
- const pos = positionRef.current;
97
- if (!pos) return;
98
-
99
- const { w, h } = dimsRef.current;
100
- if (h <= 0) return;
101
-
102
- // Calculate cursor-up distance (same logic as cursorUp() in ansiEscapes.ts)
103
- // Note: terminalHeight is in character rows (process.stdout.rows),
104
- // NOT terminalInfo.terminalHeight which is in pixels.
105
- const terminalHeight = process.stdout.rows;
106
- const cursorUpCount = pos.appHeight - pos.row;
107
- const movementCount =
108
- pos.appHeight >= terminalHeight ? cursorUpCount - 1 : cursorUpCount;
109
-
110
- const fd = process.stdout.fd;
111
- const parts: Buffer[] = [
112
- Buffer.from(`\x1b7`), // save cursor (DECSC)
113
- ];
114
- if (movementCount > 0) {
115
- parts.push(Buffer.from(`\x1b[${movementCount}A`)); // cursor up to image row
116
- }
117
- parts.push(
118
- Buffer.from(`\r`), // carriage return to col 0
119
- Buffer.from(cursorForward(pos.col)), // forward to image column
120
- Buffer.from(makeKittyPlacement(id, 1, w, h)),
121
- Buffer.from(`\x1b8`), // restore cursor (DECRC)
122
- );
123
- fs.writeSync(fd, Buffer.concat(parts));
124
- }, []);
125
-
126
- // ── First placement: after Ink writes its initial frame ──
127
- // useEffect fires AFTER React's commit phase (which includes Ink's terminal
128
- // output). So calling placeImage() directly here runs after Ink's frame write.
129
- useEffect(() => {
130
- if (!imageIdRef.current) return;
131
- if (!position) return;
132
-
133
- placeImage();
134
- }, [imageData, position, placeImage]);
135
-
136
- // ── Re-place after each React render ──
137
- // useOnRender fires DURING the commit phase (via Profiler), BEFORE Ink writes
138
- // its frame to the terminal. So we use setTimeout(0) to defer the placement
139
- // until after Ink's synchronous frame write completes.
140
- useOnRender(() => {
141
- const id = imageIdRef.current;
142
- if (!id) return;
143
-
144
- setTimeout(() => {
145
- placeImage();
146
- }, 0);
147
- });
148
-
149
- // ── Periodic re-placement to combat Ink overwrites from OTHER re-renders ──
150
- // When other parts of Codev re-render (e.g. new message arrives), Ink rewrites
151
- // the entire frame. Our subtree might not re-render, so useOnRender won't fire.
152
- // This interval ensures the image stays visible.
153
- useEffect(() => {
154
- const interval = setInterval(() => {
155
- placeImage();
156
- }, 500);
157
-
158
- return () => clearInterval(interval);
159
- }, [placeImage]);
160
-
161
- // ── Cleanup: delete image from terminal GPU memory ──
162
- useEffect(() => {
163
- return () => {
164
- const id = imageIdRef.current;
165
- if (id) {
166
- const fd = process.stdout.fd;
167
- fs.writeSync(fd, makeKittyDeletion(id));
168
- }
169
- };
170
- }, []);
171
-
172
- // ── Empty placeholder for correct TUI layout ──
173
- // Renders invisible Box with correct height so Ink reserves the right
174
- // number of character cells. The actual image is drawn via Kitty protocol.
175
- return <Box ref={containerRef} height={height} flexDirection="column" />;
176
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/tools/ImageShowTool/ImageShowTool.ts CHANGED
@@ -1,8 +1,10 @@
 
1
  import { z } from 'zod/v4'
2
  import { Jimp } from "jimp";
3
  import { buildTool, type ToolDef } from '../../Tool.js'
4
  import { loadImageFromUrl } from "../../ink-picture/utils/jimpURL.ts";
5
  import { lazySchema } from '../../utils/lazySchema.js'
 
6
  import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
7
  import { IMAGE_SHOW_TOOL_NAME, DESCRIPTION } from './prompt.js'
8
  import {
@@ -32,6 +34,7 @@ export interface ImageShowOutput {
32
  height?: number
33
  pixelWidth?: number
34
  pixelHeight?: number
 
35
  }
36
 
37
  // ── Utilities ──
@@ -89,6 +92,7 @@ const outputSchema = lazySchema(() =>
89
  height: z.number().optional(),
90
  pixelWidth: z.number().optional(),
91
  pixelHeight: z.number().optional(),
 
92
  }),
93
  )
94
  type OutputSchema = ReturnType<typeof outputSchema>
@@ -168,11 +172,34 @@ export const ImageShowTool = buildTool({
168
  cols,
169
  )
170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  return {
172
  data: {
173
  src,
174
  success: true,
175
  ...dims,
 
176
  } satisfies ImageShowOutput,
177
  }
178
  } catch (error) {
 
1
+ import { execFileSync } from 'child_process'
2
  import { z } from 'zod/v4'
3
  import { Jimp } from "jimp";
4
  import { buildTool, type ToolDef } from '../../Tool.js'
5
  import { loadImageFromUrl } from "../../ink-picture/utils/jimpURL.ts";
6
  import { lazySchema } from '../../utils/lazySchema.js'
7
+ import { whichSync } from '../../utils/which.js'
8
  import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
9
  import { IMAGE_SHOW_TOOL_NAME, DESCRIPTION } from './prompt.js'
10
  import {
 
34
  height?: number
35
  pixelWidth?: number
36
  pixelHeight?: number
37
+ kittySequence?: string
38
  }
39
 
40
  // ── Utilities ──
 
92
  height: z.number().optional(),
93
  pixelWidth: z.number().optional(),
94
  pixelHeight: z.number().optional(),
95
+ kittySequence: z.string().optional(),
96
  }),
97
  )
98
  type OutputSchema = ReturnType<typeof outputSchema>
 
172
  cols,
173
  )
174
 
175
+ // Generate full-resolution Kitty protocol sequence via timg
176
+ let kittySequence: string | undefined
177
+ try {
178
+ const timgPath = whichSync('timg')
179
+ if (timgPath) {
180
+ // Pipe image to timg via stdin, capture Kitty protocol output
181
+ const pngBuffer = await image.getBuffer("image/png")
182
+ kittySequence = execFileSync(
183
+ timgPath,
184
+ ['-p', 'kitty', '-g', `${dims.width}x${dims.height}`, '-'],
185
+ {
186
+ input: pngBuffer,
187
+ encoding: 'utf8',
188
+ timeout: 30000,
189
+ maxBuffer: 50 * 1024 * 1024,
190
+ },
191
+ )
192
+ }
193
+ } catch {
194
+ // timg not available — fall back to text display
195
+ }
196
+
197
  return {
198
  data: {
199
  src,
200
  success: true,
201
  ...dims,
202
+ kittySequence,
203
  } satisfies ImageShowOutput,
204
  }
205
  } catch (error) {
src/tools/ImageShowTool/UI.tsx CHANGED
@@ -1,49 +1,155 @@
1
- import React, { useMemo } from 'react'
2
- import { Box, Text } from '../../ink.js'
3
- import { MessageResponse } from '../../components/MessageResponse.js'
4
- import { InkPictureProvider } from '../../ink-picture/InkPictureProvider.js'
5
- import type { ImageShowOutput } from './ImageShowTool.js'
6
- import { detectTerminalCaps } from './detectTerminal.js'
7
- import { DirectImageDisplay } from './DirectImageDisplay.js'
 
 
 
8
 
9
- // ── Image display component ──
10
- // Follows the old timg approach (commit f6a6fdc):
11
- // - Writes Kitty protocol directly to the terminal fd (bypassing Ink)
12
- // - Renders empty placeholder in Ink for correct TUI layout
13
- // - The image is drawn on the terminal framebuffer without affecting layout
14
-
15
- export function ImageDisplay({ src, width, height, pixelWidth, pixelHeight }: {
16
- src: string
17
- width: number
18
- height: number
19
- pixelWidth: number
20
- pixelHeight: number
 
 
 
 
 
 
 
 
 
21
  }) {
22
- const terminalInfo = useMemo(() => detectTerminalCaps(), [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
- const supportsKittyGraphics = terminalInfo.supportsKittyGraphics === true
 
 
 
 
25
 
26
- if (!supportsKittyGraphics) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  return (
28
- <MessageResponse height={1}>
29
- <Text dimColor>Image: {src}</Text>
30
- </MessageResponse>
31
- )
 
 
 
 
 
 
32
  }
33
 
 
34
  return (
35
- <Box flexDirection="column">
36
- <InkPictureProvider terminalInfo={terminalInfo}>
37
- <DirectImageDisplay
38
- src={src}
39
- width={width}
40
- height={height}
41
- pixelWidth={pixelWidth}
42
- pixelHeight={pixelHeight}
43
- />
44
- </InkPictureProvider>
45
- </Box>
46
- )
47
  }
48
 
49
  // ── Tool rendering functions ──
@@ -52,9 +158,9 @@ export function renderToolUseMessage(
52
  { src }: { src?: string },
53
  { verbose }: { theme?: string; verbose: boolean },
54
  ): React.ReactNode {
55
- if (!src) return null
56
- if (verbose) return `src: "${src}"`
57
- return src
58
  }
59
 
60
  export function renderToolUseProgressMessage(): React.ReactNode {
@@ -62,7 +168,7 @@ export function renderToolUseProgressMessage(): React.ReactNode {
62
  <MessageResponse height={1}>
63
  <Text dimColor>Rendering image…</Text>
64
  </MessageResponse>
65
- )
66
  }
67
 
68
  export function renderToolResultMessage(
@@ -70,17 +176,17 @@ export function renderToolResultMessage(
70
  _progressMessages: unknown[],
71
  { verbose }: { verbose: boolean },
72
  ): React.ReactNode {
73
- const { src, success, width, height, pixelWidth, pixelHeight } = output
74
 
75
  if (!success) {
76
  return (
77
  <MessageResponse height={1}>
78
  <Text color="red">Failed to display: {src}</Text>
79
  </MessageResponse>
80
- )
81
  }
82
 
83
- // Render the image via ink-picture when we have dimension data
84
  if (width && height && pixelWidth && pixelHeight) {
85
  return (
86
  <ImageDisplay
@@ -89,8 +195,9 @@ export function renderToolResultMessage(
89
  height={height}
90
  pixelWidth={pixelWidth}
91
  pixelHeight={pixelHeight}
 
92
  />
93
- )
94
  }
95
 
96
  // Fallback: text-only summary
@@ -100,10 +207,10 @@ export function renderToolResultMessage(
100
  Image displayed: <Text bold>{src}</Text>
101
  </Text>
102
  </MessageResponse>
103
- )
104
  }
105
 
106
  export function getToolUseSummary(input: { src?: string } | undefined): string | null {
107
- if (!input?.src) return null
108
- return input.src.length > 80 ? input.src.slice(0, 77) + '...' : input.src
109
  }
 
1
+ import fs from "node:fs";
2
+ import React, { useCallback, useEffect, useMemo, useRef } from "react";
3
+ import { Box, Text } from "../../ink.js";
4
+ import type { DOMElement } from "../../ink.js";
5
+ import { MessageResponse } from "../../components/MessageResponse.js";
6
+ import { useOnRender, InkPictureProvider } from "../../ink-picture/InkPictureProvider.js";
7
+ import { cursorForward } from "../../ink-picture/utils/ansiEscapes.js";
8
+ import usePosition from "../../ink-picture/hooks/usePosition.js";
9
+ import type { ImageShowOutput } from "./ImageShowTool.js";
10
+ import { detectTerminalCaps } from "./detectTerminal.js";
11
 
12
+ // ── Timg-based image display ──
13
+ // Writes a pre-generated Kitty protocol sequence (from timg) directly to the
14
+ // terminal fd via fs.writeSync, completely bypassing Ink's rendering loop.
15
+ // An empty Box placeholder reserves character cells in Ink's virtual DOM so
16
+ // the TUI layout stays intact — the actual image is drawn on the terminal
17
+ // framebuffer without interference.
18
+ //
19
+ // To prevent duplicate placements (each Kitty protocol write creates a new
20
+ // image at the cursor position):
21
+ // - Position dedup: skip if the component hasn't moved since last write
22
+ // - Delete before write: send \x1b_Ga=d,d=I,i=ID to remove old image first
23
+ // - No periodic polling: remove the 500ms interval that caused duplicates
24
+
25
+ function TimgDisplay({
26
+ kittySequence,
27
+ width,
28
+ height,
29
+ }: {
30
+ kittySequence: string;
31
+ width: number;
32
+ height: number;
33
  }) {
34
+ const containerRef = useRef<DOMElement | null>(null);
35
+ const position = usePosition(containerRef);
36
+
37
+ // Extract image ID from timg's Kitty sequence: "a=T,i=645096449,..."
38
+ const imageIdRef = useRef(extractKittyImageId(kittySequence));
39
+ const positionRef = useRef(position);
40
+ positionRef.current = position;
41
+ const dimsRef = useRef({ w: width, h: height });
42
+ dimsRef.current = { w: width, h: height };
43
+
44
+ // ── Dedup: skip if position hasn't changed since last write ──
45
+ const hasPlacedRef = useRef(false);
46
+ const lastPosKeyRef = useRef<string | null>(null);
47
+
48
+ const displayImage = useCallback(() => {
49
+ const pos = positionRef.current;
50
+ if (!pos) return;
51
+
52
+ // Skip entirely if image already placed at this exact position
53
+ const posKey = `${pos.col},${pos.row},${pos.appHeight}`;
54
+ if (hasPlacedRef.current && posKey === lastPosKeyRef.current) return;
55
+ lastPosKeyRef.current = posKey;
56
+
57
+ const fd = process.stdout.fd;
58
+ const parts: Buffer[] = [Buffer.from(`\x1b7`)]; // save cursor (DECSC)
59
 
60
+ // Delete previous image before placing new one (prevents duplicates)
61
+ const imageId = imageIdRef.current;
62
+ if (hasPlacedRef.current && imageId > 0) {
63
+ parts.push(Buffer.from(`\x1b_Ga=d,d=I,i=${imageId}\x1b\\`));
64
+ }
65
 
66
+ // Position cursor at the component's row/col
67
+ const terminalHeight = process.stdout.rows;
68
+ const cursorUpCount = pos.appHeight - pos.row;
69
+ const movementCount =
70
+ pos.appHeight >= terminalHeight ? cursorUpCount - 1 : cursorUpCount;
71
+ if (movementCount > 0) {
72
+ parts.push(Buffer.from(`\x1b[${movementCount}A`));
73
+ }
74
+ parts.push(
75
+ Buffer.from(`\r`),
76
+ Buffer.from(cursorForward(pos.col)),
77
+ );
78
+
79
+ // Full timg sequence (transmit + auto-display at cursor)
80
+ parts.push(Buffer.from(kittySequence), Buffer.from(`\x1b8`)); // restore cursor
81
+
82
+ fs.writeSync(fd, Buffer.concat(parts));
83
+ hasPlacedRef.current = true;
84
+ }, [kittySequence]);
85
+
86
+ // First display: useEffect fires AFTER Ink's frame write (post-commit)
87
+ useEffect(() => {
88
+ if (!position) return;
89
+ displayImage();
90
+ }, [kittySequence, position, displayImage]);
91
+
92
+ // Re-display on Ink render: useOnRender fires DURING commit (before Ink
93
+ // writes), so setTimeout(0) defers until after Ink's frame. The dedup
94
+ // check in displayImage prevents writing if position hasn't changed.
95
+ useOnRender(() => {
96
+ setTimeout(() => {
97
+ displayImage();
98
+ }, 0);
99
+ });
100
+
101
+ // Empty placeholder for correct TUI layout
102
+ return <Box ref={containerRef} height={height} flexDirection="column" />;
103
+ }
104
+
105
+ /** Extract Kitty image ID from a timg-generated protocol sequence. */
106
+ function extractKittyImageId(sequence: string): number {
107
+ const match = sequence.match(/i=(\d+)/);
108
+ return match ? parseInt(match[1], 10) : 0;
109
+ }
110
+
111
+ // ── Image display component ──
112
+ // Uses timg-generated Kitty protocol sequence when available, otherwise
113
+ // falls back to a text-only summary.
114
+
115
+ export function ImageDisplay({
116
+ src,
117
+ width,
118
+ height,
119
+ pixelWidth,
120
+ pixelHeight,
121
+ kittySequence,
122
+ }: {
123
+ src: string;
124
+ width: number;
125
+ height: number;
126
+ pixelWidth: number;
127
+ pixelHeight: number;
128
+ kittySequence?: string;
129
+ }) {
130
+ const terminalInfo = useMemo(() => detectTerminalCaps(), []);
131
+ const supportsKittyGraphics = terminalInfo.supportsKittyGraphics === true;
132
+
133
+ if (kittySequence && supportsKittyGraphics) {
134
  return (
135
+ <Box flexDirection="column">
136
+ <InkPictureProvider terminalInfo={terminalInfo}>
137
+ <TimgDisplay
138
+ kittySequence={kittySequence}
139
+ width={width}
140
+ height={height}
141
+ />
142
+ </InkPictureProvider>
143
+ </Box>
144
+ );
145
  }
146
 
147
+ // Fallback: text-only summary
148
  return (
149
+ <MessageResponse height={1}>
150
+ <Text dimColor>Image: {src}</Text>
151
+ </MessageResponse>
152
+ );
 
 
 
 
 
 
 
 
153
  }
154
 
155
  // ── Tool rendering functions ──
 
158
  { src }: { src?: string },
159
  { verbose }: { theme?: string; verbose: boolean },
160
  ): React.ReactNode {
161
+ if (!src) return null;
162
+ if (verbose) return `src: "${src}"`;
163
+ return src;
164
  }
165
 
166
  export function renderToolUseProgressMessage(): React.ReactNode {
 
168
  <MessageResponse height={1}>
169
  <Text dimColor>Rendering image…</Text>
170
  </MessageResponse>
171
+ );
172
  }
173
 
174
  export function renderToolResultMessage(
 
176
  _progressMessages: unknown[],
177
  { verbose }: { verbose: boolean },
178
  ): React.ReactNode {
179
+ const { src, success, width, height, pixelWidth, pixelHeight, kittySequence } = output;
180
 
181
  if (!success) {
182
  return (
183
  <MessageResponse height={1}>
184
  <Text color="red">Failed to display: {src}</Text>
185
  </MessageResponse>
186
+ );
187
  }
188
 
189
+ // Render the image when we have dimension data
190
  if (width && height && pixelWidth && pixelHeight) {
191
  return (
192
  <ImageDisplay
 
195
  height={height}
196
  pixelWidth={pixelWidth}
197
  pixelHeight={pixelHeight}
198
+ kittySequence={kittySequence}
199
  />
200
+ );
201
  }
202
 
203
  // Fallback: text-only summary
 
207
  Image displayed: <Text bold>{src}</Text>
208
  </Text>
209
  </MessageResponse>
210
+ );
211
  }
212
 
213
  export function getToolUseSummary(input: { src?: string } | undefined): string | null {
214
+ if (!input?.src) return null;
215
+ return input.src.length > 80 ? input.src.slice(0, 77) + "..." : input.src;
216
  }