From 496087a8749956e1eab33651364690eb7ea4cae7 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Sun, 12 Apr 2026 20:39:42 +0200 Subject: [PATCH] tools/file: output diff format (context=bare, new=+, changes=unified diff) Co-Authored-By: Claude Sonnet 4.6 --- go.mod | 2 ++ go.sum | 2 ++ pkg/tools/file/diff.go | 25 +++++++++++++++++++++++++ pkg/tools/file/edit.go | 4 ++-- pkg/tools/file/read.go | 6 +++--- pkg/tools/file/write.go | 11 ++++++++++- 6 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 pkg/tools/file/diff.go diff --git a/go.mod b/go.mod index 7f0b627..c0d712d 100644 --- a/go.mod +++ b/go.mod @@ -3,3 +3,5 @@ module ollie go 1.25.6 require gopkg.in/yaml.v3 v3.0.1 + +require github.com/aymanbagabas/go-udiff v0.4.1 diff --git a/go.sum b/go.sum index a62c313..52afe9d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/pkg/tools/file/diff.go b/pkg/tools/file/diff.go new file mode 100644 index 0000000..545124e --- /dev/null +++ b/pkg/tools/file/diff.go @@ -0,0 +1,25 @@ +package file + +import ( + "strings" + + "github.com/aymanbagabas/go-udiff" +) + +// unifiedDiff produces a unified diff of oldContent → newContent for display. +func unifiedDiff(path, oldContent, newContent string) string { + return udiff.Unified(path, path, oldContent, newContent) +} + +// plusLines formats content as diff "added" lines, each prefixed with "+". +// Used by file_write for new files. +func plusLines(content string) string { + lines := strings.Split(strings.TrimRight(content, "\n"), "\n") + sb := strings.Builder{} + for _, l := range lines { + sb.WriteByte('+') + sb.WriteString(l) + sb.WriteByte('\n') + } + return sb.String() +} diff --git a/pkg/tools/file/edit.go b/pkg/tools/file/edit.go index c86a007..51f3286 100644 --- a/pkg/tools/file/edit.go +++ b/pkg/tools/file/edit.go @@ -16,7 +16,7 @@ var ToolEdit = tools.ToolInfo{ Usage: - You must use your ` + "`" + `Read` + "`" + ` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. -- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + "| ". Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the leading "+" prefix. Never include the leading "+" in old_string or new_string. - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. - Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. - The edit will FAIL if ` + "`" + `old_string` + "`" + ` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use ` + "`" + `replace_all` + "`" + ` to change every instance of ` + "`" + `old_string` + "`" + `. @@ -67,7 +67,7 @@ func (s *Server) dispatchEdit(_ context.Context, raw json.RawMessage) (string, e return "", fmt.Errorf("writing file: %w", err) } - return "File edited successfully", nil + return unifiedDiff(a.FilePath, original, newContent), nil } // robustReplace tries exact → whitespace-normalized → indentation-flexible → trimmed-boundary. diff --git a/pkg/tools/file/read.go b/pkg/tools/file/read.go index a0eb175..3219602 100644 --- a/pkg/tools/file/read.go +++ b/pkg/tools/file/read.go @@ -22,7 +22,7 @@ Usage: - By default, it reads up to 2000 lines starting from the beginning of the file - You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters - Any lines longer than 2000 characters will be truncated -- Results are returned with line numbers in the format: line number + "| " + content, starting at 1 +- Results are returned in diff format: existing content lines have no prefix - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool. - You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel. - If you read a file that exists but has empty contents "(empty file)" will be returned.`, @@ -82,7 +82,7 @@ func (s *Server) dispatchRead(_ context.Context, raw json.RawMessage) (string, e if len(line) > maxLineLen { line = line[:maxLineLen] + "..." } - lines = append(lines, fmt.Sprintf("%05d| %s", lineNum, line)) + lines = append(lines, line) } lineNum++ if lineNum > off+lim { @@ -97,7 +97,7 @@ func (s *Server) dispatchRead(_ context.Context, raw json.RawMessage) (string, e } s.markRead(a.FilePath) - return strings.Join(lines, "\n"), nil + return strings.Join(lines, "\n") + "\n", nil } // openChecked validates and opens a regular file within size limits. diff --git a/pkg/tools/file/write.go b/pkg/tools/file/write.go index b1f108c..aed3574 100644 --- a/pkg/tools/file/write.go +++ b/pkg/tools/file/write.go @@ -47,10 +47,19 @@ func (s *Server) dispatchWrite(_ context.Context, raw json.RawMessage) (string, return errText("existing file must be read first. Use the Read tool to examine the file contents."), nil } + var oldContent string + if exists { + oldContent, _ = readFileChecked(a.FilePath) + } + if err := os.WriteFile(a.FilePath, []byte(a.Content), 0644); err != nil { //nolint:gosec return "", fmt.Errorf("writing file: %w", err) } s.markRead(a.FilePath) - return "File written successfully", nil + + if !exists { + return plusLines(a.Content), nil + } + return unifiedDiff(a.FilePath, oldContent, a.Content), nil }