/ src / tools / FileReadTool / FileReadTool.tsx
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>&nbsp;&nbsp;⎿ &nbsp;</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>&nbsp;&nbsp;⎿ &nbsp;</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  }