FileReadTool.tsx
1 import { ImageBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' 2 import { existsSync, readFileSync, statSync } from 'fs' 3 import { Box, Text } from 'ink' 4 import * as path from 'path' 5 import { extname, relative } from 'path' 6 import * as React from 'react' 7 import { z } from 'zod' 8 import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js' 9 import { HighlightedCode } from '../../components/HighlightedCode.js' 10 import type { Tool } from '../../Tool.js' 11 import { getCwd } from '../../utils/state.js' 12 import { 13 addLineNumbers, 14 findSimilarFile, 15 normalizeFilePath, 16 readTextContent, 17 } from '../../utils/file.js' 18 import { logError } from '../../utils/log.js' 19 import { getTheme } from '../../utils/theme.js' 20 import { DESCRIPTION, PROMPT } from './prompt.js' 21 import { hasReadPermission } from '../../utils/permissions/filesystem.js' 22 23 const MAX_LINES_TO_RENDER = 3 24 const MAX_OUTPUT_SIZE = 0.25 * 1024 * 1024 // 0.25MB in bytes 25 26 // Common image extensions 27 const IMAGE_EXTENSIONS = new Set([ 28 '.png', 29 '.jpg', 30 '.jpeg', 31 '.gif', 32 '.bmp', 33 '.webp', 34 ]) 35 36 // Maximum dimensions for images 37 const MAX_WIDTH = 2000 38 const MAX_HEIGHT = 2000 39 const MAX_IMAGE_SIZE = 3.75 * 1024 * 1024 // 5MB in bytes, with base64 encoding 40 41 const inputSchema = z.strictObject({ 42 file_path: z.string().describe('The absolute path to the file to read'), 43 offset: z 44 .number() 45 .optional() 46 .describe( 47 'The line number to start reading from. Only provide if the file is too large to read at once', 48 ), 49 limit: z 50 .number() 51 .optional() 52 .describe( 53 'The number of lines to read. Only provide if the file is too large to read at once.', 54 ), 55 }) 56 57 export const FileReadTool = { 58 name: 'View', 59 async description() { 60 return DESCRIPTION 61 }, 62 async prompt() { 63 return PROMPT 64 }, 65 inputSchema, 66 isReadOnly() { 67 return true 68 }, 69 userFacingName() { 70 return 'Read' 71 }, 72 async isEnabled() { 73 return true 74 }, 75 needsPermissions({ file_path }) { 76 return !hasReadPermission(file_path || getCwd()) 77 }, 78 renderToolUseMessage(input, { verbose }) { 79 const { file_path, ...rest } = input 80 const entries = [ 81 ['file_path', verbose ? file_path : relative(getCwd(), file_path)], 82 ...Object.entries(rest), 83 ] 84 return entries 85 .map(([key, value]) => `${key}: ${JSON.stringify(value)}`) 86 .join(', ') 87 }, 88 renderToolResultMessage(output, { verbose }) { 89 // TODO: Render recursively 90 switch (output.type) { 91 case 'image': 92 return ( 93 <Box justifyContent="space-between" overflowX="hidden" width="100%"> 94 <Box flexDirection="row"> 95 <Text> ⎿ </Text> 96 <Text>Read image</Text> 97 </Box> 98 </Box> 99 ) 100 case 'text': { 101 const { filePath, content, numLines } = output.file 102 const contentWithFallback = content || '(No content)' 103 return ( 104 <Box justifyContent="space-between" overflowX="hidden" width="100%"> 105 <Box flexDirection="row"> 106 <Text> ⎿ </Text> 107 <Box flexDirection="column"> 108 <HighlightedCode 109 code={ 110 verbose 111 ? contentWithFallback 112 : contentWithFallback 113 .split('\n') 114 .slice(0, MAX_LINES_TO_RENDER) 115 .filter(_ => _.trim() !== '') 116 .join('\n') 117 } 118 language={extname(filePath).slice(1)} 119 /> 120 {!verbose && numLines > MAX_LINES_TO_RENDER && ( 121 <Text color={getTheme().secondaryText}> 122 ... (+{numLines - MAX_LINES_TO_RENDER} lines) 123 </Text> 124 )} 125 </Box> 126 </Box> 127 </Box> 128 ) 129 } 130 } 131 }, 132 renderToolUseRejectedMessage() { 133 return <FallbackToolUseRejectedMessage /> 134 }, 135 async validateInput({ file_path, offset, limit }) { 136 const fullFilePath = normalizeFilePath(file_path) 137 138 if (!existsSync(fullFilePath)) { 139 // Try to find a similar file with a different extension 140 const similarFilename = findSimilarFile(fullFilePath) 141 let message = 'File does not exist.' 142 143 // If we found a similar file, suggest it to the assistant 144 if (similarFilename) { 145 message += ` Did you mean ${similarFilename}?` 146 } 147 148 return { 149 result: false, 150 message, 151 } 152 } 153 154 // Get file stats to check size 155 const stats = statSync(fullFilePath) 156 const fileSize = stats.size 157 const ext = path.extname(fullFilePath).toLowerCase() 158 159 // Skip size check for image files - they have their own size limits 160 if (!IMAGE_EXTENSIONS.has(ext)) { 161 // If file is too large and no offset/limit provided 162 if (fileSize > MAX_OUTPUT_SIZE && !offset && !limit) { 163 return { 164 result: false, 165 message: formatFileSizeError(fileSize), 166 meta: { fileSize }, 167 } 168 } 169 } 170 171 return { result: true } 172 }, 173 async *call( 174 { file_path, offset = 1, limit = undefined }, 175 { readFileTimestamps }, 176 ) { 177 const ext = path.extname(file_path).toLowerCase() 178 const fullFilePath = normalizeFilePath(file_path) 179 180 // Update read timestamp, to invalidate stale writes 181 readFileTimestamps[fullFilePath] = Date.now() 182 183 // If it's an image file, process and return base64 encoded contents 184 if (IMAGE_EXTENSIONS.has(ext)) { 185 const data = await readImage(fullFilePath, ext) 186 yield { 187 type: 'result', 188 data, 189 resultForAssistant: this.renderResultForAssistant(data), 190 } 191 return 192 } 193 194 // Handle offset properly - if offset is 0, don't subtract 1 195 const lineOffset = offset === 0 ? 0 : offset - 1 196 const { content, lineCount, totalLines } = readTextContent( 197 fullFilePath, 198 lineOffset, 199 limit, 200 ) 201 202 // Add size validation after reading for non-image files 203 if (!IMAGE_EXTENSIONS.has(ext) && content.length > MAX_OUTPUT_SIZE) { 204 throw new Error(formatFileSizeError(content.length)) 205 } 206 207 const data = { 208 type: 'text' as const, 209 file: { 210 filePath: file_path, 211 content: content, 212 numLines: lineCount, 213 startLine: offset, 214 totalLines, 215 }, 216 } 217 218 yield { 219 type: 'result', 220 data, 221 resultForAssistant: this.renderResultForAssistant(data), 222 } 223 }, 224 renderResultForAssistant(data) { 225 switch (data.type) { 226 case 'image': 227 return [ 228 { 229 type: 'image', 230 source: { 231 type: 'base64', 232 data: data.file.base64, 233 media_type: data.file.type, 234 }, 235 }, 236 ] 237 case 'text': 238 return addLineNumbers(data.file) 239 } 240 }, 241 } satisfies Tool< 242 typeof inputSchema, 243 | { 244 type: 'text' 245 file: { 246 filePath: string 247 content: string 248 numLines: number 249 startLine: number 250 totalLines: number 251 } 252 } 253 | { 254 type: 'image' 255 file: { base64: string; type: ImageBlockParam.Source['media_type'] } 256 } 257 > 258 259 const formatFileSizeError = (sizeInBytes: number) => 260 `File content (${Math.round(sizeInBytes / 1024)}KB) exceeds maximum allowed size (${Math.round(MAX_OUTPUT_SIZE / 1024)}KB). Please use offset and limit parameters to read specific portions of the file, or use the GrepTool to search for specific content.` 261 262 function createImageResponse( 263 buffer: Buffer, 264 ext: string, 265 ): { 266 type: 'image' 267 file: { base64: string; type: ImageBlockParam.Source['media_type'] } 268 } { 269 return { 270 type: 'image', 271 file: { 272 base64: buffer.toString('base64'), 273 type: `image/${ext.slice(1)}` as ImageBlockParam.Source['media_type'], 274 }, 275 } 276 } 277 278 async function readImage( 279 filePath: string, 280 ext: string, 281 ): Promise<{ 282 type: 'image' 283 file: { base64: string; type: ImageBlockParam.Source['media_type'] } 284 }> { 285 try { 286 const stats = statSync(filePath) 287 const sharp = ( 288 (await import('sharp')) as unknown as { default: typeof import('sharp') } 289 ).default 290 const image = sharp(readFileSync(filePath)) 291 const metadata = await image.metadata() 292 293 if (!metadata.width || !metadata.height) { 294 if (stats.size > MAX_IMAGE_SIZE) { 295 const compressedBuffer = await image.jpeg({ quality: 80 }).toBuffer() 296 return createImageResponse(compressedBuffer, 'jpeg') 297 } 298 } 299 300 // Calculate dimensions while maintaining aspect ratio 301 let width = metadata.width || 0 302 let height = metadata.height || 0 303 304 // Check if the original file just works 305 if ( 306 stats.size <= MAX_IMAGE_SIZE && 307 width <= MAX_WIDTH && 308 height <= MAX_HEIGHT 309 ) { 310 return createImageResponse(readFileSync(filePath), ext) 311 } 312 313 if (width > MAX_WIDTH) { 314 height = Math.round((height * MAX_WIDTH) / width) 315 width = MAX_WIDTH 316 } 317 318 if (height > MAX_HEIGHT) { 319 width = Math.round((width * MAX_HEIGHT) / height) 320 height = MAX_HEIGHT 321 } 322 323 // Resize image and convert to buffer 324 const resizedImageBuffer = await image 325 .resize(width, height, { 326 fit: 'inside', 327 withoutEnlargement: true, 328 }) 329 .toBuffer() 330 331 // If still too large after resize, compress quality 332 if (resizedImageBuffer.length > MAX_IMAGE_SIZE) { 333 const compressedBuffer = await image.jpeg({ quality: 80 }).toBuffer() 334 return createImageResponse(compressedBuffer, 'jpeg') 335 } 336 337 return createImageResponse(resizedImageBuffer, ext) 338 } catch (e) { 339 logError(e) 340 // If any error occurs during processing, return original image 341 return createImageResponse(readFileSync(filePath), ext) 342 } 343 }