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
|
| 2 |
-
import {
|
| 3 |
-
import {
|
| 4 |
-
import {
|
| 5 |
-
import
|
| 6 |
-
import {
|
| 7 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
// ──
|
| 10 |
-
//
|
| 11 |
-
//
|
| 12 |
-
//
|
| 13 |
-
//
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
}) {
|
| 22 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
return (
|
| 28 |
-
<
|
| 29 |
-
<
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
|
|
|
| 34 |
return (
|
| 35 |
-
<
|
| 36 |
-
<
|
| 37 |
-
|
| 38 |
-
|
| 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
|
| 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) +
|
| 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 |
}
|