import { ImageBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
import { statSync } from 'node:fs'
import { Box, Text } from 'ink'
import * as path from 'node:path'
import { extname, relative } from 'node:path'
import * as React from 'react'
import { z } from 'zod'
import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage'
import { HighlightedCode } from '../../components/HighlightedCode'
import type { Tool } from '../../Tool'
import { getCwd } from '../../utils/state'
import {
addLineNumbers,
findSimilarFile,
normalizeFilePath,
readTextContent,
} from '../../utils/file.js'
import { logError } from '../../utils/log'
import { getTheme } from '../../utils/theme'
import { emitReminderEvent } from '../../services/systemReminder'
import {
recordFileRead,
generateFileModificationReminder,
} from '../../services/fileFreshness'
import { DESCRIPTION, PROMPT } from './prompt'
import { hasReadPermission } from '../../utils/permissions/filesystem'
import { secureFileService } from '../../utils/secureFile'
const MAX_LINES_TO_RENDER = 5
const MAX_OUTPUT_SIZE = 0.25 * 1024 * 1024 // 0.25MB in bytes
// Common image extensions
const IMAGE_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.bmp',
'.webp',
])
// Maximum dimensions for images
const MAX_WIDTH = 2000
const MAX_HEIGHT = 2000
const MAX_IMAGE_SIZE = 3.75 * 1024 * 1024 // 5MB in bytes, with base64 encoding
const inputSchema = z.strictObject({
file_path: z.string().describe('The absolute path to the file to read'),
offset: z
.number()
.optional()
.describe(
'The line number to start reading from. Only provide if the file is too large to read at once',
),
limit: z
.number()
.optional()
.describe(
'The number of lines to read. Only provide if the file is too large to read at once.',
),
})
export const FileReadTool = {
name: 'View',
async description() {
return DESCRIPTION
},
async prompt() {
return PROMPT
},
inputSchema,
isReadOnly() {
return true
},
isConcurrencySafe() {
return true // FileRead is read-only, safe for concurrent execution
},
userFacingName() {
return 'Read'
},
async isEnabled() {
return true
},
needsPermissions({ file_path }) {
return !hasReadPermission(file_path || getCwd())
},
renderToolUseMessage(input, { verbose }) {
const { file_path, ...rest } = input
const entries = [
['file_path', verbose ? file_path : relative(getCwd(), file_path)],
...Object.entries(rest),
]
return entries
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
.join(', ')
},
renderToolResultMessage(output) {
const verbose = false // Set default value for verbose
// TODO: Render recursively
switch (output.type) {
case 'image':
return (
⎿
Read image
)
case 'text': {
const { filePath, content, numLines } = output.file
const contentWithFallback = content || '(No content)'
return (
⎿
_.trim() !== '')
.join('\n')
}
language={extname(filePath).slice(1)}
/>
{!verbose && numLines > MAX_LINES_TO_RENDER && (
... (+{numLines - MAX_LINES_TO_RENDER} lines)
)}
)
}
}
},
renderToolUseRejectedMessage() {
return
},
async validateInput({ file_path, offset, limit }) {
const fullFilePath = normalizeFilePath(file_path)
// Use secure file service to check if file exists and get file info
const fileCheck = secureFileService.safeGetFileInfo(fullFilePath)
if (!fileCheck.success) {
// Try to find a similar file with a different extension
const similarFilename = findSimilarFile(fullFilePath)
let message = 'File does not exist.'
// If we found a similar file, suggest it to the assistant
if (similarFilename) {
message += ` Did you mean ${similarFilename}?`
}
return {
result: false,
message,
}
}
const stats = fileCheck.stats!
const fileSize = stats.size
const ext = path.extname(fullFilePath).toLowerCase()
// Skip size check for image files - they have their own size limits
if (!IMAGE_EXTENSIONS.has(ext)) {
// If file is too large and no offset/limit provided
if (fileSize > MAX_OUTPUT_SIZE && !offset && !limit) {
return {
result: false,
message: formatFileSizeError(fileSize),
meta: { fileSize },
}
}
}
return { result: true }
},
async *call(
{ file_path, offset = 1, limit = undefined },
{ readFileTimestamps },
) {
const ext = path.extname(file_path).toLowerCase()
const fullFilePath = normalizeFilePath(file_path)
// Record file read for freshness tracking
recordFileRead(fullFilePath)
// Emit file read event for system reminders
emitReminderEvent('file:read', {
filePath: fullFilePath,
extension: ext,
timestamp: Date.now(),
})
// Update read timestamp, to invalidate stale writes
readFileTimestamps[fullFilePath] = Date.now()
// Check for file modifications and generate reminder if needed
const modificationReminder = generateFileModificationReminder(fullFilePath)
if (modificationReminder) {
emitReminderEvent('file:modified', {
filePath: fullFilePath,
reminder: modificationReminder,
timestamp: Date.now(),
})
}
// If it's an image file, process and return base64 encoded contents
if (IMAGE_EXTENSIONS.has(ext)) {
const data = await readImage(fullFilePath, ext)
yield {
type: 'result',
data,
resultForAssistant: this.renderResultForAssistant(data),
}
return
}
// Handle offset properly - if offset is 0, don't subtract 1
const lineOffset = offset === 0 ? 0 : offset - 1
const { content, lineCount, totalLines } = readTextContent(
fullFilePath,
lineOffset,
limit,
)
// Add size validation after reading for non-image files
if (!IMAGE_EXTENSIONS.has(ext) && content.length > MAX_OUTPUT_SIZE) {
throw new Error(formatFileSizeError(content.length))
}
const data = {
type: 'text' as const,
file: {
filePath: file_path,
content: content,
numLines: lineCount,
startLine: offset,
totalLines,
},
}
yield {
type: 'result',
data,
resultForAssistant: this.renderResultForAssistant(data),
}
},
renderResultForAssistant(data) {
switch (data.type) {
case 'image':
return [
{
type: 'image',
source: {
type: 'base64',
data: data.file.base64,
media_type: data.file.type,
},
},
]
case 'text':
return addLineNumbers(data.file)
}
},
} satisfies Tool<
typeof inputSchema,
| {
type: 'text'
file: {
filePath: string
content: string
numLines: number
startLine: number
totalLines: number
}
}
| {
type: 'image'
file: { base64: string; type: ImageBlockParam.Source['media_type'] }
}
>
const formatFileSizeError = (sizeInBytes: number) =>
`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.`
function createImageResponse(
buffer: Buffer,
ext: string,
): {
type: 'image'
file: { base64: string; type: ImageBlockParam.Source['media_type'] }
} {
return {
type: 'image',
file: {
base64: buffer.toString('base64'),
type: `image/${ext.slice(1)}` as ImageBlockParam.Source['media_type'],
},
}
}
async function readImage(
filePath: string,
ext: string,
): Promise<{
type: 'image'
file: { base64: string; type: ImageBlockParam.Source['media_type'] }
}> {
try {
const stats = statSync(filePath)
const sharp = (
(await import('sharp')) as unknown as { default: typeof import('sharp') }
).default
// Use secure file service to read the file
const fileReadResult = secureFileService.safeReadFile(filePath, {
encoding: 'buffer' as BufferEncoding,
maxFileSize: MAX_IMAGE_SIZE
})
if (!fileReadResult.success) {
throw new Error(`Failed to read image file: ${fileReadResult.error}`)
}
const image = sharp(fileReadResult.content as Buffer)
const metadata = await image.metadata()
if (!metadata.width || !metadata.height) {
if (stats.size > MAX_IMAGE_SIZE) {
const compressedBuffer = await image.jpeg({ quality: 80 }).toBuffer()
return createImageResponse(compressedBuffer, 'jpeg')
}
}
// Calculate dimensions while maintaining aspect ratio
let width = metadata.width || 0
let height = metadata.height || 0
// Check if the original file just works
if (
stats.size <= MAX_IMAGE_SIZE &&
width <= MAX_WIDTH &&
height <= MAX_HEIGHT
) {
// Use secure file service to read the file
const fileReadResult = secureFileService.safeReadFile(filePath, {
encoding: 'buffer' as BufferEncoding,
maxFileSize: MAX_IMAGE_SIZE
})
if (!fileReadResult.success) {
throw new Error(`Failed to read image file: ${fileReadResult.error}`)
}
return createImageResponse(fileReadResult.content as Buffer, ext)
}
if (width > MAX_WIDTH) {
height = Math.round((height * MAX_WIDTH) / width)
width = MAX_WIDTH
}
if (height > MAX_HEIGHT) {
width = Math.round((width * MAX_HEIGHT) / height)
height = MAX_HEIGHT
}
// Resize image and convert to buffer
const resizedImageBuffer = await image
.resize(width, height, {
fit: 'inside',
withoutEnlargement: true,
})
.toBuffer()
// If still too large after resize, compress quality
if (resizedImageBuffer.length > MAX_IMAGE_SIZE) {
const compressedBuffer = await image.jpeg({ quality: 80 }).toBuffer()
return createImageResponse(compressedBuffer, 'jpeg')
}
return createImageResponse(resizedImageBuffer, ext)
} catch (e) {
logError(e)
// If any error occurs during processing, return original image
const fileReadResult = secureFileService.safeReadFile(filePath, {
encoding: 'buffer' as BufferEncoding,
maxFileSize: MAX_IMAGE_SIZE
})
if (!fileReadResult.success) {
throw new Error(`Failed to read image file: ${fileReadResult.error}`)
}
return createImageResponse(fileReadResult.content as Buffer, ext)
}
}