# Diff View for edit/write Tools — Design **Status:** Approved, pending implementation plan **Date:** 2026-07-15 **Scope:** `src/tool/fs/edit.rs`, `src/tool/fs/write.rs`, `src/view/markdown.rs`, `src/view/chat.rs` ## Context `edit` currently reports only a byte-delta (`"edited {rel} ({N} byte delta)"`), and `write` reports only a byte count. Neither the model nor the user sees what actually changed — just a number. This makes it hard for the model to self-verify an edit landed correctly, and hard for the user to review a change without opening the file. No diff-computing library exists in the dependency tree today. ## Goals - `edit` returns a real unified diff (git-style, 3 lines of context) of the change it just made, in place of the byte-delta note. - `write` returns the same kind of diff when it overwrites a file that already existed with valid UTF-8 content; falls back to the current "wrote N bytes" message for new files or non-UTF-8 (binary) overwrites. - Diffs render in the chat view with real color (green add / red remove / cyan hunk header) instead of being flattened to dim/italic like other tool output. - Large diffs are truncated with a trailing count, matching the existing pattern in `read.rs` (`"... ({N} more lines, total {total})"`). ## Non-goals - No diff view for any tool besides `edit`/`write` (e.g. no retroactive diffing of `bash_tools.rs` shell edits). - No side-by-side diff layout — unified format only, matching how every other tool output already renders as a single text stream. - No persistence of diff history; each diff is only the delta of the single tool call that produced it, not a cumulative session diff. - No changes to non-tool (assistant/user/system) message rendering or coloring. ## Dependency Add `similar = "3"` (line/word diff crate; permissive MIT/Apache-2.0, no heavy transitive deps). Use `TextDiff::from_lines(old, new).unified_diff().context_radius(3)`, which produces standard `@@ -a,b +c,d @@` hunk headers and `-`/`+`/` `-prefixed lines — no custom diff algorithm needed. ## Tool changes ### `edit.rs` After computing `new_content` and writing it to disk: 1. Compute `similar::TextDiff::from_lines(&content, &new_content).unified_diff().context_radius(3).to_string()`. 2. Split into lines; if `> MAX_DIFF_LINES` (200), keep the first 200 and append `"... ({N} more lines truncated)"`. 3. Wrap the (possibly truncated) diff text in a fenced ` ```diff ` block. 4. Replace the byte-delta note in the returned message with this block; keep the existing "Graduated checks matched" / LSP note suffixes in their current position (after the diff block). ### `write.rs` Before overwriting: 1. If `path.exists()` and `fs::read_to_string(&path)` succeeds (valid UTF-8), capture it as `old_content` and note `is_overwrite = true`. 2. If the file doesn't exist, or reading it fails (binary/non-UTF-8), `is_overwrite = false` — no error, just skip the diff path silently. 3. After writing, if `is_overwrite`, compute and truncate the diff exactly as in `edit.rs` and append the fenced block to the return message (in addition to the existing "wrote N bytes" line, not instead of it — for `write`, unlike `edit`, the byte count is still useful since it can be a full-file rewrite). 4. If not `is_overwrite`, return message is unchanged from today. The truncation constant (`MAX_DIFF_LINES = 200`) and truncation message format are shared — factor into a small helper in `tool/fs/helpers.rs` used by both tools. ## Rendering changes ### `markdown.rs` - `render_markdown` gains a `dim: bool` parameter: `render_markdown(text, width, dim)`. - Capture the fence language from `Tag::CodeBlock(CodeBlockKind::Fenced(lang))` (today matched as `CodeBlock(_)`, discarding the language). Track `in_diff_block: bool` when `lang == "diff"`. - Inside a diff block, process text line-by-line instead of as one blob: a line starting with `+` (not `+++`) is styled green, `-` (not `---`) red, `@@` cyan/muted, everything else (context lines, `+++`/`---` file headers) uses the existing code-block teal. - When `dim` is `true`: every span keeps its assigned color as computed above, but non-diff spans (headings, links, plain text, non-diff code blocks, table cells) fall back to `Theme::TEXT_DIM` + `Modifier::ITALIC` instead of their normal palette color — this replicates today's "tool output is always dim" behavior for everything except diff lines. - When `dim` is `false`: behavior is unchanged from today (full color, used for assistant/user/system messages). ### `chat.rs` - `Role::Tool` branch: replace the two manual span-remapping loops (that force every span to `dim_italic`) with a direct call to `render_markdown(&content, content_width, true)` and use the returned spans as-is. - All other roles: call `render_markdown(&content_str, content_width, false)` — same call as today, just with the new explicit `false` argument. ## Testing Inline `#[cfg(test)] mod tests` per CLAUDE.md convention: - `edit.rs`: a normal single-replace edit produces a diff block with matching `-`/`+` lines; a `replace_all` across 250+ lines truncates at 200 with the correct trailing count. - `write.rs`: writing a brand-new file keeps the old "wrote N bytes" message with no diff block; overwriting an existing UTF-8 file produces a diff block; overwriting a path that reads as invalid UTF-8 (simulate via non-UTF-8 bytes) falls back to the byte-count message without erroring. - `markdown.rs`: a fenced ` ```diff ` block with `+`/`-`/`@@` lines produces spans with the expected fg colors under `dim=true` (diff lines colored) and confirms non-diff text in the same call falls back to `TEXT_DIM` + italic.