Browse Source

Add text decorations based edit streaming

Saoud Rizwan 1 year ago
parent
commit
c2a2e1b54c

+ 16 - 11
src/core/ClaudeDev.ts

@@ -893,16 +893,9 @@ export class ClaudeDev {
 									await this.diffViewProvider.open(relPath)
 								}
 								// editor is open, stream content in
-								await this.diffViewProvider.update(newContent)
+								await this.diffViewProvider.update(newContent, false)
 								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.diffViewProvider.isEditing) {
-									await this.diffViewProvider.open(relPath)
-								}
-								await this.diffViewProvider.update(newContent)
 								if (!relPath) {
 									this.consecutiveMistakeCount++
 									pushToolResult(await this.sayAndCreateMissingParamError("write_to_file", "path"))
@@ -917,6 +910,16 @@ export class ClaudeDev {
 								}
 								this.consecutiveMistakeCount = 0
 
+								// 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.diffViewProvider.isEditing) {
+									await this.diffViewProvider.open(relPath)
+								}
+								await this.diffViewProvider.update(newContent, true)
+								await delay(300) // wait for diff view to update
+								this.diffViewProvider.scrollToFirstDiff()
+
 								const completeMessage = JSON.stringify({
 									...sharedMessageProps,
 									content: fileExists ? undefined : newContent,
@@ -933,7 +936,7 @@ export class ClaudeDev {
 									await this.diffViewProvider.revertChanges()
 									break
 								}
-								const userEdits = await this.diffViewProvider.saveChanges()
+								const { newProblemsMessage, 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(
@@ -945,10 +948,12 @@ export class ClaudeDev {
 										} satisfies ClaudeSayTool)
 									)
 									pushToolResult(
-										`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.)`
+										`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.)${newProblemsMessage}`
 									)
 								} else {
-									pushToolResult(`The content was successfully saved to ${relPath.toPosix()}.`)
+									pushToolResult(
+										`The content was successfully saved to ${relPath.toPosix()}.${newProblemsMessage}`
+									)
 								}
 								await this.diffViewProvider.reset()
 								break

+ 2 - 2
src/core/prompts/system.ts

@@ -89,13 +89,13 @@ Remember:
 - Formulate your tool use using the XML format specified for each tool.
 - After using a tool, you will receive the tool use result in the user's next message. This result will provide you with the necessary information to continue your task or make further decisions.
 
-CRITICAL RULE: You must use only one tool at a time. Multiple tool uses in a single message are strictly prohibited.
+CRITICAL RULE: You are only allowed to use one tool per message. Multiple tool uses in a single message is STRICTLY FORBIDDEN. Even if it may seem efficient to use multiple tools at once, the system will crash and burn if you do so.
 
 To ensure compliance:
 
 1. After each tool use, wait for the result before proceeding.
 2. Analyze the result of each tool use before deciding on the next step.
-3. If multiple actions are needed, break them into separate, sequential steps, each using a single tool.
+3. If multiple actions are needed, break them into separate, sequential messages, each using a single tool.
 
 Remember: *One tool use per message. No exceptions.*
 

+ 81 - 0
src/integrations/editor/DecorationController.ts

@@ -0,0 +1,81 @@
+import * as vscode from "vscode"
+
+const fadedOverlayDecorationType = vscode.window.createTextEditorDecorationType({
+	backgroundColor: "rgba(255, 255, 0, 0.1)",
+	opacity: "0.4",
+	isWholeLine: true,
+})
+
+const activeLineDecorationType = vscode.window.createTextEditorDecorationType({
+	backgroundColor: "rgba(255, 255, 0, 0.3)",
+	opacity: "1",
+	isWholeLine: true,
+	border: "1px solid rgba(255, 255, 0, 0.5)",
+})
+
+type DecorationType = "fadedOverlay" | "activeLine"
+
+export class DecorationController {
+	private decorationType: DecorationType
+	private editor: vscode.TextEditor
+	private ranges: vscode.Range[] = []
+
+	constructor(decorationType: DecorationType, editor: vscode.TextEditor) {
+		this.decorationType = decorationType
+		this.editor = editor
+	}
+
+	getDecoration() {
+		switch (this.decorationType) {
+			case "fadedOverlay":
+				return fadedOverlayDecorationType
+			case "activeLine":
+				return activeLineDecorationType
+		}
+	}
+
+	addLines(startIndex: number, numLines: number) {
+		// Guard against invalid inputs
+		if (startIndex < 0 || numLines <= 0) {
+			return
+		}
+
+		const lastRange = this.ranges[this.ranges.length - 1]
+		if (lastRange && lastRange.end.line === startIndex - 1) {
+			this.ranges[this.ranges.length - 1] = lastRange.with(undefined, lastRange.end.translate(numLines))
+		} else {
+			const endLine = startIndex + numLines - 1
+			this.ranges.push(new vscode.Range(startIndex, 0, endLine, Number.MAX_SAFE_INTEGER))
+		}
+
+		this.editor.setDecorations(this.getDecoration(), this.ranges)
+	}
+
+	clear() {
+		this.ranges = []
+		this.editor.setDecorations(this.getDecoration(), this.ranges)
+	}
+
+	updateOverlayAfterLine(line: number, totalLines: number) {
+		// Remove any existing ranges that start at or after the current line
+		this.ranges = this.ranges.filter((range) => range.end.line < line)
+
+		// Add a new range for all lines after the current line
+		if (line < totalLines - 1) {
+			this.ranges.push(
+				new vscode.Range(
+					new vscode.Position(line + 1, 0),
+					new vscode.Position(totalLines - 1, Number.MAX_SAFE_INTEGER)
+				)
+			)
+		}
+
+		// Apply the updated decorations
+		this.editor.setDecorations(this.getDecoration(), this.ranges)
+	}
+
+	setActiveLine(line: number) {
+		this.ranges = [new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER)]
+		this.editor.setDecorations(this.getDecoration(), this.ranges)
+	}
+}

