import { Hunk } from 'diff' import { existsSync, mkdirSync, readFileSync, statSync } from 'fs' import { Box, Text } from 'ink' import { dirname, isAbsolute, relative, resolve, sep } from 'path' import * as React from 'react' import { z } from 'zod' import { FileEditToolUpdatedMessage } from '../../components/FileEditToolUpdatedMessage' import { StructuredDiff } from '../../components/StructuredDiff' import { logEvent } from '../../services/statsig' import { Tool, ValidationResult } from '../../Tool' import { intersperse } from '../../utils/array' import { addLineNumbers, detectFileEncoding, detectLineEndings, findSimilarFile, writeTextContent, } from '../../utils/file.js' import { logError } from '../../utils/log' import { getCwd } from '../../utils/state' import { getTheme } from '../../utils/theme' import { emitReminderEvent } from '../../services/systemReminder' import { recordFileEdit } from '../../services/fileFreshness' import { NotebookEditTool } from '../NotebookEditTool/NotebookEditTool' import { DESCRIPTION } from './prompt' import { applyEdit } from './utils' import { hasWritePermission } from '../../utils/permissions/filesystem' import { PROJECT_FILE } from '../../constants/product' const inputSchema = z.strictObject({ file_path: z.string().describe('The absolute path to the file to modify'), old_string: z.string().describe('The text to replace'), new_string: z.string().describe('The text to replace it with'), }) export type In = typeof inputSchema // Number of lines of context to include before/after the change in our result message const N_LINES_SNIPPET = 4 export const FileEditTool = { name: 'Edit', async description() { return 'A tool for editing files' }, async prompt() { return DESCRIPTION }, inputSchema, userFacingName() { return 'Edit' }, async isEnabled() { return true }, isReadOnly() { return false }, isConcurrencySafe() { return false // FileEdit modifies files, not safe for concurrent execution }, needsPermissions({ file_path }) { return !hasWritePermission(file_path) }, renderToolUseMessage(input, { verbose }) { return `file_path: ${verbose ? input.file_path : relative(getCwd(), input.file_path)}` }, renderToolResultMessage({ filePath, structuredPatch }) { const verbose = false // Set default value for verbose return ( ) }, renderToolUseRejectedMessage( { file_path, old_string, new_string }, { columns, verbose }, ) { try { const { patch } = applyEdit(file_path, old_string, new_string) return ( {' '}⎿{' '} User rejected {old_string === '' ? 'write' : 'update'} to{' '} {verbose ? file_path : relative(getCwd(), file_path)} {intersperse( patch.map(patch => ( )), i => ( ... ), )} ) } catch (e) { // Handle the case where while we were showing the diff, the user manually made the change. // TODO: Find a way to show the diff in this case logError(e) return ( {' '}⎿ (No changes) ) } }, async validateInput( { file_path, old_string, new_string }, { readFileTimestamps }, ) { if (old_string === new_string) { return { result: false, message: 'No changes to make: old_string and new_string are exactly the same.', meta: { old_string, }, } as ValidationResult } const fullFilePath = isAbsolute(file_path) ? file_path : resolve(getCwd(), file_path) if (existsSync(fullFilePath) && old_string === '') { return { result: false, message: 'Cannot create new file - file already exists.', } } if (!existsSync(fullFilePath) && old_string === '') { return { result: true, } } if (!existsSync(fullFilePath)) { // 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, } } if (fullFilePath.endsWith('.ipynb')) { return { result: false, message: `File is a Jupyter Notebook. Use the ${NotebookEditTool.name} to edit this file.`, } } const readTimestamp = readFileTimestamps[fullFilePath] if (!readTimestamp) { return { result: false, message: 'File has not been read yet. Read it first before writing to it.', meta: { isFilePathAbsolute: String(isAbsolute(file_path)), }, } } // Check if file exists and get its last modified time const stats = statSync(fullFilePath) const lastWriteTime = stats.mtimeMs if (lastWriteTime > readTimestamp) { return { result: false, message: 'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.', } } const enc = detectFileEncoding(fullFilePath) const file = readFileSync(fullFilePath, enc) if (!file.includes(old_string)) { return { result: false, message: `String to replace not found in file.`, meta: { isFilePathAbsolute: String(isAbsolute(file_path)), }, } } const matches = file.split(old_string).length - 1 if (matches > 1) { return { result: false, message: `Found ${matches} matches of the string to replace. For safety, this tool only supports replacing exactly one occurrence at a time. Add more lines of context to your edit and try again.`, meta: { isFilePathAbsolute: String(isAbsolute(file_path)), }, } } return { result: true } }, async *call({ file_path, old_string, new_string }, { readFileTimestamps }) { const { patch, updatedFile } = applyEdit(file_path, old_string, new_string) const fullFilePath = isAbsolute(file_path) ? file_path : resolve(getCwd(), file_path) const dir = dirname(fullFilePath) mkdirSync(dir, { recursive: true }) const enc = existsSync(fullFilePath) ? detectFileEncoding(fullFilePath) : 'utf8' const endings = existsSync(fullFilePath) ? detectLineEndings(fullFilePath) : 'LF' const originalFile = existsSync(fullFilePath) ? readFileSync(fullFilePath, enc) : '' writeTextContent(fullFilePath, updatedFile, enc, endings) // Record Agent edit operation for file freshness tracking recordFileEdit(fullFilePath, updatedFile) // Update read timestamp, to invalidate stale writes readFileTimestamps[fullFilePath] = statSync(fullFilePath).mtimeMs // Log when editing CLAUDE.md if (fullFilePath.endsWith(`${sep}${PROJECT_FILE}`)) { logEvent('tengu_write_claudemd', {}) } // Emit file edited event for system reminders emitReminderEvent('file:edited', { filePath: fullFilePath, oldString: old_string, newString: new_string, timestamp: Date.now(), operation: old_string === '' ? 'create' : new_string === '' ? 'delete' : 'update', }) const data = { filePath: file_path, oldString: old_string, newString: new_string, originalFile, structuredPatch: patch, } yield { type: 'result', data, resultForAssistant: this.renderResultForAssistant(data), } }, renderResultForAssistant({ filePath, originalFile, oldString, newString }) { const { snippet, startLine } = getSnippet( originalFile || '', oldString, newString, ) return `The file ${filePath} has been updated. Here's the result of running \`cat -n\` on a snippet of the edited file: ${addLineNumbers({ content: snippet, startLine, })}` }, } satisfies Tool< typeof inputSchema, { filePath: string oldString: string newString: string originalFile: string structuredPatch: Hunk[] } > export function getSnippet( initialText: string, oldStr: string, newStr: string, ): { snippet: string; startLine: number } { const before = initialText.split(oldStr)[0] ?? '' const replacementLine = before.split(/\r?\n/).length - 1 const newFileLines = initialText.replace(oldStr, newStr).split(/\r?\n/) // Calculate the start and end line numbers for the snippet const startLine = Math.max(0, replacementLine - N_LINES_SNIPPET) const endLine = replacementLine + N_LINES_SNIPPET + newStr.split(/\r?\n/).length // Get snippet const snippetLines = newFileLines.slice(startLine, endLine + 1) const snippet = snippetLines.join('\n') return { snippet, startLine: startLine + 1 } }