Move search_and_replace to a tool file (#2096)

This commit is contained in:
Matt Rubens 2025-03-30 01:15:43 -04:00 committed by GitHub
parent d5328770e7
commit bdb668b68b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 195 additions and 179 deletions

View file

@ -88,6 +88,7 @@ import { getWorkspacePath } from "../utils/path"
import { writeToFileTool } from "./tools/writeToFileTool"
import { applyDiffTool } from "./tools/applyDiffTool"
import { insertContentTool } from "./tools/insertContentTool"
import { searchAndReplaceTool } from "./tools/searchAndReplaceTool"
export type ToolResponse = string | Array<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<Anthropic.Messages.ContentBlockParam>
@ -1576,187 +1577,25 @@ export class Cline extends EventEmitter<ClineEvents> {
case "insert_content":
await insertContentTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
break
case "search_and_replace": {
const relPath: string | undefined = block.params.path
const operations: string | undefined = block.params.operations
const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
path: getReadablePath(this.cwd, removeClosingTag("path", relPath)),
}
try {
if (block.partial) {
const partialMessage = JSON.stringify({
path: removeClosingTag("path", relPath),
operations: removeClosingTag("operations", operations),
})
await this.ask("tool", partialMessage, block.partial).catch(() => {})
break
} else {
if (!relPath) {
this.consecutiveMistakeCount++
pushToolResult(
await this.sayAndCreateMissingParamError("search_and_replace", "path"),
)
break
}
if (!operations) {
this.consecutiveMistakeCount++
pushToolResult(
await this.sayAndCreateMissingParamError("search_and_replace", "operations"),
)
break
}
const absolutePath = path.resolve(this.cwd, relPath)
const fileExists = await fileExistsAtPath(absolutePath)
if (!fileExists) {
this.consecutiveMistakeCount++
const formattedError = `File does not exist at path: ${absolutePath}\n\n<error_details>\nThe specified file could not be found. Please verify the file path and try again.\n</error_details>`
await this.say("error", formattedError)
pushToolResult(formattedError)
break
}
let parsedOperations: Array<{
search: string
replace: string
start_line?: number
end_line?: number
use_regex?: boolean
ignore_case?: boolean
regex_flags?: string
}>
try {
parsedOperations = JSON.parse(operations)
if (!Array.isArray(parsedOperations)) {
throw new Error("Operations must be an array")
}
} catch (error) {
this.consecutiveMistakeCount++
await this.say("error", `Failed to parse operations JSON: ${error.message}`)
pushToolResult(formatResponse.toolError("Invalid operations JSON format"))
break
}
// Read the original file content
const fileContent = await fs.readFile(absolutePath, "utf-8")
this.diffViewProvider.editType = "modify"
this.diffViewProvider.originalContent = fileContent
let lines = fileContent.split("\n")
for (const op of parsedOperations) {
const flags = op.regex_flags ?? (op.ignore_case ? "gi" : "g")
const multilineFlags = flags.includes("m") ? flags : flags + "m"
const searchPattern = op.use_regex
? new RegExp(op.search, multilineFlags)
: new RegExp(escapeRegExp(op.search), multilineFlags)
if (op.start_line || op.end_line) {
const startLine = Math.max((op.start_line ?? 1) - 1, 0)
const endLine = Math.min((op.end_line ?? lines.length) - 1, lines.length - 1)
// Get the content before and after the target section
const beforeLines = lines.slice(0, startLine)
const afterLines = lines.slice(endLine + 1)
// Get the target section and perform replacement
const targetContent = lines.slice(startLine, endLine + 1).join("\n")
const modifiedContent = targetContent.replace(searchPattern, op.replace)
const modifiedLines = modifiedContent.split("\n")
// Reconstruct the full content with the modified section
lines = [...beforeLines, ...modifiedLines, ...afterLines]
} else {
// Global replacement
const fullContent = lines.join("\n")
const modifiedContent = fullContent.replace(searchPattern, op.replace)
lines = modifiedContent.split("\n")
}
}
const newContent = lines.join("\n")
this.consecutiveMistakeCount = 0
// Show diff preview
const diff = formatResponse.createPrettyPatch(relPath, fileContent, newContent)
if (!diff) {
pushToolResult(`No changes needed for '${relPath}'`)
break
}
await this.diffViewProvider.open(relPath)
await this.diffViewProvider.update(newContent, true)
this.diffViewProvider.scrollToFirstDiff()
const completeMessage = JSON.stringify({
...sharedMessageProps,
diff: diff,
} satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await this.diffViewProvider.revertChanges() // This likely handles closing the diff view
break
}
const { newProblemsMessage, userEdits, finalContent } =
await this.diffViewProvider.saveChanges()
this.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
if (userEdits) {
await this.say(
"user_feedback_diff",
JSON.stringify({
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(this.cwd, relPath),
diff: userEdits,
} satisfies ClineSayTool),
)
pushToolResult(
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(finalContent || "")}\n</final_file_content>\n\n` +
`Please note:\n` +
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
`2. Proceed with the task using this updated file content as the new baseline.\n` +
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
`${newProblemsMessage}`,
)
} else {
pushToolResult(
`Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}`,
)
}
await this.diffViewProvider.reset()
break
}
} catch (error) {
await handleError("applying search and replace", error)
await this.diffViewProvider.reset()
break
}
}
case "read_file": {
case "search_and_replace":
await searchAndReplaceTool(
this,
block,
askApproval,
handleError,
pushToolResult,
removeClosingTag,
)
break
case "read_file":
await readFileTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
break
}
case "fetch_instructions": {
case "fetch_instructions":
await fetchInstructionsTool(this, block, askApproval, handleError, pushToolResult)
break
}
case "list_files": {
case "list_files":
await listFilesTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag)
break
}
case "list_code_definition_names": {
const relPath: string | undefined = block.params.path
const sharedMessageProps: ClineSayTool = {
@ -3562,7 +3401,3 @@ export class Cline extends EventEmitter<ClineEvents> {
}
}
}
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}

View file

@ -0,0 +1,181 @@
import { Cline } from "../Cline"
import { ToolUse } from "../assistant-message"
import { AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "./types"
import { formatResponse } from "../prompts/responses"
import { ClineSayTool } from "../../shared/ExtensionMessage"
import { getReadablePath } from "../../utils/path"
import path from "path"
import { fileExistsAtPath } from "../../utils/fs"
import { addLineNumbers } from "../../integrations/misc/extract-text"
import fs from "fs/promises"
export async function searchAndReplaceTool(
cline: Cline,
block: ToolUse,
askApproval: AskApproval,
handleError: HandleError,
pushToolResult: PushToolResult,
removeClosingTag: RemoveClosingTag,
) {
const relPath: string | undefined = block.params.path
const operations: string | undefined = block.params.operations
const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
path: getReadablePath(cline.cwd, removeClosingTag("path", relPath)),
}
try {
if (block.partial) {
const partialMessage = JSON.stringify({
path: removeClosingTag("path", relPath),
operations: removeClosingTag("operations", operations),
})
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
return
} else {
if (!relPath) {
cline.consecutiveMistakeCount++
pushToolResult(await cline.sayAndCreateMissingParamError("search_and_replace", "path"))
return
}
if (!operations) {
cline.consecutiveMistakeCount++
pushToolResult(await cline.sayAndCreateMissingParamError("search_and_replace", "operations"))
return
}
const absolutePath = path.resolve(cline.cwd, relPath)
const fileExists = await fileExistsAtPath(absolutePath)
if (!fileExists) {
cline.consecutiveMistakeCount++
const formattedError = `File does not exist at path: ${absolutePath}\n\n<error_details>\nThe specified file could not be found. Please verify the file path and try again.\n</error_details>`
await cline.say("error", formattedError)
pushToolResult(formattedError)
return
}
let parsedOperations: Array<{
search: string
replace: string
start_line?: number
end_line?: number
use_regex?: boolean
ignore_case?: boolean
regex_flags?: string
}>
try {
parsedOperations = JSON.parse(operations)
if (!Array.isArray(parsedOperations)) {
throw new Error("Operations must be an array")
}
} catch (error) {
cline.consecutiveMistakeCount++
await cline.say("error", `Failed to parse operations JSON: ${error.message}`)
pushToolResult(formatResponse.toolError("Invalid operations JSON format"))
return
}
// Read the original file content
const fileContent = await fs.readFile(absolutePath, "utf-8")
cline.diffViewProvider.editType = "modify"
cline.diffViewProvider.originalContent = fileContent
let lines = fileContent.split("\n")
for (const op of parsedOperations) {
const flags = op.regex_flags ?? (op.ignore_case ? "gi" : "g")
const multilineFlags = flags.includes("m") ? flags : flags + "m"
const searchPattern = op.use_regex
? new RegExp(op.search, multilineFlags)
: new RegExp(escapeRegExp(op.search), multilineFlags)
if (op.start_line || op.end_line) {
const startLine = Math.max((op.start_line ?? 1) - 1, 0)
const endLine = Math.min((op.end_line ?? lines.length) - 1, lines.length - 1)
// Get the content before and after the target section
const beforeLines = lines.slice(0, startLine)
const afterLines = lines.slice(endLine + 1)
// Get the target section and perform replacement
const targetContent = lines.slice(startLine, endLine + 1).join("\n")
const modifiedContent = targetContent.replace(searchPattern, op.replace)
const modifiedLines = modifiedContent.split("\n")
// Reconstruct the full content with the modified section
lines = [...beforeLines, ...modifiedLines, ...afterLines]
} else {
// Global replacement
const fullContent = lines.join("\n")
const modifiedContent = fullContent.replace(searchPattern, op.replace)
lines = modifiedContent.split("\n")
}
}
const newContent = lines.join("\n")
cline.consecutiveMistakeCount = 0
// Show diff preview
const diff = formatResponse.createPrettyPatch(relPath, fileContent, newContent)
if (!diff) {
pushToolResult(`No changes needed for '${relPath}'`)
return
}
await cline.diffViewProvider.open(relPath)
await cline.diffViewProvider.update(newContent, true)
cline.diffViewProvider.scrollToFirstDiff()
const completeMessage = JSON.stringify({
...sharedMessageProps,
diff: diff,
} satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
await cline.diffViewProvider.revertChanges() // cline likely handles closing the diff view
return
}
const { newProblemsMessage, userEdits, finalContent } = await cline.diffViewProvider.saveChanges()
cline.didEditFile = true // used to determine if we should wait for busy terminal to update before sending api request
if (userEdits) {
await cline.say(
"user_feedback_diff",
JSON.stringify({
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cline.cwd, relPath),
diff: userEdits,
} satisfies ClineSayTool),
)
pushToolResult(
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
`The updated content, which includes both your original modifications and the user's edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file, including line numbers:\n\n` +
`<final_file_content path="${relPath.toPosix()}">\n${addLineNumbers(finalContent || "")}\n</final_file_content>\n\n` +
`Please note:\n` +
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
`2. Proceed with the task using cline updated file content as the new baseline.\n` +
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
`${newProblemsMessage}`,
)
} else {
pushToolResult(`Changes successfully applied to ${relPath.toPosix()}:\n\n${newProblemsMessage}`)
}
await cline.diffViewProvider.reset()
return
}
} catch (error) {
await handleError("applying search and replace", error)
await cline.diffViewProvider.reset()
return
}
}
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}