Files
zesdex/docs/superpowers/specs/2026-07-15-diff-view-design.md
T

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

  • 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.