diff --git a/src/core/ClaudeDev.ts b/src/core/ClaudeDev.ts index 44775ed..e3d5d81 100644 --- a/src/core/ClaudeDev.ts +++ b/src/core/ClaudeDev.ts @@ -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 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 diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index 605b304..b465c92 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -76,7 +76,7 @@ export const formatResponse = { } }, - createPrettyPatch: (filename = "file", oldStr: string, newStr: string) => { + createPrettyPatch: (filename = "file", oldStr?: string, newStr?: string) => { // strings cannot be undefined or diff throws exception const patch = diff.createPatch(filename.toPosix(), oldStr || "", newStr || "") const lines = patch.split("\n") diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 4273ad4..2777ffa 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1,51 +1,396 @@ import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" +import { createDirectoriesForFile, fileExistsAtPath } from "../../utils/fs" +import { arePathsEqual } from "../../utils/path" +import { formatResponse } from "../../core/prompts/responses" import * as diff from "diff" -export class DiffViewProvider implements vscode.TextDocumentContentProvider { - private _onDidChange = new vscode.EventEmitter() - onDidChange = this._onDidChange.event +export class DiffViewProvider { + editType?: "create" | "modify" + isEditing = false + // private isEditingExistingFile: boolean | undefined + originalContent: string | undefined + private createdDirs: string[] = [] + private documentWasOpen = false - private originalContent: string = "" - private newContent: string = "" - private fileName: string = "" + private relPath?: string + private newContent?: string - constructor() { - // Register the provider - vscode.workspace.registerTextDocumentContentProvider("claude-dev-diff", this) + constructor(private cwd: string) {} + + async update(relPath: string, newContent: string): Promise { + this.relPath = relPath + this.newContent = newContent + const fileExists = this.editType === "modify" + const absolutePath = path.resolve(this.cwd, relPath) + + if (!this.isEditing) { + // starting edit + // open the editor and prepare to stream content in + + this.isEditing = true + + // 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() + + if (fileExists) { + this.originalContent = await fs.readFile(absolutePath, "utf-8") + // fix issue where claude always removes newline from the file + // const eol = this.originalContent.includes("\r\n") ? "\r\n" : "\n" + // if (this.originalContent.endsWith(eol) && !this.newContent.endsWith(eol)) { + // this.newContent += eol + // } + } else { + this.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 + this.createdDirs = 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(this.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) + this.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) + this.documentWasOpen = true + } + } + + // editor is open, stream content in + + const updatedDocument = vscode.workspace.textDocuments.find((doc) => + arePathsEqual(doc.uri.fsPath, absolutePath) + )! + + // 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) + 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) + } + // 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") + + // 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() + // } } - initialize(fileName: string, originalContent: string) { - this.fileName = fileName - this.originalContent = originalContent - this.newContent = originalContent + // async applyEdit(relPath: string, newContent: string): Promise {} + + async saveChanges() { + if (!this.relPath || !this.newContent) { + return + } + + const absolutePath = path.resolve(this.cwd, this.relPath) + + const updatedDocument = vscode.workspace.textDocuments.find((doc) => + arePathsEqual(doc.uri.fsPath, absolutePath) + )! + + const editedContent = updatedDocument.getText() + if (updatedDocument.isDirty) { + await updatedDocument.save() + } + + // 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 = this.newContent.includes("\r\n") ? "\r\n" : "\n" + const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL) + const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL) // just in case the new content has a mix of varying EOL characters + + if (normalizedEditedContent !== normalizedNewContent) { + // user made changes before approving edit + return formatResponse.createPrettyPatch( + this.relPath.toPosix(), + normalizedNewContent, + normalizedEditedContent + ) + } else { + // no changes to claude's edits + return undefined + } } - updateNewContent(updatedContent: string) { - this.newContent = updatedContent - this._onDidChange.fire(this.getDiffUri()) + async revertChanges(): Promise { + if (!this.relPath) { + return + } + const fileExists = this.editType === "modify" + const updatedDocument = vscode.workspace.textDocuments.find((doc) => + arePathsEqual(doc.uri.fsPath, absolutePath) + )! + const absolutePath = path.resolve(this.cwd, this.relPath) + 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 = this.createdDirs.length - 1; i >= 0; i--) { + await fs.rmdir(this.createdDirs[i]) + console.log(`Directory ${this.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, this.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 (this.documentWasOpen) { + await vscode.window.showTextDocument(vscode.Uri.file(absolutePath), { + preview: false, + }) + } + await this.closeDiffViews() + } + + // edit is done + this.reset() } - provideTextDocumentContent(uri: vscode.Uri): string { - return this.createDiffContent() + 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) + } + } } - private createDiffContent(): string { - const diffResult = diff.createPatch(this.fileName, this.originalContent, this.newContent) - return diffResult + // close editor if open? + async reset() { + this.editType = undefined + this.isEditing = false + this.originalContent = undefined + this.createdDirs = [] + this.documentWasOpen = false } - getDiffUri(): vscode.Uri { - return vscode.Uri.parse(`claude-dev-diff:${this.fileName}`).with({ - query: Buffer.from(this.originalContent).toString("base64"), - }) - } - - async showDiff() { - await vscode.commands.executeCommand( - "vscode.diff", - this.getDiffUri(), - vscode.Uri.file(this.fileName), - `${this.fileName}: Original ↔ Claude's Changes (Editable)` - ) - } + // ... (other helper methods like showDiffView, closeExistingTab, deleteNewFile, revertExistingFile, etc.) }