+ 193 - 241
src/integrations/editor/DiffViewProvider.ts

@@ -4,6 +4,9 @@ import * as fs from "fs/promises"
 import { createDirectoriesForFile } from "../../utils/fs"
 import { arePathsEqual } from "../../utils/path"
 import { formatResponse } from "../../core/prompts/responses"
+import { DecorationController } from "./DecorationController"
+import * as diff from "diff"
+import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics"
 
 export class DiffViewProvider {
 	editType?: "create" | "modify"
@@ -13,6 +16,11 @@ export class DiffViewProvider {
 	private documentWasOpen = false
 	private relPath?: string
 	private newContent?: string
+	private activeDiffEditor?: vscode.TextEditor
+	private fadedOverlayController?: DecorationController
+	private activeLineController?: DecorationController
+	private streamedLines: string[] = []
+	private preDiagnostics: [vscode.Uri, vscode.Diagnostic[]][] = []
 
 	constructor(private cwd: string) {}
 
@@ -20,9 +28,7 @@ export class DiffViewProvider {
 		this.relPath = relPath
 		const fileExists = this.editType === "modify"
 		const absolutePath = path.resolve(this.cwd, relPath)
-
 		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) =>
@@ -34,69 +40,22 @@ export class DiffViewProvider {
 		}
 
 		// 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()
+		this.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
+		// close the tab if it's open (it's already saved above)
 		const tabs = vscode.window.tabGroups.all
 			.map((tg) => tg.tabs)
 			.flat()
@@ -104,229 +63,145 @@ export class DiffViewProvider {
 				(tab) => tab.input instanceof vscode.TabInputText && arePathsEqual(tab.input.uri.fsPath, absolutePath)
 			)
 		for (const tab of tabs) {
-			await vscode.window.tabGroups.close(tab)
+			if (!tab.isDirty) {
+				await vscode.window.tabGroups.close(tab)
+			}
 			this.documentWasOpen = true
 		}
+		this.activeDiffEditor = await this.openDiffEditor()
+		this.fadedOverlayController = new DecorationController("fadedOverlay", this.activeDiffEditor)
+		this.activeLineController = new DecorationController("activeLine", this.activeDiffEditor)
+		// Apply faded overlay to all lines initially
+		this.fadedOverlayController.addLines(0, this.activeDiffEditor.document.lineCount)
+		this.scrollEditorToLine(0) // will this crash for new files?
+		this.streamedLines = []
 	}
 
-	async update(newContent: string): Promise<void> {
-		if (!this.relPath) {
+	async update(accumulatedContent: string, isFinal: boolean) {
+		if (!this.relPath || !this.activeLineController || !this.fadedOverlayController) {
+			throw new Error("Required values not set")
+		}
+		this.newContent = accumulatedContent
+		const accumulatedLines = accumulatedContent.split("\n")
+		if (!isFinal) {
+			accumulatedLines.pop() // remove the last partial line only if it's not the final update
+		}
+		const diffLines = accumulatedLines.slice(this.streamedLines.length)
+		const document = vscode.window.activeTextEditor?.document
+		if (!document) {
+			console.error("No active text editor")
 			return
 		}
-		this.newContent = newContent
-
-		const fileExists = this.editType === "modify"
-		const absolutePath = path.resolve(this.cwd, this.relPath)
-
-		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, this.newContent)
-		} else {
-			const fullRange = new vscode.Range(
-				updatedDocument.positionAt(0),
-				updatedDocument.positionAt(updatedDocument.getText().length)
-			)
-			edit.replace(updatedDocument.uri, fullRange, this.newContent)
+		const diffViewEditor = vscode.window.activeTextEditor
+		if (!diffViewEditor) {
+			console.error("No active diff view editor")
+			return
+		}
+		for (let i = 0; i < diffLines.length; i++) {
+			const currentLine = this.streamedLines.length + i
+			// Replace all content up to the current line with accumulated lines
+			// This is necessary (as compared to inserting one line at a time) to handle cases where html tags on previous lines are auto closed for example
+			const edit = new vscode.WorkspaceEdit()
+			const rangeToReplace = new vscode.Range(0, 0, currentLine + 1, 0)
+			const contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n") + "\n"
+			edit.replace(document.uri, rangeToReplace, contentToReplace)
+			await vscode.workspace.applyEdit(edit)
+			// Update decorations
+			this.activeLineController.setActiveLine(currentLine)
+			this.fadedOverlayController.updateOverlayAfterLine(currentLine, document.lineCount)
+			// Scroll to the current line
+			this.scrollEditorToLine(currentLine)
+		}
+		// Update the streamedLines with the new accumulated content
+		this.streamedLines = accumulatedLines
+		if (isFinal) {
+			// Handle any remaining lines if the new content is shorter than the original
+			if (this.streamedLines.length < document.lineCount) {
+				const edit = new vscode.WorkspaceEdit()
+				edit.delete(document.uri, new vscode.Range(this.streamedLines.length, 0, document.lineCount, 0))
+				await vscode.workspace.applyEdit(edit)
+			}
+			// Add empty last line if original content had one
+			const hasEmptyLastLine = this.originalContent?.endsWith("\n")
+			if (hasEmptyLastLine) {
+				const accumulatedLines = accumulatedContent.split("\n")
+				if (accumulatedLines[accumulatedLines.length - 1] !== "") {
+					accumulatedContent += "\n"
+				}
+			}
+			// Clear all decorations at the end (before applying final edit)
+			this.fadedOverlayController.clear()
+			this.activeLineController.clear()
 		}
-		// 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()
-		// }
 	}
 
-	// async applyEdit(relPath: string, newContent: string): Promise<void> {}
-
-	async saveChanges() {
-		if (!this.relPath || !this.newContent) {
-			return
+	async saveChanges(): Promise<{ newProblemsMessage: string | undefined; userEdits: string | undefined }> {
+		if (!this.relPath || !this.newContent || !this.activeDiffEditor) {
+			return { newProblemsMessage: undefined, userEdits: undefined }
 		}
-
 		const absolutePath = path.resolve(this.cwd, this.relPath)
-
-		const updatedDocument = vscode.workspace.textDocuments.find((doc) =>
-			arePathsEqual(doc.uri.fsPath, absolutePath)
-		)!
-
+		const updatedDocument = this.activeDiffEditor.document
 		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.closeAllDiffViews()
 
 		/*
-			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 })
+		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(this.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)
+			],
+			this.cwd
+		) // will be empty string if no errors
+		const newProblemsMessage =
+			newProblems.length > 0 ? `\n\nNew problems detected after saving the file:\n${newProblems}` : ""
 
 		// 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
-
+		const normalizedEditedContent = editedContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL // trimEnd to fix issue where editor adds in extra new line automatically
+		// just in case the new content has a mix of varying EOL characters
+		const normalizedNewContent = this.newContent.replace(/\r\n|\n/g, newContentEOL).trimEnd() + newContentEOL
 		if (normalizedEditedContent !== normalizedNewContent) {
 			// user made changes before approving edit
-			return formatResponse.createPrettyPatch(
+			const userEdits = formatResponse.createPrettyPatch(
 				this.relPath.toPosix(),
 				normalizedNewContent,
 				normalizedEditedContent
 			)
+			return { newProblemsMessage, userEdits }
 		} else {
 			// no changes to claude's edits
-			return undefined
+			return { newProblemsMessage, userEdits: undefined }
 		}
 	}
 
 	async revertChanges(): Promise<void> {
-		if (!this.relPath) {
+		if (!this.relPath || !this.activeDiffEditor) {
 			return
 		}
 		const fileExists = this.editType === "modify"
-		const updatedDocument = vscode.workspace.textDocuments.find((doc) =>
-			arePathsEqual(doc.uri.fsPath, absolutePath)
-		)!
+		const updatedDocument = this.activeDiffEditor.document
 		const absolutePath = path.resolve(this.cwd, this.relPath)
 		if (!fileExists) {
 			if (updatedDocument.isDirty) {
@@ -366,13 +241,11 @@ export class DiffViewProvider {
 
 	private async closeAllDiffViews() {
 		const tabs = vscode.window.tabGroups.all
-			.map((tg) => tg.tabs)
-			.flat()
+			.flatMap((tg) => tg.tabs)
 			.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) {
@@ -381,6 +254,82 @@ export class DiffViewProvider {
 		}
 	}
 
+	private async openDiffEditor(): Promise<vscode.TextEditor> {
+		if (!this.relPath) {
+			throw new Error("No file path set")
+		}
+		const uri = vscode.Uri.file(path.resolve(this.cwd, this.relPath))
+		// If this diff editor is already open (ie if a previous write file was interrupted) then we should activate that instead of opening a new diff
+		const diffTab = vscode.window.tabGroups.all
+			.flatMap((group) => group.tabs)
+			.find(
+				(tab) =>
+					tab.input instanceof vscode.TabInputTextDiff &&
+					tab.input?.original?.scheme === "claude-dev-diff" &&
+					arePathsEqual(tab.input.modified.fsPath, uri.fsPath)
+			)
+		if (diffTab && diffTab.input instanceof vscode.TabInputTextDiff) {
+			const editor = await vscode.window.showTextDocument(diffTab.input.modified)
+			return editor
+		}
+		// Open new diff editor
+		return new Promise<vscode.TextEditor>((resolve, reject) => {
+			const fileName = path.basename(uri.fsPath)
+			const fileExists = this.editType === "modify"
+			const disposable = vscode.window.onDidChangeActiveTextEditor((editor) => {
+				if (editor && arePathsEqual(editor.document.uri.fsPath, uri.fsPath)) {
+					disposable.dispose()
+					resolve(editor)
+				}
+			})
+			vscode.commands.executeCommand(
+				"vscode.diff",
+				vscode.Uri.parse(`claude-dev-diff:${fileName}`).with({
+					query: Buffer.from(this.originalContent ?? "").toString("base64"),
+				}),
+				uri,
+				`${fileName}: ${fileExists ? "Original ↔ Claude's Changes" : "New File"} (Editable)`
+			)
+			// This should never happen but if it does it's worth investigating
+			setTimeout(() => {
+				disposable.dispose()
+				reject(new Error("Failed to open diff editor"))
+			}, 5_000)
+		})
+	}
+
+	private scrollEditorToLine(line: number) {
+		if (this.activeDiffEditor) {
+			const scrollLine = line + 4
+			this.activeDiffEditor.revealRange(
+				new vscode.Range(scrollLine, 0, scrollLine, 0),
+				vscode.TextEditorRevealType.InCenter
+			)
+		}
+	}
+
+	scrollToFirstDiff() {
+		if (!this.activeDiffEditor) {
+			return
+		}
+		const currentContent = this.activeDiffEditor.document.getText()
+		const diffs = diff.diffLines(this.originalContent || "", currentContent)
+		let lineCount = 0
+		for (const part of diffs) {
+			if (part.added || part.removed) {
+				// Found the first diff, scroll to it
+				this.activeDiffEditor.revealRange(
+					new vscode.Range(lineCount, 0, lineCount, 0),
+					vscode.TextEditorRevealType.InCenter
+				)
+				return
+			}
+			if (!part.removed) {
+				lineCount += part.count || 0
+			}
+		}
+	}
+
 	// close editor if open?
 	async reset() {
 		this.editType = undefined
@@ -388,7 +337,10 @@ export class DiffViewProvider {
 		this.originalContent = undefined
 		this.createdDirs = []
 		this.documentWasOpen = false
+		this.activeDiffEditor = undefined
+		this.fadedOverlayController = undefined
+		this.activeLineController = undefined
+		this.streamedLines = []
+		this.preDiagnostics = []
 	}
-
-	// ... (other helper methods like showDiffView, closeExistingTab, deleteNewFile, revertExistingFile, etc.)
 }