Use diff based editing with write_to_file tool

This commit is contained in:
Saoud Rizwan 2024-12-15 21:02:28 -08:00
parent d49397bdb2
commit 44f70c1102
8 changed files with 350 additions and 84 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "claude-dev",
"version": "2.2.0",
"version": "2.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "2.2.0",
"version": "2.2.2",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",

View file

@ -48,6 +48,7 @@ import { truncateHalfConversation } from "./sliding-window"
import { ClineProvider, GlobalFileNames } from "./webview/ClineProvider"
import { showOmissionWarning } from "../integrations/editor/detect-omission"
import { BrowserSession } from "../services/browser/BrowserSession"
import { constructNewFileContent } from "./assistant-message/diff"
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
@ -1034,9 +1035,9 @@ export class Cline {
switch (block.name) {
case "write_to_file": {
const relPath: string | undefined = block.params.path
let newContent: string | undefined = block.params.content
if (!relPath || !newContent) {
// checking for newContent ensure relPath is complete
let diff: string | undefined = block.params.diff
if (!relPath || !diff) {
// checking for diff ensure relPath is complete
// wait so we can determine if it's a new file or editing an existing file
break
}
@ -1050,34 +1051,43 @@ export class Cline {
this.diffViewProvider.editType = fileExists ? "modify" : "create"
}
// pre-processing newContent for cases where weaker models might add artifacts like markdown codeblock markers (deepseek/llama) or extra escape characters (gemini)
if (newContent.startsWith("```")) {
// this handles cases where it includes language specifiers like ```python ```js
newContent = newContent.split("\n").slice(1).join("\n").trim()
}
if (newContent.endsWith("```")) {
newContent = newContent.split("\n").slice(0, -1).join("\n").trim()
}
if (!this.api.getModel().id.includes("claude")) {
// it seems not just llama models are doing this, but also gemini and potentially others
if (
newContent.includes(">") ||
newContent.includes("<") ||
newContent.includes(""")
) {
newContent = newContent
.replace(/>/g, ">")
.replace(/&lt;/g, "<")
.replace(/&quot;/g, '"')
}
}
const sharedMessageProps: ClineSayTool = {
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cwd, removeClosingTag("path", relPath)),
}
try {
// Construct newContent from diff
let newContent = await constructNewFileContent(
diff,
this.diffViewProvider.originalContent || "",
!block.partial,
)
// pre-processing newContent for cases where weaker models might add artifacts like markdown codeblock markers (deepseek/llama) or extra escape characters (gemini)
// if (newContent.startsWith("```")) {
// // this handles cases where it includes language specifiers like ```python ```js
// newContent = newContent.split("\n").slice(1).join("\n").trim()
// }
// if (newContent.endsWith("```")) {
// newContent = newContent.split("\n").slice(0, -1).join("\n").trim()
// }
if (!this.api.getModel().id.includes("claude")) {
// it seems not just llama models are doing this, but also gemini and potentially others
if (
newContent.includes("&gt;") ||
newContent.includes("&lt;") ||
newContent.includes("&quot;")
) {
newContent = newContent
.replace(/&gt;/g, ">")
.replace(/&lt;/g, "<")
.replace(/&quot;/g, '"')
}
}
newContent = newContent.trimEnd() // remove any trailing newlines, since it's automatically inserted by the editor
if (block.partial) {
// update gui message
const partialMessage = JSON.stringify(sharedMessageProps)
@ -1097,9 +1107,9 @@ export class Cline {
await this.diffViewProvider.reset()
break
}
if (!newContent) {
if (!diff) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "content"))
pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "diff"))
await this.diffViewProvider.reset()
break
}
@ -1117,18 +1127,18 @@ export class Cline {
await this.diffViewProvider.update(newContent, true)
await delay(300) // wait for diff view to update
this.diffViewProvider.scrollToFirstDiff()
showOmissionWarning(this.diffViewProvider.originalContent || "", newContent)
// showOmissionWarning(this.diffViewProvider.originalContent || "", newContent)
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: fileExists ? undefined : newContent,
diff: fileExists
? formatResponse.createPrettyPatch(
relPath,
this.diffViewProvider.originalContent,
newContent,
)
: undefined,
diff: fileExists ? diff : undefined,
// ? formatResponse.createPrettyPatch(
// relPath,
// this.diffViewProvider.originalContent,
// newContent,
// )
// : undefined,
} satisfies ClineSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
@ -1151,6 +1161,7 @@ export class Cline {
`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` +
`IMPORTANT: If you need to make further changes to this file, use this final_file_content as the new baseline for your changes, as it is now the current state of the file (including the user's edits and any auto-formatting done by the system). \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` +
@ -1159,7 +1170,11 @@ export class Cline {
)
} else {
pushToolResult(
`The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}`,
`The content was successfully saved to ${relPath.toPosix()}.\n\n` +
`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` +
`IMPORTANT: If you need to make further changes to this file, use this final_file_content as the new baseline for your changes, as it is now the current state of the file (including any auto-formatting done by the system). \n\n` +
`${newProblemsMessage}`,
)
}
await this.diffViewProvider.reset()
@ -1167,6 +1182,7 @@ export class Cline {
}
} catch (error) {
await handleError("writing file", error)
await this.diffViewProvider.revertChanges()
await this.diffViewProvider.reset()
break
}

View file

@ -0,0 +1,165 @@
/**
* This function reconstructs the file content by applying a streamed diff (in a
* specialized SEARCH/REPLACE block format) to the original file content. It is designed
* to handle both incremental updates and the final resulting file after all chunks have
* been processed.
*
* The diff format is a custom structure that uses three markers to define changes:
*
* <<<<<<< SEARCH
* [Exact content to find in the original file]
* =======
* [Content to replace with]
* >>>>>>> REPLACE
*
* Behavior and Assumptions:
* 1. The file is processed chunk-by-chunk. Each chunk of `diffContent` may contain
* partial or complete SEARCH/REPLACE blocks. By calling this function with each
* incremental chunk (with `isFinal` indicating the last chunk), the final reconstructed
* file content is produced.
*
* 2. Exact Matching:
* - For each SEARCH block, the exact text must appear in the original file after
* `lastProcessedIndex`. If it does, that portion of the original file will be replaced
* with the corresponding REPLACE content immediately following the "=======" marker.
* - If no exact match is found, an error is thrown.
*
* 3. Empty SEARCH Section:
* - If SEARCH is empty and the original file is empty, this indicates creating a new file
* (pure insertion).
* - If SEARCH is empty and the original file is not empty, this indicates a complete
* file replacement (the entire original content is considered matched and replaced).
*
* 4. Applying Changes:
* - Before encountering the "=======" marker, lines are accumulated as search content.
* - After "=======" and before ">>>>>>> REPLACE", lines are accumulated as replacement content.
* - Once the block is complete (">>>>>>> REPLACE"), the matched section in the original
* file is replaced with the accumulated replacement lines, and the position in the original
* file is advanced.
*
* 5. Incremental Output:
* - As soon as the match location is found and we are in the REPLACE section, each new
* replacement line is appended to the result so that partial updates can be viewed
* incrementally.
*
* 6. Partial Markers:
* - If the final line of the chunk looks like it might be part of a marker but is not one
* of the known markers, it is removed. This prevents incomplete or partial markers
* from corrupting the output.
*
* 7. Finalization:
* - Once all chunks have been processed (when `isFinal` is true), any remaining original
* content after the last replaced section is appended to the result.
* - Trailing newlines are trimmed.
*
* Errors:
* - If a specified SEARCH block does not appear in the original file at the expected position,
* an error is thrown indicating that the search text was not found.
*/
export async function constructNewFileContent(
diffContent: string,
originalContent: string,
isFinal: boolean,
): Promise<string> {
let result = ""
let lastProcessedIndex = 0
let currentSearchContent = ""
let currentReplaceContent = ""
let inSearch = false
let inReplace = false
let searchMatchIndex = -1
let searchEndIndex = -1
let lines = diffContent.split("\n")
// If the last line looks like a partial marker but isn't recognized,
// we are removing it because it might be incomplete.
const lastLine = lines[lines.length - 1]
if (
lines.length > 0 &&
(lastLine.startsWith("<") || lastLine.startsWith("=") || lastLine.startsWith(">")) &&
lastLine !== "<<<<<<< SEARCH" &&
lastLine !== "=======" &&
lastLine !== ">>>>>>> REPLACE"
) {
lines.pop()
}
for (const line of lines) {
if (line === "<<<<<<< SEARCH") {
inSearch = true
currentSearchContent = ""
currentReplaceContent = ""
continue
}
if (line === "=======") {
inSearch = false
inReplace = true
if (!currentSearchContent) {
// Empty search block
if (originalContent.length === 0) {
// New file scenario: nothing to match, just start inserting
searchMatchIndex = 0
searchEndIndex = 0
} else {
// Complete file replacement scenario: treat the entire file as matched
searchMatchIndex = 0
searchEndIndex = originalContent.length
}
} else {
// Exact search match scenario
const exactIndex = originalContent.indexOf(currentSearchContent, lastProcessedIndex)
if (exactIndex !== -1) {
searchMatchIndex = exactIndex
searchEndIndex = exactIndex + currentSearchContent.length
} else {
throw new Error(
`The search text:\n${currentSearchContent.trimEnd()}\n...does not match anything in the file.`,
)
}
}
// Output everything up to the match location
result += originalContent.slice(lastProcessedIndex, searchMatchIndex)
continue
}
if (line === ">>>>>>> REPLACE") {
// Finished one replace block
// Advance lastProcessedIndex to after the matched section
lastProcessedIndex = searchEndIndex
// Reset for next block
inSearch = false
inReplace = false
currentSearchContent = ""
currentReplaceContent = ""
searchMatchIndex = -1
searchEndIndex = -1
continue
}
// Accumulate content for search or replace
if (inSearch) {
currentSearchContent += line + "\n"
} else if (inReplace) {
currentReplaceContent += line + "\n"
// Output replacement lines immediately if we know the insertion point
if (searchMatchIndex !== -1) {
result += line + "\n"
}
}
}
// If this is the final chunk, append any remaining original content
if (isFinal && lastProcessedIndex < originalContent.length) {
result += originalContent.slice(lastProcessedIndex)
}
return result
}

View file

@ -28,7 +28,7 @@ export type ToolUseName = (typeof toolUseNames)[number]
export const toolParamNames = [
"command",
"path",
"content",
"diff",
"regex",
"file_pattern",
"recursive",
@ -67,7 +67,7 @@ export interface ReadFileToolUse extends ToolUse {
export interface WriteToFileToolUse extends ToolUse {
name: "write_to_file"
params: Partial<Pick<Record<ToolParamName, string>, "path" | "content">>
params: Partial<Pick<Record<ToolParamName, string>, "path" | "diff">>
}
export interface SearchFilesToolUse extends ToolUse {

View file

@ -61,18 +61,16 @@ export function parseAssistantMessage(assistantMessage: string) {
// there's no current param, and not starting a new param
// special case for write_to_file where file contents could contain the closing tag, in which case the param would have closed and we end up with the rest of the file contents here. To work around this, we get the string between the starting content tag and the LAST content tag.
const contentParamName: ToolParamName = "content"
if (currentToolUse.name === "write_to_file" && accumulator.endsWith(`</${contentParamName}>`)) {
// special case for write_to_file where file contents could contain the closing tag, in which case the param would have closed and we end up with the rest of the file contents here. To work around this, we get the string between the starting diff tag and the LAST diff tag.
const diffParamName: ToolParamName = "diff"
if (currentToolUse.name === "write_to_file" && accumulator.endsWith(`</${diffParamName}>`)) {
const toolContent = accumulator.slice(currentToolUseStartIndex)
const contentStartTag = `<${contentParamName}>`
const contentEndTag = `</${contentParamName}>`
const contentStartIndex = toolContent.indexOf(contentStartTag) + contentStartTag.length
const contentEndIndex = toolContent.lastIndexOf(contentEndTag)
if (contentStartIndex !== -1 && contentEndIndex !== -1 && contentEndIndex > contentStartIndex) {
currentToolUse.params[contentParamName] = toolContent
.slice(contentStartIndex, contentEndIndex)
.trim()
const diffStartTag = `<${diffParamName}>`
const diffEndTag = `</${diffParamName}>`
const diffStartIndex = toolContent.indexOf(diffStartTag) + diffStartTag.length
const diffEndIndex = toolContent.lastIndexOf(diffEndTag)
if (diffStartIndex !== -1 && diffEndIndex !== -1 && diffEndIndex > diffStartIndex) {
currentToolUse.params[diffParamName] = toolContent.slice(diffStartIndex, diffEndIndex).trim()
}
}

View file

@ -54,16 +54,40 @@ Usage:
</read_file>
## write_to_file
Description: Request to write content to a file at the specified path. If the file exists, it will be overwritten with the provided content. If the file doesn't exist, it will be created. This tool will automatically create any directories needed to write the file.
Description: Request to write to a file using search/replace blocks that define exact changes. Creates new files or modifies existing ones with precise control. If the file doesn't exist, it will be created. This tool will automatically create any directories needed to write the file.
Parameters:
- path: (required) The path of the file to write to (relative to the current working directory ${cwd.toPosix()})
- content: (required) The content to write to the file. ALWAYS provide the COMPLETE intended content of the file, without any truncation or omissions. You MUST include ALL parts of the file, even if they haven't been modified.
- diff: (required) One or more search/replace blocks following this exact format:
\`\`\`
<<<<<<< SEARCH
[exact content to find]
=======
[content to replace with]
>>>>>>> REPLACE
\`\`\`
Each block must follow these critical rules:
1. SEARCH section must match existing file content EXACTLY:
* Match character-for-character including whitespace, indentation, line endings
* Include all comments, docstrings, etc.
2. Only the first match occurrence is replaced
* Use multiple blocks for multiple occurrences
* Include enough context lines to ensure unique matches
3. Keep blocks focused:
* Break large changes into multiple small blocks
* Include only changing lines and minimal context
* Do not include long runs of unchanged lines
* Each block should modify one logical chunk
4. Special operations:
* New files: Use empty SEARCH section
* Completely replace existing file's contents: Use empty SEARCH section
* Moving code: Use two blocks (delete from original + insert at new location)
* Code deletion: Use empty REPLACE section
Usage:
<write_to_file>
<path>File path here</path>
<content>
Your file content here
</content>
<diff>
Search and replace blocks here
</diff>
</write_to_file>
## search_files
@ -197,29 +221,7 @@ Your final result description here
<command>npm run dev</command>
</execute_command>
## Example 2: Requesting to write to a file
<write_to_file>
<path>frontend-config.json</path>
<content>
{
"apiEndpoint": "https://api.example.com",
"theme": {
"primaryColor": "#007bff",
"secondaryColor": "#6c757d",
"fontFamily": "Arial, sans-serif"
},
"features": {
"darkMode": true,
"notifications": true,
"analytics": false
},
"version": "1.0.0"
}
</content>
</write_to_file>
## Example 3: Requesting to use an MCP tool
## Example 2: Requesting to use an MCP tool
<use_mcp_tool>
<server_name>weather-server</server_name>
@ -232,13 +234,95 @@ Your final result description here
</arguments>
</use_mcp_tool>
## Example 4: Requesting to access an MCP resource
## Example 3: Requesting to access an MCP resource
<access_mcp_resource>
<server_name>weather-server</server_name>
<uri>weather://san-francisco/current</uri>
</access_mcp_resource>
## Example 4: Requesting to create a new file
<write_to_file>
<path>src/utils/types.ts</path>
<diff>
<<<<<<< SEARCH
=======
export interface User {
id: string;
name: string;
email: string;
}
>>>>>>> REPLACE
</diff>
</write_to_file>
## Example 5: Requesting to modify existing content
<write_to_file>
<path>src/services/UserService.ts</path>
<diff>
<<<<<<< SEARCH
async getUser(id: string) {
return await this.users.findOne(id);
}
=======
async getUser(id: string) {
const user = await this.users.findOne(id);
if (!user) throw new Error(\`User \${id} not found\`);
return user;
}
>>>>>>> REPLACE
</write_to_file>
## Example 6: Requesting to move two blocks of code
<write_to_file>
<path>src/components/App.tsx</path>
<diff>
<<<<<<< SEARCH
function handleSubmit() {
saveData();
setLoading(false);
}
=======
>>>>>>> REPLACE
<<<<<<< SEARCH
return (
<div>
=======
function handleSubmit() {
saveData();
setLoading(false);
}
return (
<div>
>>>>>>> REPLACE
</diff>
</write_to_file>
## Example 7: Requesting to make multiple focused changes in a file
<write_to_file>
<path>src/config.ts</path>
<diff>
<<<<<<< SEARCH
const maxRetries = 3;
=======
const maxRetries = 5;
>>>>>>> REPLACE
<<<<<<< SEARCH
export const timeout = 1000;
=======
export const timeout = process.env.TIMEOUT || 1000;
>>>>>>> REPLACE
</diff>
</write_to_file>
# Tool Use Guidelines
1. In <thinking> tags, assess what information you already have and what information you need to proceed with the task.
@ -710,7 +794,6 @@ RULES
- When presented with images, utilize your vision capabilities to thoroughly examine them and extract meaningful information. Incorporate these insights into your thought process as you accomplish the user's task.
- At the end of each user message, you will automatically receive environment_details. This information is not written by the user themselves, but is auto-generated to provide potentially relevant context about the project structure and environment. While this information can be valuable for understanding the project context, do not treat it as a direct part of the user's request or response. Use it to inform your actions and decisions, but don't assume the user is explicitly asking about or referring to this information unless they clearly do so in their message. When using environment_details, explain your actions clearly to ensure the user understands, as they may not be aware of these details.
- Before executing commands, check the "Actively Running Terminals" section in environment_details. If present, consider how these active processes might impact your task. For example, if a local development server is already running, you wouldn't need to start it again. If no active terminals are listed, proceed with command execution as normal.
- When using the write_to_file tool, ALWAYS provide the COMPLETE file content in your response. This is NON-NEGOTIABLE. Partial updates or placeholders like '// rest of code unchanged' are STRICTLY FORBIDDEN. You MUST include ALL parts of the file, even if they haven't been modified. Failure to do so will result in incomplete or broken code, severely impacting the user's project.
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.
- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.${
supportsComputerUse

View file

@ -148,11 +148,15 @@ export class DiffViewProvider {
}
const absolutePath = path.resolve(this.cwd, this.relPath)
const updatedDocument = this.activeDiffEditor.document
const editedContent = updatedDocument.getText()
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
// await delay(100)
// Need to get text after save in case there is any auto-formatting done by the editor
const editedContent = updatedDocument.getText()
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
await this.closeAllDiffViews()

View file

@ -257,7 +257,7 @@ export const ChatRowContent = ({
</div>
<CodeAccordian
isLoading={message.partial}
diff={tool.diff!}
code={tool.diff!}
path={tool.path!}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}