Checkpoint on insert and search/replace tools

This commit is contained in:
Matt Rubens
2025-01-21 23:49:48 -08:00
committed by sam hoang
parent f07109b686
commit 2c97b59ed1
7 changed files with 459 additions and 1 deletions

View File

@@ -60,6 +60,7 @@ import { BrowserSession } from "../services/browser/BrowserSession"
import { OpenRouterHandler } from "../api/providers/openrouter" import { OpenRouterHandler } from "../api/providers/openrouter"
import { McpHub } from "../services/mcp/McpHub" import { McpHub } from "../services/mcp/McpHub"
import crypto from "crypto" import crypto from "crypto"
import { insertGroups } from "./diff/insert-groups"
const cwd = const cwd =
vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution
@@ -1008,6 +1009,10 @@ export class Cline {
return `[${block.name} for '${block.params.regex}'${ return `[${block.name} for '${block.params.regex}'${
block.params.file_pattern ? ` in '${block.params.file_pattern}'` : "" block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
}]` }]`
case "insert_code_block":
return `[${block.name} for '${block.params.path}']`
case "search_and_replace":
return `[${block.name} for '${block.params.path}']`
case "list_files": case "list_files":
return `[${block.name} for '${block.params.path}']` return `[${block.name} for '${block.params.path}']`
case "list_code_definition_names": case "list_code_definition_names":
@@ -1479,6 +1484,323 @@ export class Cline {
break break
} }
} }
case "insert_code_block": {
const relPath: string | undefined = block.params.path
const operations: string | undefined = block.params.operations
const sharedMessageProps: ClineSayTool = {
tool: "appliedDiff",
path: getReadablePath(cwd, removeClosingTag("path", relPath)),
}
try {
if (block.partial) {
const partialMessage = JSON.stringify(sharedMessageProps)
await this.ask("tool", partialMessage, block.partial).catch(() => {})
break
}
// Validate required parameters
if (!relPath) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("insert_code_block", "path"))
break
}
if (!operations) {
this.consecutiveMistakeCount++
pushToolResult(
await this.sayAndCreateMissingParamError("insert_code_block", "operations"),
)
break
}
const absolutePath = path.resolve(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<{
start_line: number
content: 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
}
this.consecutiveMistakeCount = 0
// Read the file
const fileContent = await fs.readFile(absolutePath, "utf8")
this.diffViewProvider.editType = "modify"
this.diffViewProvider.originalContent = fileContent
const lines = fileContent.split("\n")
const updatedContent = insertGroups(
lines,
parsedOperations.map((elem) => {
return {
index: elem.start_line - 1,
elements: elem.content.split("\n"),
}
}),
).join("\n")
// Show changes in diff view
if (!this.diffViewProvider.isEditing) {
await this.ask("tool", JSON.stringify(sharedMessageProps), true).catch(() => {})
// First open with original content
await this.diffViewProvider.open(relPath)
await this.diffViewProvider.update(fileContent, false)
this.diffViewProvider.scrollToFirstDiff()
await delay(200)
}
const diff = formatResponse.createPrettyPatch(
relPath,
this.diffViewProvider.originalContent,
updatedContent,
)
if (!diff) {
pushToolResult(`No changes needed for '${relPath}'`)
break
}
await this.diffViewProvider.update(updatedContent, true)
const completeMessage = JSON.stringify({
...sharedMessageProps,
diff,
} satisfies ClineSayTool)
const didApprove = await this.ask("tool", completeMessage, false).then(
(response) => response.response === "yesButtonClicked",
)
if (!didApprove) {
await this.diffViewProvider.revertChanges()
pushToolResult("Changes were rejected by the user.")
break
}
const { newProblemsMessage, userEdits, finalContent } =
await this.diffViewProvider.saveChanges()
this.didEditFile = true
if (!userEdits) {
pushToolResult(
`The code block was successfully inserted in ${relPath.toPosix()}.${newProblemsMessage}`,
)
await this.diffViewProvider.reset()
break
}
const userFeedbackDiff = JSON.stringify({
tool: "appliedDiff",
path: getReadablePath(cwd, relPath),
diff: userEdits,
} satisfies ClineSayTool)
console.debug("[DEBUG] User made edits, sending feedback diff:", userFeedbackDiff)
await this.say("user_feedback_diff", userFeedbackDiff)
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:\n\n` +
`<final_file_content path="${relPath.toPosix()}">\n${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}`,
)
await this.diffViewProvider.reset()
} catch (error) {
handleError("insert block", error)
await this.diffViewProvider.reset()
}
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(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(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")
const lines = fileContent.split("\n")
let newContent = fileContent
// Apply each search/replace operation
for (const op of parsedOperations) {
const searchPattern = op.use_regex
? new RegExp(op.search, op.regex_flags || (op.ignore_case ? "gi" : "g"))
: new RegExp(escapeRegExp(op.search), op.ignore_case ? "gi" : "g")
if (op.start_line || op.end_line) {
// Line-restricted replacement
const startLine = (op.start_line || 1) - 1
const endLine = (op.end_line || lines.length) - 1
const beforeLines = lines.slice(0, startLine)
const targetLines = lines.slice(startLine, endLine + 1)
const afterLines = lines.slice(endLine + 1)
const modifiedLines = targetLines.map((line) =>
line.replace(searchPattern, op.replace),
)
newContent = [...beforeLines, ...modifiedLines, ...afterLines].join("\n")
} else {
// Global replacement
newContent = newContent.replace(searchPattern, op.replace)
}
}
this.consecutiveMistakeCount = 0
// Show diff preview
const diff = formatResponse.createPrettyPatch(
relPath,
this.diffViewProvider.originalContent,
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(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 "read_file": {
const relPath: string | undefined = block.params.path const relPath: string | undefined = block.params.path
const sharedMessageProps: ClineSayTool = { const sharedMessageProps: ClineSayTool = {
@@ -2750,3 +3072,7 @@ export class Cline {
return `<environment_details>\n${details.trim()}\n</environment_details>` return `<environment_details>\n${details.trim()}\n</environment_details>`
} }
} }
function escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}

View File

@@ -13,6 +13,8 @@ export const toolUseNames = [
"read_file", "read_file",
"write_to_file", "write_to_file",
"apply_diff", "apply_diff",
"insert_code_block",
"search_and_replace",
"search_files", "search_files",
"list_files", "list_files",
"list_code_definition_names", "list_code_definition_names",
@@ -50,6 +52,7 @@ export const toolParamNames = [
"end_line", "end_line",
"mode_slug", "mode_slug",
"reason", "reason",
"operations",
] as const ] as const
export type ToolParamName = (typeof toolParamNames)[number] export type ToolParamName = (typeof toolParamNames)[number]
@@ -78,6 +81,11 @@ export interface WriteToFileToolUse extends ToolUse {
params: Partial<Pick<Record<ToolParamName, string>, "path" | "content" | "line_count">> params: Partial<Pick<Record<ToolParamName, string>, "path" | "content" | "line_count">>
} }
export interface InsertCodeBlockToolUse extends ToolUse {
name: "insert_code_block"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "operations">>
}
export interface SearchFilesToolUse extends ToolUse { export interface SearchFilesToolUse extends ToolUse {
name: "search_files" name: "search_files"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "regex" | "file_pattern">> params: Partial<Pick<Record<ToolParamName, string>, "path" | "regex" | "file_pattern">>

View File

@@ -0,0 +1,31 @@
/**
* Inserts multiple groups of elements at specified indices in an array
* @param original Array to insert into, split by lines
* @param insertGroups Array of groups to insert, each with an index and elements to insert
* @returns New array with all insertions applied
*/
export interface InsertGroup {
index: number
elements: string[]
}
export function insertGroups(original: string[], insertGroups: InsertGroup[]): string[] {
// Sort groups by index to maintain order
insertGroups.sort((a, b) => a.index - b.index)
let result: string[] = []
let lastIndex = 0
insertGroups.forEach(({ index, elements }) => {
// Add elements from original array up to insertion point
result.push(...original.slice(lastIndex, index))
// Add the group of elements
result.push(...elements)
lastIndex = index
})
// Add remaining elements from original array
result.push(...original.slice(lastIndex))
return result
}

View File

@@ -3,6 +3,8 @@ import { getReadFileDescription } from "./read-file"
import { getWriteToFileDescription } from "./write-to-file" import { getWriteToFileDescription } from "./write-to-file"
import { getSearchFilesDescription } from "./search-files" import { getSearchFilesDescription } from "./search-files"
import { getListFilesDescription } from "./list-files" import { getListFilesDescription } from "./list-files"
import { getInsertCodeBlockDescription } from "./insert-code-block"
import { getSearchAndReplaceDescription } from "./search-and-replace"
import { getListCodeDefinitionNamesDescription } from "./list-code-definition-names" import { getListCodeDefinitionNamesDescription } from "./list-code-definition-names"
import { getBrowserActionDescription } from "./browser-action" import { getBrowserActionDescription } from "./browser-action"
import { getAskFollowupQuestionDescription } from "./ask-followup-question" import { getAskFollowupQuestionDescription } from "./ask-followup-question"
@@ -30,6 +32,8 @@ const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined>
use_mcp_tool: (args) => getUseMcpToolDescription(args), use_mcp_tool: (args) => getUseMcpToolDescription(args),
access_mcp_resource: (args) => getAccessMcpResourceDescription(args), access_mcp_resource: (args) => getAccessMcpResourceDescription(args),
switch_mode: () => getSwitchModeDescription(), switch_mode: () => getSwitchModeDescription(),
insert_code_block: (args) => getInsertCodeBlockDescription(args),
search_and_replace: (args) => getSearchAndReplaceDescription(args),
apply_diff: (args) => apply_diff: (args) =>
args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "", args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "",
} }
@@ -100,4 +104,6 @@ export {
getUseMcpToolDescription, getUseMcpToolDescription,
getAccessMcpResourceDescription, getAccessMcpResourceDescription,
getSwitchModeDescription, getSwitchModeDescription,
getInsertCodeBlockDescription,
getSearchAndReplaceDescription,
} }

View File

@@ -0,0 +1,35 @@
import { ToolArgs } from "./types"
export function getInsertCodeBlockDescription(args: ToolArgs): string {
return `## insert_code_block
Description: Inserts code blocks at specific line positions in a file. This is the primary tool for adding new code (functions/methods/classes, imports, attributes etc.) as it allows for precise insertions without overwriting existing content. The tool uses an efficient line-based insertion system that maintains file integrity and proper ordering of multiple insertions. Beware to use the proper indentation. This tool is the preferred way to add new code to files.
Parameters:
- path: (required) The path of the file to insert code into (relative to the current working directory ${args.cwd.toPosix()})
- operations: (required) A JSON array of insertion operations. Each operation is an object with:
* start_line: (required) The line number where the code block should be inserted
* content: (required) The code block to insert at the specified position
Usage:
<insert_code_block>
<path>File path here</path>
<operations>[
{
"start_line": 10,
"content": "Your code block here"
}
]</operations>
</insert_code_block>
Example: Insert a new function and its import statement
<insert_code_block>
<path>src/app.ts</path>
<operations>[
{
"start_line": 1,
"content": "import { sum } from './utils';"
},
{
"start_line": 10,
"content": "function calculateTotal(items: number[]): number {\n return items.reduce((sum, item) => sum + item, 0);\n}"
}
]</operations>
</insert_code_block>`
}

View File

@@ -0,0 +1,52 @@
import { ToolArgs } from "./types"
export function getSearchAndReplaceDescription(args: ToolArgs): string {
return `## search_and_replace
Description: Request to perform search and replace operations on a file. Each operation can specify a search pattern (string or regex) and replacement text, with optional line range restrictions and regex flags. Shows a diff preview before applying changes.
Parameters:
- path: (required) The path of the file to modify (relative to the current working directory ${args.cwd.toPosix()})
- operations: (required) A JSON array of search/replace operations. Each operation is an object with:
* search: (required) The text or pattern to search for
* replace: (required) The text to replace matches with
* start_line: (optional) Starting line number for restricted replacement
* end_line: (optional) Ending line number for restricted replacement
* use_regex: (optional) Whether to treat search as a regex pattern
* ignore_case: (optional) Whether to ignore case when matching
* regex_flags: (optional) Additional regex flags when use_regex is true
Usage:
<search_and_replace>
<path>File path here</path>
<operations>[
{
"search": "text to find",
"replace": "replacement text",
"start_line": 1,
"end_line": 10
}
]</operations>
</search_and_replace>
Example: Replace "foo" with "bar" in lines 1-10 of example.ts
<search_and_replace>
<path>example.ts</path>
<operations>[
{
"search": "foo",
"replace": "bar",
"start_line": 1,
"end_line": 10
}
]</operations>
</search_and_replace>
Example: Replace all occurrences of "old" with "new" using regex
<search_and_replace>
<path>example.ts</path>
<operations>[
{
"search": "old\\w+",
"replace": "new$&",
"use_regex": true,
"ignore_case": true
}
]</operations>
</search_and_replace>`
}

View File

@@ -21,7 +21,7 @@ export const TOOL_DISPLAY_NAMES = {
// Define available tool groups // Define available tool groups
export const TOOL_GROUPS: Record<string, ToolGroupValues> = { export const TOOL_GROUPS: Record<string, ToolGroupValues> = {
read: ["read_file", "search_files", "list_files", "list_code_definition_names"], read: ["read_file", "search_files", "list_files", "list_code_definition_names"],
edit: ["write_to_file", "apply_diff"], edit: ["write_to_file", "apply_diff", "insert_code_block", "search_and_replace"],
browser: ["browser_action"], browser: ["browser_action"],
command: ["execute_command"], command: ["execute_command"],
mcp: ["use_mcp_tool", "access_mcp_resource"], mcp: ["use_mcp_tool", "access_mcp_resource"],