/ src / tools / FileEditTool / FileEditTool.tsx
FileEditTool.tsx
  1  import { Hunk } from 'diff'
  2  import { existsSync, mkdirSync, readFileSync, statSync } from 'fs'
  3  import { Box, Text } from 'ink'
  4  import { dirname, isAbsolute, relative, resolve, sep } from 'path'
  5  import * as React from 'react'
  6  import { z } from 'zod'
  7  import { FileEditToolUpdatedMessage } from '../../components/FileEditToolUpdatedMessage.js'
  8  import { StructuredDiff } from '../../components/StructuredDiff.js'
  9  import { logEvent } from '../../services/statsig.js'
 10  import { Tool, ValidationResult } from '../../Tool.js'
 11  import { intersperse } from '../../utils/array.js'
 12  import {
 13    addLineNumbers,
 14    detectFileEncoding,
 15    detectLineEndings,
 16    findSimilarFile,
 17    writeTextContent,
 18  } from '../../utils/file.js'
 19  import { logError } from '../../utils/log.js'
 20  import { getCwd } from '../../utils/state.js'
 21  import { getTheme } from '../../utils/theme.js'
 22  import { NotebookEditTool } from '../NotebookEditTool/NotebookEditTool.js'
 23  import { DESCRIPTION } from './prompt.js'
 24  import { applyEdit } from './utils.js'
 25  import { hasWritePermission } from '../../utils/permissions/filesystem.js'
 26  
 27  const inputSchema = z.strictObject({
 28    file_path: z.string().describe('The absolute path to the file to modify'),
 29    old_string: z.string().describe('The text to replace'),
 30    new_string: z.string().describe('The text to replace it with'),
 31  })
 32  
 33  export type In = typeof inputSchema
 34  
 35  // Number of lines of context to include before/after the change in our result message
 36  const N_LINES_SNIPPET = 4
 37  
 38  export const FileEditTool = {
 39    name: 'Edit',
 40    async description() {
 41      return 'A tool for editing files'
 42    },
 43    async prompt() {
 44      return DESCRIPTION
 45    },
 46    inputSchema,
 47    userFacingName({ old_string, new_string }) {
 48      if (old_string === '') return 'Create'
 49      if (new_string === '') return 'Delete'
 50      return 'Update'
 51    },
 52    async isEnabled() {
 53      return true
 54    },
 55    needsPermissions({ file_path }) {
 56      return !hasWritePermission(file_path)
 57    },
 58    isReadOnly() {
 59      return false
 60    },
 61    renderToolUseMessage(input, { verbose }) {
 62      return `file_path: ${verbose ? input.file_path : relative(getCwd(), input.file_path)}`
 63    },
 64    renderToolResultMessage({ filePath, structuredPatch }, { verbose }) {
 65      return (
 66        <FileEditToolUpdatedMessage
 67          filePath={filePath}
 68          structuredPatch={structuredPatch}
 69          verbose={verbose}
 70        />
 71      )
 72    },
 73    renderToolUseRejectedMessage(
 74      { file_path, old_string, new_string },
 75      { columns, verbose },
 76    ) {
 77      try {
 78        const { patch } = applyEdit(file_path, old_string, new_string)
 79        return (
 80          <Box flexDirection="column">
 81            <Text>
 82              {'  '}⎿{' '}
 83              <Text color={getTheme().error}>
 84                User rejected {old_string === '' ? 'write' : 'update'} to{' '}
 85              </Text>
 86              <Text bold>
 87                {verbose ? file_path : relative(getCwd(), file_path)}
 88              </Text>
 89            </Text>
 90            {intersperse(
 91              patch.map(patch => (
 92                <Box flexDirection="column" paddingLeft={5} key={patch.newStart}>
 93                  <StructuredDiff patch={patch} dim={true} width={columns - 12} />
 94                </Box>
 95              )),
 96              i => (
 97                <Box paddingLeft={5} key={`ellipsis-${i}`}>
 98                  <Text color={getTheme().secondaryText}>...</Text>
 99                </Box>
100              ),
101            )}
102          </Box>
103        )
104      } catch (e) {
105        // Handle the case where while we were showing the diff, the user manually made the change.
106        // TODO: Find a way to show the diff in this case
107        logError(e)
108        return (
109          <Box flexDirection="column">
110            <Text>{'  '}⎿ (No changes)</Text>
111          </Box>
112        )
113      }
114    },
115    async validateInput(
116      { file_path, old_string, new_string },
117      { readFileTimestamps },
118    ) {
119      if (old_string === new_string) {
120        return {
121          result: false,
122          message:
123            'No changes to make: old_string and new_string are exactly the same.',
124          meta: {
125            old_string,
126          },
127        } as ValidationResult
128      }
129  
130      const fullFilePath = isAbsolute(file_path)
131        ? file_path
132        : resolve(getCwd(), file_path)
133  
134      if (existsSync(fullFilePath) && old_string === '') {
135        return {
136          result: false,
137          message: 'Cannot create new file - file already exists.',
138        }
139      }
140  
141      if (!existsSync(fullFilePath) && old_string === '') {
142        return {
143          result: true,
144        }
145      }
146  
147      if (!existsSync(fullFilePath)) {
148        // Try to find a similar file with a different extension
149        const similarFilename = findSimilarFile(fullFilePath)
150        let message = 'File does not exist.'
151  
152        // If we found a similar file, suggest it to the assistant
153        if (similarFilename) {
154          message += ` Did you mean ${similarFilename}?`
155        }
156  
157        return {
158          result: false,
159          message,
160        }
161      }
162  
163      if (fullFilePath.endsWith('.ipynb')) {
164        return {
165          result: false,
166          message: `File is a Jupyter Notebook. Use the ${NotebookEditTool.name} to edit this file.`,
167        }
168      }
169  
170      const readTimestamp = readFileTimestamps[fullFilePath]
171      if (!readTimestamp) {
172        return {
173          result: false,
174          message:
175            'File has not been read yet. Read it first before writing to it.',
176          meta: {
177            isFilePathAbsolute: String(isAbsolute(file_path)),
178          },
179        }
180      }
181  
182      // Check if file exists and get its last modified time
183      const stats = statSync(fullFilePath)
184      const lastWriteTime = stats.mtimeMs
185      if (lastWriteTime > readTimestamp) {
186        return {
187          result: false,
188          message:
189            'File has been modified since read, either by the user or by a linter. Read it again before attempting to write it.',
190        }
191      }
192  
193      const enc = detectFileEncoding(fullFilePath)
194      const file = readFileSync(fullFilePath, enc)
195      if (!file.includes(old_string)) {
196        return {
197          result: false,
198          message: `String to replace not found in file.`,
199          meta: {
200            isFilePathAbsolute: String(isAbsolute(file_path)),
201          },
202        }
203      }
204  
205      const matches = file.split(old_string).length - 1
206      if (matches > 1) {
207        return {
208          result: false,
209          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.`,
210          meta: {
211            isFilePathAbsolute: String(isAbsolute(file_path)),
212          },
213        }
214      }
215  
216      return { result: true }
217    },
218    async *call({ file_path, old_string, new_string }, { readFileTimestamps }) {
219      const { patch, updatedFile } = applyEdit(file_path, old_string, new_string)
220  
221      const fullFilePath = isAbsolute(file_path)
222        ? file_path
223        : resolve(getCwd(), file_path)
224      const dir = dirname(fullFilePath)
225      mkdirSync(dir, { recursive: true })
226      const enc = existsSync(fullFilePath)
227        ? detectFileEncoding(fullFilePath)
228        : 'utf8'
229      const endings = existsSync(fullFilePath)
230        ? detectLineEndings(fullFilePath)
231        : 'LF'
232      const originalFile = existsSync(fullFilePath)
233        ? readFileSync(fullFilePath, enc)
234        : ''
235      writeTextContent(fullFilePath, updatedFile, enc, endings)
236  
237      // Update read timestamp, to invalidate stale writes
238      readFileTimestamps[fullFilePath] = statSync(fullFilePath).mtimeMs
239  
240      // Log when editing CLAUDE.md
241      if (fullFilePath.endsWith(`${sep}CLAUDE.md`)) {
242        logEvent('tengu_write_claudemd', {})
243      }
244  
245      const data = {
246        filePath: file_path,
247        oldString: old_string,
248        newString: new_string,
249        originalFile,
250        structuredPatch: patch,
251      }
252      yield {
253        type: 'result',
254        data,
255        resultForAssistant: this.renderResultForAssistant(data),
256      }
257    },
258    renderResultForAssistant({ filePath, originalFile, oldString, newString }) {
259      const { snippet, startLine } = getSnippet(
260        originalFile || '',
261        oldString,
262        newString,
263      )
264      return `The file ${filePath} has been updated. Here's the result of running \`cat -n\` on a snippet of the edited file:
265  ${addLineNumbers({
266    content: snippet,
267    startLine,
268  })}`
269    },
270  } satisfies Tool<
271    typeof inputSchema,
272    {
273      filePath: string
274      oldString: string
275      newString: string
276      originalFile: string
277      structuredPatch: Hunk[]
278    }
279  >
280  
281  export function getSnippet(
282    initialText: string,
283    oldStr: string,
284    newStr: string,
285  ): { snippet: string; startLine: number } {
286    const before = initialText.split(oldStr)[0] ?? ''
287    const replacementLine = before.split(/\r?\n/).length - 1
288    const newFileLines = initialText.replace(oldStr, newStr).split(/\r?\n/)
289    // Calculate the start and end line numbers for the snippet
290    const startLine = Math.max(0, replacementLine - N_LINES_SNIPPET)
291    const endLine =
292      replacementLine + N_LINES_SNIPPET + newStr.split(/\r?\n/).length
293    // Get snippet
294    const snippetLines = newFileLines.slice(startLine, endLine + 1)
295    const snippet = snippetLines.join('\n')
296    return { snippet, startLine: startLine + 1 }
297  }