Refactor file editing to DiffViewProvider

This commit is contained in:
Saoud Rizwan
2024-09-29 18:17:54 -04:00
parent 2ed84244d5
commit 8b243fa536
3 changed files with 421 additions and 726 deletions

View File

@@ -1,7 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import cloneDeep from "clone-deep"
import delay from "delay"
import * as diff from "diff"
import fs from "fs/promises"
import os from "os"
import pWaitFor from "p-wait-for"
@@ -10,7 +9,7 @@ import { serializeError } from "serialize-error"
import * as vscode from "vscode"
import { ApiHandler, buildApiHandler } from "../api"
import { ApiStream } from "../api/transform/stream"
import { diagnosticsToProblemsString, getNewDiagnostics } from "../integrations/diagnostics"
import { DiffViewProvider } from "../integrations/editor/DiffViewProvider"
import { formatContentBlockToMarkdown } from "../integrations/misc/export-markdown"
import { extractTextFromFile } from "../integrations/misc/extract-text"
import { TerminalManager } from "../integrations/terminal/TerminalManager"
@@ -27,7 +26,10 @@ import { getApiMetrics } from "../shared/getApiMetrics"
import { HistoryItem } from "../shared/HistoryItem"
import { ToolName } from "../shared/Tool"
import { ClaudeAskResponse } from "../shared/WebviewMessage"
import { calculateApiCost } from "../utils/cost"
import { fileExistsAtPath } from "../utils/fs"
import { arePathsEqual, getReadablePath } from "../utils/path"
import { parseMentions } from "./mentions"
import {
AssistantMessageContent,
TextContent,
@@ -37,13 +39,10 @@ import {
ToolUseName,
toolUseNames,
} from "./prompts/AssistantMessage"
import { parseMentions } from "./mentions"
import { formatResponse } from "./prompts/responses"
import { addCustomInstructions, SYSTEM_PROMPT } from "./prompts/system"
import { truncateHalfConversation } from "./sliding-window"
import { ClaudeDevProvider, GlobalFileNames } from "./webview/ClaudeDevProvider"
import { calculateApiCost } from "../utils/cost"
import { createDirectoriesForFile, fileExistsAtPath } from "../utils/fs"
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
@@ -70,6 +69,17 @@ export class ClaudeDev {
private consecutiveMistakeCount: number = 0
private providerRef: WeakRef<ClaudeDevProvider>
private abort: boolean = false
private diffViewProvider: DiffViewProvider
// streaming
private currentStreamingContentIndex = 0
private assistantMessageContent: AssistantMessageContent[] = []
private presentAssistantMessageLocked = false
private presentAssistantMessageHasPendingUpdates = false
private userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
private userMessageContentReady = false
private didRejectTool = false
private didCompleteReadingStream = false
constructor(
provider: ClaudeDevProvider,
@@ -84,6 +94,7 @@ export class ClaudeDev {
this.api = buildApiHandler(apiConfiguration)
this.terminalManager = new TerminalManager()
this.urlContentFetcher = new UrlContentFetcher(provider.context)
this.diffViewProvider = new DiffViewProvider(cwd)
this.customInstructions = customInstructions
this.alwaysAllowReadOnly = alwaysAllowReadOnly ?? false
@@ -591,399 +602,6 @@ export class ClaudeDev {
// Tools
// return is [didUserRejectTool, ToolResponse]
async writeToFile(relPath?: string, newContent?: string): Promise<[boolean, ToolResponse]> {
if (relPath === undefined) {
this.consecutiveMistakeCount++
return [false, await this.sayAndCreateMissingParamError("write_to_file", "path")]
}
if (newContent === undefined) {
this.consecutiveMistakeCount++
// Custom error message for this particular case
await this.say(
"error",
`Claude tried to use write_to_file for '${relPath.toPosix()}' without value for required parameter 'content'. This is likely due to reaching the maximum output token limit. Retrying with suggestion to change response size...`
)
return [
false,
formatResponse.toolError(
`Missing value for required parameter 'content'. This may occur if the file is too large, exceeding output limits. Consider splitting into smaller files or reducing content size. Please retry with all required parameters.`
),
]
}
this.consecutiveMistakeCount = 0
try {
const absolutePath = path.resolve(cwd, relPath)
const fileExists = await fileExistsAtPath(absolutePath)
// if the file is already open, ensure it's not dirty before getting its contents
if (fileExists) {
const existingDocument = vscode.workspace.textDocuments.find((doc) =>
arePathsEqual(doc.uri.fsPath, absolutePath)
)
if (existingDocument && existingDocument.isDirty) {
await existingDocument.save()
}
}
// get diagnostics before editing the file, we'll compare to diagnostics after editing to see if claude needs to fix anything
const preDiagnostics = vscode.languages.getDiagnostics()
let originalContent: string
if (fileExists) {
originalContent = await fs.readFile(absolutePath, "utf-8")
// fix issue where claude always removes newline from the file
const eol = originalContent.includes("\r\n") ? "\r\n" : "\n"
if (originalContent.endsWith(eol) && !newContent.endsWith(eol)) {
newContent += eol
}
} else {
originalContent = ""
}
const fileName = path.basename(absolutePath)
// for new files, create any necessary directories and keep track of new directories to delete if the user denies the operation
// Keep track of newly created directories
const createdDirs: string[] = await createDirectoriesForFile(absolutePath)
// console.log(`Created directories: ${createdDirs.join(", ")}`)
// make sure the file exists before we open it
if (!fileExists) {
await fs.writeFile(absolutePath, "")
}
// Open the existing file with the new contents
const updatedDocument = await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath))
// await updatedDocument.save()
// const edit = new vscode.WorkspaceEdit()
// const fullRange = new vscode.Range(
// updatedDocument.positionAt(0),
// updatedDocument.positionAt(updatedDocument.getText().length)
// )
// edit.replace(updatedDocument.uri, fullRange, newContent)
// await vscode.workspace.applyEdit(edit)
// Windows file locking issues can prevent temporary files from being saved or closed properly.
// To avoid these problems, we use in-memory TextDocument objects with the `untitled` scheme.
// This method keeps the document entirely in memory, bypassing the filesystem and ensuring
// a consistent editing experience across all platforms. This also has the added benefit of not
// polluting the user's workspace with temporary files.
// Create an in-memory document for the new content
// const inMemoryDocumentUri = vscode.Uri.parse(`untitled:${fileName}`) // untitled scheme is necessary to open a file without it being saved to disk
// const inMemoryDocument = await vscode.workspace.openTextDocument(inMemoryDocumentUri)
// const edit = new vscode.WorkspaceEdit()
// edit.insert(inMemoryDocumentUri, new vscode.Position(0, 0), newContent)
// await vscode.workspace.applyEdit(edit)
// Show diff
await vscode.commands.executeCommand(
"vscode.diff",
vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({
query: Buffer.from(originalContent).toString("base64"),
}),
updatedDocument.uri,
`${fileName}: ${fileExists ? "Original ↔ Claude's Changes" : "New File"} (Editable)`
)
// if the file was already open, close it (must happen after showing the diff view since if it's the only tab the column will close)
let documentWasOpen = false
// close the tab if it's open
const tabs = vscode.window.tabGroups.all
.map((tg) => tg.tabs)
.flat()
.filter(
(tab) =>
tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, absolutePath)
)
for (const tab of tabs) {
await vscode.window.tabGroups.close(tab)
// console.log(`Closed tab for ${absolutePath}`)
documentWasOpen = true
}
// console.log(`Document was open: ${documentWasOpen}`)
// edit needs to happen after we close the original tab
const edit = new vscode.WorkspaceEdit()
if (!fileExists) {
edit.insert(updatedDocument.uri, new vscode.Position(0, 0), newContent)
} else {
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, newContent)
}
// Apply the edit, but without saving so this doesnt trigger a local save in timeline history
await vscode.workspace.applyEdit(edit) // has the added benefit of maintaing the file's original EOLs
// Find the first range where the content differs and scroll to it
if (fileExists) {
const diffResult = diff.diffLines(originalContent, newContent)
for (let i = 0, lineCount = 0; i < diffResult.length; i++) {
const part = diffResult[i]
if (part.added || part.removed) {
const startLine = lineCount + 1
const endLine = lineCount + (part.count || 0)
const activeEditor = vscode.window.activeTextEditor
if (activeEditor) {
try {
activeEditor.revealRange(
// + 3 to move the editor up slightly as this looks better
new vscode.Range(
new vscode.Position(startLine, 0),
new vscode.Position(
Math.min(endLine + 3, activeEditor.document.lineCount - 1),
0
)
),
vscode.TextEditorRevealType.InCenter
)
} catch (error) {
console.error(`Error revealing range for ${absolutePath}: ${error}`)
}
}
break
}
lineCount += part.count || 0
}
}
// remove cursor from the document
await vscode.commands.executeCommand("workbench.action.focusSideBar")
let userResponse: {
response: ClaudeAskResponse
text?: string
images?: string[]
}
if (fileExists) {
userResponse = await this.ask(
"tool",
JSON.stringify({
tool: "editedExistingFile",
path: getReadablePath(cwd, relPath),
diff: formatResponse.createPrettyPatch(relPath, originalContent, newContent),
} satisfies ClaudeSayTool)
)
} else {
userResponse = await this.ask(
"tool",
JSON.stringify({
tool: "newFileCreated",
path: getReadablePath(cwd, relPath),
content: newContent,
} satisfies ClaudeSayTool)
)
}
const { response, text, images } = userResponse
// const closeInMemoryDocAndDiffViews = async () => {
// // ensure that the in-memory doc is active editor (this seems to fail on windows machines if its already active, so ignoring if there's an error as it's likely it's already active anyways)
// // try {
// // await vscode.window.showTextDocument(inMemoryDocument, {
// // preview: false, // ensures it opens in non-preview tab (preview tabs are easily replaced)
// // preserveFocus: false,
// // })
// // // await vscode.window.showTextDocument(inMemoryDocument.uri, { preview: true, preserveFocus: false })
// // } catch (error) {
// // console.log(`Could not open editor for ${absolutePath}: ${error}`)
// // }
// // await delay(50)
// // // Wait for the in-memory document to become the active editor (sometimes vscode timing issues happen and this would accidentally close claude dev!)
// // await pWaitFor(
// // () => {
// // return vscode.window.activeTextEditor?.document === inMemoryDocument
// // },
// // { timeout: 5000, interval: 50 }
// // )
// // if (vscode.window.activeTextEditor?.document === inMemoryDocument) {
// // await vscode.commands.executeCommand("workbench.action.revertAndCloseActiveEditor") // allows us to close the untitled doc without being prompted to save it
// // }
// await this.closeDiffViews()
// }
if (response !== "yesButtonTapped") {
if (!fileExists) {
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
await this.closeDiffViews()
await fs.unlink(absolutePath)
// Remove only the directories we created, in reverse order
for (let i = createdDirs.length - 1; i >= 0; i--) {
await fs.rmdir(createdDirs[i])
console.log(`Directory ${createdDirs[i]} has been deleted.`)
}
console.log(`File ${absolutePath} has been deleted.`)
} else {
// revert document
const edit = new vscode.WorkspaceEdit()
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, originalContent)
// Apply the edit and save, since contents shouldnt have changed this wont show in local history unless of course the user made changes and saved during the edit
await vscode.workspace.applyEdit(edit)
await updatedDocument.save()
console.log(`File ${absolutePath} has been reverted to its original content.`)
if (documentWasOpen) {
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
}
await this.closeDiffViews()
}
if (response === "messageResponse") {
await this.say("user_feedback", text, images)
return [true, formatResponse.toolResult(formatResponse.toolDeniedWithFeedback(text), images)]
}
return [true, formatResponse.toolDenied()]
}
// Save the changes
const editedContent = updatedDocument.getText()
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
this.didEditFile = true
// Read the potentially edited content from the document
// trigger an entry in the local history for the file
// if (fileExists) {
// await fs.writeFile(absolutePath, originalContent)
// const editor = await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// const edit = new vscode.WorkspaceEdit()
// const fullRange = new vscode.Range(
// editor.document.positionAt(0),
// editor.document.positionAt(editor.document.getText().length)
// )
// edit.replace(editor.document.uri, fullRange, editedContent)
// // Apply the edit, this will trigger a local save and timeline history
// await vscode.workspace.applyEdit(edit) // has the added benefit of maintaing the file's original EOLs
// await editor.document.save()
// }
// if (!fileExists) {
// await fs.mkdir(path.dirname(absolutePath), { recursive: true })
// await fs.writeFile(absolutePath, "")
// }
// await closeInMemoryDocAndDiffViews()
// await fs.writeFile(absolutePath, editedContent)
// open file and add text to it, if it fails fallback to using writeFile
// we try doing it this way since it adds to local history for users to see what's changed in the file's timeline
// try {
// const editor = await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// const edit = new vscode.WorkspaceEdit()
// const fullRange = new vscode.Range(
// editor.document.positionAt(0),
// editor.document.positionAt(editor.document.getText().length)
// )
// edit.replace(editor.document.uri, fullRange, editedContent)
// // Apply the edit, this will trigger a local save and timeline history
// await vscode.workspace.applyEdit(edit) // has the added benefit of maintaing the file's original EOLs
// await editor.document.save()
// } catch (saveError) {
// console.log(`Could not open editor for ${absolutePath}: ${saveError}`)
// await fs.writeFile(absolutePath, editedContent)
// // calling showTextDocument would sometimes fail even though changes were applied, so we'll ignore these one-off errors (likely due to vscode locking issues)
// try {
// await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// } catch (openFileError) {
// console.log(`Could not open editor for ${absolutePath}: ${openFileError}`)
// }
// }
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
await this.closeDiffViews()
/*
Getting diagnostics before and after the file edit is a better approach than
automatically tracking problems in real-time. This method ensures we only
report new problems that are a direct result of this specific edit.
Since these are new problems resulting from Claude's edit, we know they're
directly related to the work he's doing. This eliminates the risk of Claude
going off-task or getting distracted by unrelated issues, which was a problem
with the previous auto-debug approach. Some users' machines may be slow to
update diagnostics, so this approach provides a good balance between automation
and avoiding potential issues where Claude might get stuck in loops due to
outdated problem information. If no new problems show up by the time the user
accepts the changes, they can always debug later using the '@problems' mention.
This way, Claude only becomes aware of new problems resulting from his edits
and can address them accordingly. If problems don't change immediately after
applying a fix, Claude won't be notified, which is generally fine since the
initial fix is usually correct and it may just take time for linters to catch up.
*/
const postDiagnostics = vscode.languages.getDiagnostics()
const newProblems = diagnosticsToProblemsString(
getNewDiagnostics(preDiagnostics, postDiagnostics),
[
vscode.DiagnosticSeverity.Error, // only including errors since warnings can be distracting (if user wants to fix warnings they can use the @problems mention)
],
cwd
) // will be empty string if no errors
const newProblemsMessage =
newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : ""
// await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
// If the edited content has different EOL characters, we don't want to show a diff with all the EOL differences.
const newContentEOL = newContent.includes("\r\n") ? "\r\n" : "\n"
const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL)
const normalizedNewContent = newContent.replace(/\r\n|\n/g, newContentEOL) // just in case the new content has a mix of varying EOL characters
if (normalizedEditedContent !== normalizedNewContent) {
const userDiff = diff.createPatch(relPath.toPosix(), normalizedNewContent, normalizedEditedContent)
await this.say(
"user_feedback_diff",
JSON.stringify({
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cwd, relPath),
diff: formatResponse.createPrettyPatch(relPath, normalizedNewContent, normalizedEditedContent),
} satisfies ClaudeSayTool)
)
return [
false,
`The user made the following updates to your content:\n\n${userDiff}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath.toPosix()}. (Note this does not mean you need to re-write the file with the user's changes, as they have already been applied to the file.)${newProblemsMessage}`,
]
} else {
return [false, `The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}`]
}
} catch (error) {
const errorString = `Error writing file: ${JSON.stringify(serializeError(error))}`
await this.say(
"error",
`Error writing file:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`
)
return [false, formatResponse.toolError(errorString)]
}
}
async closeDiffViews() {
const tabs = vscode.window.tabGroups.all
.map((tg) => tg.tabs)
.flat()
.filter(
(tab) =>
tab.input instanceof vscode.TabInputTextDiff && tab.input?.original?.scheme === "claude-dev-diff"
)
for (const tab of tabs) {
// trying to close dirty views results in save popup
if (!tab.isDirty) {
await vscode.window.tabGroups.close(tab)
}
}
}
async executeCommandTool(
command: string,
returnEmptyStringOnSuccess: boolean = false
@@ -1253,330 +871,83 @@ export class ClaudeDev {
}
// Check if file exists using cached map or fs.access
let fileExists: boolean
if (this.isEditingExistingFile !== undefined) {
fileExists = this.isEditingExistingFile
if (this.diffViewProvider.editType !== undefined) {
fileExists = this.diffViewProvider.editType === "modify"
} else {
const absolutePath = path.resolve(cwd, relPath)
fileExists = await fileExistsAtPath(absolutePath)
this.isEditingExistingFile = fileExists
this.diffViewProvider.editType = fileExists ? "modify" : "create"
}
const sharedMessageProps: ClaudeSayTool = {
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cwd, relPath),
}
try {
const absolutePath = path.resolve(cwd, relPath)
if (block.partial) {
// update gui message
const partialMessage = JSON.stringify(sharedMessageProps)
await this.ask("tool", partialMessage, block.partial).catch(() => {})
if (!this.isEditingFile) {
// open the editor and prepare to stream content in
this.isEditingFile = true
if (fileExists) {
this.editorOriginalContent = await fs.readFile(
path.resolve(cwd, relPath),
"utf-8"
)
} else {
this.editorOriginalContent = ""
}
const fileName = path.basename(absolutePath)
// for new files, create any necessary directories and keep track of new directories to delete if the user denies the operation
// Keep track of newly created directories
this.editFileCreatedDirs = await createDirectoriesForFile(absolutePath)
// console.log(`Created directories: ${createdDirs.join(", ")}`)
// make sure the file exists before we open it
if (!fileExists) {
await fs.writeFile(absolutePath, "")
}
// Open the existing file with the new contents
const updatedDocument = await vscode.workspace.openTextDocument(
vscode.Uri.file(absolutePath)
)
// Show diff
await vscode.commands.executeCommand(
"vscode.diff",
vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({
query: Buffer.from(this.editorOriginalContent).toString("base64"),
}),
updatedDocument.uri,
`${fileName}: ${
fileExists ? "Original ↔ Claude's Changes" : "New File"
} (Editable)`
)
// if the file was already open, close it (must happen after showing the diff view since if it's the only tab the column will close)
this.editFileDocumentWasOpen = false
// close the tab if it's open
const tabs = vscode.window.tabGroups.all
.map((tg) => tg.tabs)
.flat()
.filter(
(tab) =>
tab.input instanceof vscode.TabInputText &&
arePathsEqual(tab.input.uri.fsPath, absolutePath)
)
for (const tab of tabs) {
await vscode.window.tabGroups.close(tab)
this.editFileDocumentWasOpen = true
}
}
// editor is open, stream content in
const updatedDocument = vscode.workspace.textDocuments.find((doc) =>
arePathsEqual(doc.uri.fsPath, absolutePath)
)!
const edit = new vscode.WorkspaceEdit()
if (!fileExists) {
// edit.insert(updatedDocument.uri, new vscode.Position(0, 0), newContent)
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, newContent)
} else {
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, newContent)
}
await vscode.workspace.applyEdit(edit)
// update editor
await this.diffViewProvider.update(relPath, newContent)
break
} else {
// if isEditingFile false, that means we have the full contents of the file already.
// it's important to note how this function works, you can't make the assumption that the block.partial conditional will always be called since it may immediately get complete, non-partial data. So this part of the logic will always be called.
// in other words, you must always repeat the block.partial logic here
if (!this.isEditingFile) {
// open the editor and prepare to stream content in
this.isEditingFile = true
if (fileExists) {
this.editorOriginalContent = await fs.readFile(
path.resolve(cwd, relPath),
"utf-8"
)
} else {
this.editorOriginalContent = ""
}
const fileName = path.basename(absolutePath)
// for new files, create any necessary directories and keep track of new directories to delete if the user denies the operation
// Keep track of newly created directories
this.editFileCreatedDirs = await createDirectoriesForFile(absolutePath)
// console.log(`Created directories: ${createdDirs.join(", ")}`)
// make sure the file exists before we open it
if (!fileExists) {
await fs.writeFile(absolutePath, "")
}
// Open the existing file with the new contents
const updatedDocument = await vscode.workspace.openTextDocument(
vscode.Uri.file(absolutePath)
)
// Show diff
await vscode.commands.executeCommand(
"vscode.diff",
vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({
query: Buffer.from(this.editorOriginalContent).toString("base64"),
}),
updatedDocument.uri,
`${fileName}: ${
fileExists ? "Original ↔ Claude's Changes" : "New File"
} (Editable)`
)
// if the file was already open, close it (must happen after showing the diff view since if it's the only tab the column will close)
this.editFileDocumentWasOpen = false
// close the tab if it's open
const tabs = vscode.window.tabGroups.all
.map((tg) => tg.tabs)
.flat()
.filter(
(tab) =>
tab.input instanceof vscode.TabInputText &&
arePathsEqual(tab.input.uri.fsPath, absolutePath)
)
for (const tab of tabs) {
await vscode.window.tabGroups.close(tab)
this.editFileDocumentWasOpen = true
}
// edit needs to happen after we close the original tab
const edit = new vscode.WorkspaceEdit()
if (!fileExists) {
edit.insert(updatedDocument.uri, new vscode.Position(0, 0), newContent) // newContent is partial
} else {
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, newContent)
}
// Apply the edit, but without saving so this doesnt trigger a local save in timeline history
await vscode.workspace.applyEdit(edit) // has the added benefit of maintaing the file's original EOLs
}
await this.diffViewProvider.update(relPath, newContent)
if (!relPath) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "path"))
// edit is done
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
await this.diffViewProvider.reset()
break
}
if (!newContent) {
this.consecutiveMistakeCount++
pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "content"))
// edit is done
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
await this.diffViewProvider.reset()
break
}
this.consecutiveMistakeCount = 0
// execute tool
const updatedDocument = vscode.workspace.textDocuments.find((doc) =>
arePathsEqual(doc.uri.fsPath, absolutePath)
)!
const originalContent = this.editorOriginalContent!
const createdDirs = this.editFileCreatedDirs
const documentWasOpen = this.editFileDocumentWasOpen
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: fileExists ? undefined : newContent,
diff: fileExists
? formatResponse.createPrettyPatch(relPath, originalContent, newContent)
? formatResponse.createPrettyPatch(
relPath,
this.diffViewProvider.originalContent,
newContent
)
: undefined,
} satisfies ClaudeSayTool)
const didApprove = await askApproval("tool", completeMessage)
if (!didApprove) {
if (!fileExists) {
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
await this.closeDiffViews()
await fs.unlink(absolutePath)
// Remove only the directories we created, in reverse order
for (let i = createdDirs.length - 1; i >= 0; i--) {
await fs.rmdir(createdDirs[i])
console.log(`Directory ${createdDirs[i]} has been deleted.`)
}
console.log(`File ${absolutePath} has been deleted.`)
} else {
// revert document
const edit = new vscode.WorkspaceEdit()
const fullRange = new vscode.Range(
updatedDocument.positionAt(0),
updatedDocument.positionAt(updatedDocument.getText().length)
)
edit.replace(updatedDocument.uri, fullRange, originalContent)
// Apply the edit and save, since contents shouldnt have changed this wont show in local history unless of course the user made changes and saved during the edit
await vscode.workspace.applyEdit(edit)
await updatedDocument.save()
console.log(`File ${absolutePath} has been reverted to its original content.`)
if (documentWasOpen) {
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), {
preview: false,
})
}
await this.closeDiffViews()
}
// edit is done
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
await this.diffViewProvider.revertChanges()
break
}
// Save the changes
const editedContent = updatedDocument.getText()
if (updatedDocument.isDirty) {
await updatedDocument.save()
}
this.didEditFile = true
await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { preview: false })
await this.closeDiffViews()
// If the edited content has different EOL characters, we don't want to show a diff with all the EOL differences.
const newContentEOL = newContent.includes("\r\n") ? "\r\n" : "\n"
const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL)
const normalizedNewContent = newContent.replace(/\r\n|\n/g, newContentEOL) // just in case the new content has a mix of varying EOL characters
if (normalizedEditedContent !== normalizedNewContent) {
const userDiff = diff.createPatch(
relPath.toPosix(),
normalizedNewContent,
normalizedEditedContent
)
const userEdits = 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: formatResponse.createPrettyPatch(
relPath,
normalizedNewContent,
normalizedEditedContent
),
diff: userEdits,
} satisfies ClaudeSayTool)
)
pushToolResult(
`The user made the following updates to your content:\n\n${userDiff}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath.toPosix()}. (Note this does not mean you need to re-write the file with the user's changes, as they have already been applied to the file.)`
`The user made the following updates to your content:\n\n${userEdits}\n\nThe updated content, which includes both your original modifications and the user's additional edits, has been successfully saved to ${relPath.toPosix()}. (Note this does not mean you need to re-write the file with the user's changes, as they have already been applied to the file.)`
)
} else {
pushToolResult(`The content was successfully saved to ${relPath.toPosix()}.`)
}
// edit is done
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
await this.diffViewProvider.reset()
break
}
} catch (error) {
await handleError("writing file", error)
// edit is done
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
await this.diffViewProvider.reset()
break
}
}
@@ -2006,7 +1377,7 @@ export class ClaudeDev {
}
/*
seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
Seeing out of bounds is fine, it means that the next too call is being built up and ready to add to assistantMessageContent to present.
When you see the UI inactive during this, it means that a tool is breaking without presenting any UI. For example the write_to_file tool was breaking when relpath was undefined, and for invalid relpath it never presented UI.
*/
this.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked
@@ -2035,22 +1406,6 @@ export class ClaudeDev {
}
}
// streaming
private currentStreamingContentIndex = 0
private assistantMessageContent: AssistantMessageContent[] = []
private didCompleteReadingStream = false
private userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
private userMessageContentReady = false
private didRejectTool = false
private presentAssistantMessageLocked = false
private presentAssistantMessageHasPendingUpdates = false
//edit
private isEditingExistingFile: boolean | undefined
private isEditingFile = false
private editorOriginalContent: string | undefined
private editFileCreatedDirs: string[] = []
private editFileDocumentWasOpen = false
parseAssistantMessage(assistantMessage: string) {
// let text = ""
let textContent: TextContent = {
@@ -2228,6 +1583,7 @@ export class ClaudeDev {
let outputTokens = 0
let totalCost: number | undefined
// reset streaming state
this.currentStreamingContentIndex = 0
this.assistantMessageContent = []
this.didCompleteReadingStream = false
@@ -2236,13 +1592,7 @@ export class ClaudeDev {
this.didRejectTool = false
this.presentAssistantMessageLocked = false
this.presentAssistantMessageHasPendingUpdates = false
// edit
this.isEditingExistingFile = undefined
this.isEditingFile = false
this.editorOriginalContent = undefined
this.editFileCreatedDirs = []
this.editFileDocumentWasOpen = false
await this.diffViewProvider.reset()
let assistantMessage = ""
// TODO: handle error being thrown in stream