5.6 KiB
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
editreturns a real unified diff (git-style, 3 lines of context) of the change it just made, in place of the byte-delta note.writereturns 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 ofbash_tools.rsshell 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:
- Compute
similar::TextDiff::from_lines(&content, &new_content).unified_diff().context_radius(3).to_string(). - Split into lines; if
> MAX_DIFF_LINES(200), keep the first 200 and append"... ({N} more lines truncated)". - Wrap the (possibly truncated) diff text in a fenced
```diffblock. - 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:
- If
path.exists()andfs::read_to_string(&path)succeeds (valid UTF-8), capture it asold_contentand noteis_overwrite = true. - 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. - After writing, if
is_overwrite, compute and truncate the diff exactly as inedit.rsand append the fenced block to the return message (in addition to the existing "wrote N bytes" line, not instead of it — forwrite, unlikeedit, the byte count is still useful since it can be a full-file rewrite). - 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_markdowngains adim: boolparameter:render_markdown(text, width, dim).- Capture the fence language from
Tag::CodeBlock(CodeBlockKind::Fenced(lang))(today matched asCodeBlock(_), discarding the language). Trackin_diff_block: boolwhenlang == "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
dimistrue: 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 toTheme::TEXT_DIM+Modifier::ITALICinstead of their normal palette color — this replicates today's "tool output is always dim" behavior for everything except diff lines. - When
dimisfalse: behavior is unchanged from today (full color, used for assistant/user/system messages).
chat.rs
Role::Toolbranch: replace the two manual span-remapping loops (that force every span todim_italic) with a direct call torender_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 explicitfalseargument.
Testing
Inline #[cfg(test)] mod tests per CLAUDE.md convention:
edit.rs: a normal single-replace edit produces a diff block with matching-/+lines; areplace_allacross 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```diffblock with+/-/@@lines produces spans with the expected fg colors underdim=true(diff lines colored) and confirms non-diff text in the same call falls back toTEXT_DIM+ italic.