File size: 6,699 Bytes
67fd3a5 999003d 67fd3a5 999003d 67fd3a5 fbafa72 67fd3a5 43b76b7 67fd3a5 43b76b7 67fd3a5 43b76b7 67fd3a5 999003d 67fd3a5 999003d e7b07b3 8b35d5c dd104c9 e7b07b3 6d1207a e7b07b3 67fd3a5 e7b07b3 6d1207a e7b07b3 999003d e7b07b3 67fd3a5 999003d e7b07b3 67fd3a5 6d1207a 999003d 67fd3a5 999003d e7b07b3 999003d 67fd3a5 999003d 67fd3a5 6d1207a 999003d 6d1207a e7b07b3 67fd3a5 6d1207a e7b07b3 67fd3a5 999003d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 | import fs from "node:fs";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { Box, Text } from "../../ink.js";
import type { DOMElement } from "../../ink.js";
import { MessageResponse } from "../../components/MessageResponse.js";
import { useOnRender, InkPictureProvider } from "../../ink-picture/InkPictureProvider.js";
import { cursorForward } from "../../ink-picture/utils/ansiEscapes.js";
import usePosition from "../../ink-picture/hooks/usePosition.js";
import type { ImageShowOutput } from "./ImageShowTool.js";
import { detectTerminalCaps } from "./detectTerminal.js";
// ββ Timg-based image display ββ
// Writes a pre-generated Kitty protocol sequence (from timg) directly to the
// terminal fd via fs.writeSync, completely bypassing Ink's rendering loop.
// An empty Box placeholder reserves character cells in Ink's virtual DOM so
// the TUI layout stays intact β the actual image is drawn on the terminal
// framebuffer without interference.
//
// To prevent duplicate placements (each Kitty protocol write creates a new
// image at the cursor position):
// - Position dedup: skip if the component hasn't moved since last write
// - Delete before write: send \x1b_Ga=d,d=I,i=ID to remove old image first
// - No periodic polling: remove the 500ms interval that caused duplicates
function TimgDisplay({
kittySequence,
width,
height,
}: {
kittySequence: string;
width: number;
height: number;
}) {
const containerRef = useRef<DOMElement | null>(null);
const position = usePosition(containerRef);
// Extract image ID from timg's Kitty sequence: "a=T,i=645096449,..."
const imageIdRef = useRef(extractKittyImageId(kittySequence));
const positionRef = useRef(position);
positionRef.current = position;
const dimsRef = useRef({ w: width, h: height });
dimsRef.current = { w: width, h: height };
// ββ Dedup: skip if position hasn't changed since last write ββ
const hasPlacedRef = useRef(false);
const lastPosKeyRef = useRef<string | null>(null);
const displayImage = useCallback(() => {
const pos = positionRef.current;
if (!pos) return;
// Skip entirely if image already placed at this exact position
const posKey = `${pos.col},${pos.row},${pos.appHeight}`;
if (hasPlacedRef.current && posKey === lastPosKeyRef.current) return;
lastPosKeyRef.current = posKey;
const fd = process.stdout.fd;
const parts: Buffer[] = [Buffer.from(`\x1b7`)]; // save cursor (DECSC)
// Delete previous image before placing new one (prevents duplicates)
const imageId = imageIdRef.current;
if (hasPlacedRef.current && imageId > 0) {
parts.push(Buffer.from(`\x1b_Ga=d,d=I,i=${imageId}\x1b\\`));
}
// Position cursor at the component's row/col
const terminalHeight = process.stdout.rows;
const cursorUpCount = pos.appHeight - pos.row;
const movementCount =
pos.appHeight >= terminalHeight ? cursorUpCount - 1 : cursorUpCount;
if (movementCount > 0) {
parts.push(Buffer.from(`\x1b[${movementCount}A`));
}
parts.push(
Buffer.from(`\r`),
Buffer.from(cursorForward(pos.col)),
);
// Full timg sequence (transmit + auto-display at cursor)
parts.push(Buffer.from(kittySequence), Buffer.from(`\x1b8`)); // restore cursor
fs.writeSync(fd, Buffer.concat(parts));
hasPlacedRef.current = true;
}, [kittySequence]);
// First display: useEffect fires AFTER Ink's frame write (post-commit)
useEffect(() => {
if (!position) return;
displayImage();
}, [kittySequence, position, displayImage]);
// Re-display on Ink render: useOnRender fires DURING commit (before Ink
// writes), so setTimeout(0) defers until after Ink's frame. The dedup
// check in displayImage prevents writing if position hasn't changed.
useOnRender(() => {
setTimeout(() => {
displayImage();
}, 0);
});
// Empty placeholder for correct TUI layout
return <Box ref={containerRef} height={height} flexDirection="column" />;
}
/** Extract Kitty image ID from a timg-generated protocol sequence. */
function extractKittyImageId(sequence: string): number {
const match = sequence.match(/i=(\d+)/);
return match ? parseInt(match[1], 10) : 0;
}
// ββ Image display component ββ
// Uses timg-generated Kitty protocol sequence when available, otherwise
// falls back to a text-only summary.
export function ImageDisplay({
src,
width,
height,
pixelWidth,
pixelHeight,
kittySequence,
}: {
src: string;
width: number;
height: number;
pixelWidth: number;
pixelHeight: number;
kittySequence?: string;
}) {
const terminalInfo = useMemo(() => detectTerminalCaps(), []);
const supportsKittyGraphics = terminalInfo.supportsKittyGraphics === true;
if (kittySequence && supportsKittyGraphics) {
return (
<Box flexDirection="column">
<InkPictureProvider terminalInfo={terminalInfo}>
<TimgDisplay
kittySequence={kittySequence}
width={width}
height={height}
/>
</InkPictureProvider>
</Box>
);
}
// Fallback: text-only summary
return (
<MessageResponse height={1}>
<Text dimColor>Image: {src}</Text>
</MessageResponse>
);
}
// ββ Tool rendering functions ββ
export function renderToolUseMessage(): React.ReactNode {
return "Image";
}
export function renderToolUseProgressMessage(): React.ReactNode {
return (
<MessageResponse height={1}>
<Text dimColor>Rendering imageβ¦</Text>
</MessageResponse>
);
}
export function renderToolResultMessage(
output: ImageShowOutput,
_progressMessages: unknown[],
{ verbose }: { verbose: boolean },
): React.ReactNode {
const { src, success, width, height, pixelWidth, pixelHeight, kittySequence } = output;
if (!success) {
return (
<MessageResponse height={1}>
<Text color="red">Failed to display: {src}</Text>
</MessageResponse>
);
}
// Render the image when we have dimension data
if (width && height && pixelWidth && pixelHeight) {
return (
<ImageDisplay
src={src}
width={width}
height={height}
pixelWidth={pixelWidth}
pixelHeight={pixelHeight}
kittySequence={kittySequence}
/>
);
}
// Fallback: text-only summary
return (
<MessageResponse height={1}>
<Text>
Image displayed: <Text bold>{src}</Text>
</Text>
</MessageResponse>
);
}
export function getToolUseSummary(input: { src?: string } | undefined): string | null {
if (!input?.src) return null;
return input.src.length > 80 ? input.src.slice(0, 77) + "..." : input.src;
}
|