| 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"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| function TimgDisplay({ |
| kittySequence, |
| width, |
| height, |
| }: { |
| kittySequence: string; |
| width: number; |
| height: number; |
| }) { |
| const containerRef = useRef<DOMElement | null>(null); |
| const position = usePosition(containerRef); |
|
|
| |
| 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 }; |
|
|
| |
| const hasPlacedRef = useRef(false); |
| const lastPosKeyRef = useRef<string | null>(null); |
|
|
| const displayImage = useCallback(() => { |
| const pos = positionRef.current; |
| if (!pos) return; |
|
|
| |
| 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`)]; |
|
|
| |
| const imageId = imageIdRef.current; |
| if (hasPlacedRef.current && imageId > 0) { |
| parts.push(Buffer.from(`\x1b_Ga=d,d=I,i=${imageId}\x1b\\`)); |
| } |
|
|
| |
| 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)), |
| ); |
|
|
| |
| parts.push(Buffer.from(kittySequence), Buffer.from(`\x1b8`)); |
|
|
| fs.writeSync(fd, Buffer.concat(parts)); |
| hasPlacedRef.current = true; |
| }, [kittySequence]); |
|
|
| |
| useEffect(() => { |
| if (!position) return; |
| displayImage(); |
| }, [kittySequence, position, displayImage]); |
|
|
| |
| |
| |
| useOnRender(() => { |
| setTimeout(() => { |
| displayImage(); |
| }, 0); |
| }); |
|
|
| |
| return <Box ref={containerRef} height={height} flexDirection="column" />; |
| } |
|
|
| |
| function extractKittyImageId(sequence: string): number { |
| const match = sequence.match(/i=(\d+)/); |
| return match ? parseInt(match[1], 10) : 0; |
| } |
|
|
| |
| |
| |
|
|
| 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> |
| ); |
| } |
|
|
| |
| return ( |
| <MessageResponse height={1}> |
| <Text dimColor>Image: {src}</Text> |
| </MessageResponse> |
| ); |
| } |
|
|
| |
|
|
| 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> |
| ); |
| } |
|
|
| |
| if (width && height && pixelWidth && pixelHeight) { |
| return ( |
| <ImageDisplay |
| src={src} |
| width={width} |
| height={height} |
| pixelWidth={pixelWidth} |
| pixelHeight={pixelHeight} |
| kittySequence={kittySequence} |
| /> |
| ); |
| } |
|
|
| |
| 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; |
| } |
|
|