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(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(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 ; } /** 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 ( ); } // Fallback: text-only summary return ( Image: {src} ); } // ── Tool rendering functions ── export function renderToolUseMessage(): React.ReactNode { return "Image"; } export function renderToolUseProgressMessage(): React.ReactNode { return ( Rendering image… ); } export function renderToolResultMessage( output: ImageShowOutput, _progressMessages: unknown[], { verbose }: { verbose: boolean }, ): React.ReactNode { const { src, success, width, height, pixelWidth, pixelHeight, kittySequence } = output; if (!success) { return ( Failed to display: {src} ); } // Render the image when we have dimension data if (width && height && pixelWidth && pixelHeight) { return ( ); } // Fallback: text-only summary return ( Image displayed: {src} ); } 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; }