fix(tool): fix `edit` and `multi-edit` tools on windows

Andrey Nering and theguy000 created

We should convert the content to LF to apply the patch while still
respecting the original line endings when saving the file on disk.

Co-authored-by: theguy000 <istiakm30@gmail.com>

Change summary

internal/fsext/fileutil.go      | 16 ++++++++++++++++
internal/llm/tools/edit.go      | 13 +++++++++++--
internal/llm/tools/multiedit.go |  6 +++++-
3 files changed, 32 insertions(+), 3 deletions(-)

Detailed changes

internal/fsext/fileutil.go 🔗

@@ -233,3 +233,19 @@ func HasPrefix(path, prefix string) bool {
 	// If path is within prefix, Rel will not return a path starting with ".."
 	return !strings.HasPrefix(rel, "..")
 }
+
+// ToUnixLineEndings converts Windows line endings (CRLF) to Unix line endings (LF).
+func ToUnixLineEndings(content string) (string, bool) {
+	if strings.Contains(content, "\r\n") {
+		return strings.ReplaceAll(content, "\r\n", "\n"), true
+	}
+	return content, false
+}
+
+// ToWindowsLineEndings converts Unix line endings (LF) to Windows line endings (CRLF).
+func ToWindowsLineEndings(content string) (string, bool) {
+	if !strings.Contains(content, "\r\n") {
+		return strings.ReplaceAll(content, "\n", "\r\n"), true
+	}
+	return content, false
+}

internal/llm/tools/edit.go 🔗

@@ -99,6 +99,7 @@ WINDOWS NOTES:
 - File paths should use forward slashes (/) for cross-platform compatibility
 - On Windows, absolute paths start with drive letters (C:/) but forward slashes work throughout
 - File permissions are handled automatically by the Go runtime
+- Always assumes \n for line endings. The tool will handle \r\n conversion automatically if needed.
 
 Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`
 )
@@ -299,7 +300,7 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
 		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
 	}
 
-	oldContent := string(content)
+	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
 
 	var newContent string
 	var deletionCount int
@@ -356,6 +357,10 @@ func (e *editTool) deleteContent(ctx context.Context, filePath, oldString string
 		return ToolResponse{}, permission.ErrorPermissionDenied
 	}
 
+	if isCrlf {
+		newContent, _ = fsext.ToWindowsLineEndings(newContent)
+	}
+
 	err = os.WriteFile(filePath, []byte(newContent), 0o644)
 	if err != nil {
 		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
@@ -428,7 +433,7 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
 		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
 	}
 
-	oldContent := string(content)
+	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
 
 	var newContent string
 	var replacementCount int
@@ -487,6 +492,10 @@ func (e *editTool) replaceContent(ctx context.Context, filePath, oldString, newS
 		return ToolResponse{}, permission.ErrorPermissionDenied
 	}
 
+	if isCrlf {
+		newContent, _ = fsext.ToWindowsLineEndings(newContent)
+	}
+
 	err = os.WriteFile(filePath, []byte(newContent), 0o644)
 	if err != nil {
 		return ToolResponse{}, fmt.Errorf("failed to write file: %w", err)

internal/llm/tools/multiedit.go 🔗

@@ -335,7 +335,7 @@ func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params
 		return ToolResponse{}, fmt.Errorf("failed to read file: %w", err)
 	}
 
-	oldContent := string(content)
+	oldContent, isCrlf := fsext.ToUnixLineEndings(string(content))
 	currentContent := oldContent
 
 	// Apply all edits sequentially
@@ -377,6 +377,10 @@ func (m *multiEditTool) processMultiEditExistingFile(ctx context.Context, params
 		return ToolResponse{}, permission.ErrorPermissionDenied
 	}
 
+	if isCrlf {
+		currentContent, _ = fsext.ToWindowsLineEndings(currentContent)
+	}
+
 	// Write the updated content
 	err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
 	if err != nil {