feat(diff): implement unified diff formatting for edit_file tool results

This commit is contained in:
X-iZhang
2026-03-09 22:18:54 +00:00
parent 4c53bb0480
commit aad431785c
5 changed files with 519 additions and 4 deletions
+38 -1
View File
@@ -8,6 +8,7 @@ from textual.containers import Vertical
from textual.events import Click
from textual.widgets import Static
from ...stream.diff_format import build_edit_diff
from ...stream.utils import format_tool_compact
_SPINNER_FRAMES = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"
@@ -65,6 +66,7 @@ class ToolCallWidget(Vertical):
self._tool_id = tool_id
self._status = "running"
self._result_content = ""
self._diff_markup: str | None = None # cached full diff for toggle
self._frame = 0
self._elapsed = 0.0
self._timer_handle = None
@@ -186,6 +188,27 @@ class ToolCallWidget(Vertical):
output_w = self.query_one(".tool-output", Static)
if not self._result_content.strip():
return
# Diff rendering for edit_file (never truncates — collapses instead)
if self._tool_name == "edit_file" and self._status == "success" and self._tool_args:
old_str = self._tool_args.get("old_string", "")
new_str = self._tool_args.get("new_string", "")
path = self._tool_args.get("path", self._tool_args.get("file_path", ""))
if old_str and new_str and old_str != new_str:
diff_markup = build_edit_diff(path, old_str, new_str)
if diff_markup:
self._diff_markup = diff_markup
if diff_markup.count("\n") > _COLLAPSE_LINES:
# Long diff: collapse, click to expand
self._collapsed = True
output_w.update(
Text(" [click to expand diff]", style="dim italic"),
)
else:
# Short diff: show inline
self._collapsed = False
output_w.update(diff_markup)
output_w.add_class("--visible")
return
if self._status == "error" or not self._should_collapse():
# Show full output for errors or short output
self._collapsed = False
@@ -207,9 +230,23 @@ class ToolCallWidget(Vertical):
"""Toggle collapsed output on click."""
if self._status == "running" or not self._result_content.strip():
return
output_w = self.query_one(".tool-output", Static)
# Diff toggle (separate path — _should_collapse checks plain text,
# not the diff markup, so we handle it independently)
if self._diff_markup is not None:
if self._collapsed:
self._collapsed = False
output_w.update(self._diff_markup)
else:
self._collapsed = True
output_w.update(
Text(" [click to expand diff]", style="dim italic"),
)
return
if not self._should_collapse():
return # Short output is always visible, nothing to toggle
output_w = self.query_one(".tool-output", Static)
if self._collapsed:
# Expand
self._collapsed = False
+4
View File
@@ -30,6 +30,7 @@ from .utils import (
)
from .state import SubAgentState, StreamState, _parse_todo_items, _build_todo_stats
from .events import stream_agent_events
from .diff_format import build_edit_diff, format_diff_rich
from .display import (
console,
formatter,
@@ -70,6 +71,9 @@ __all__ = [
"_build_todo_stats",
# Events
"stream_agent_events",
# Diff formatting
"build_edit_diff",
"format_diff_rich",
# Display
"console",
"formatter",
+207
View File
@@ -0,0 +1,207 @@
"""Diff formatting for edit_file tool results.
Constructs unified diffs from old_string/new_string tool args and renders
them with Rich markup (color-coded lines, line numbers, gutter bars).
Works in both Rich CLI and Textual TUI (both render Rich markup natively).
Ported from upstream deepagents-cli widgets/diff.py + config.py.
"""
from __future__ import annotations
import difflib
import os
import re
import sys
# ---------------------------------------------------------------------------
# Charset detection (simplified from upstream config.py)
# ---------------------------------------------------------------------------
def _detect_unicode_support() -> bool:
"""Check if the terminal supports Unicode glyphs."""
encoding = getattr(sys.stdout, "encoding", "") or ""
if "utf" in encoding.lower():
return True
lang = os.environ.get("LANG", "") or os.environ.get("LC_ALL", "")
return "utf" in lang.lower()
# Module-level glyph constants
_UNICODE = _detect_unicode_support()
GUTTER_BAR = "\u258c" if _UNICODE else "|" # ▌ or |
BOX_VERTICAL = "\u2502" if _UNICODE else "|" # │ or |
BOX_DOUBLE_HORIZ = "\u2550" if _UNICODE else "=" # ═ or =
# ---------------------------------------------------------------------------
# Markup escaping
# ---------------------------------------------------------------------------
def _escape_markup(text: str) -> str:
"""Escape Rich markup characters in text.
Prevents ``[`` and ``]`` from being interpreted as Rich tags.
"""
return text.replace("[", r"\[").replace("]", r"\]")
# ---------------------------------------------------------------------------
# Diff formatting (produces Rich markup string)
# ---------------------------------------------------------------------------
def _build_stats_text(additions: int, deletions: int) -> str:
"""Build a ``+N -M`` stats string with Rich markup."""
parts: list[str] = []
if additions:
parts.append(f"[green]+{additions}[/green]")
if deletions:
parts.append(f"[red]-{deletions}[/red]")
return " ".join(parts)
def format_diff_rich(
diff: str,
max_lines: int | None = 100,
title: str | None = None,
) -> str:
"""Format a unified diff with line numbers and colors.
Args:
diff: Unified diff string.
max_lines: Maximum number of content lines to show before truncating.
``None`` means unlimited.
title: Optional title shown above the diff (e.g. file path).
Returns:
Rich-markup formatted diff string.
"""
if not diff:
return "[dim]No changes detected[/dim]"
lines = diff.splitlines()
# Compute stats (skip +++ / --- headers)
additions = sum(
1 for ln in lines if ln.startswith("+") and not ln.startswith("+++")
)
deletions = sum(
1 for ln in lines if ln.startswith("-") and not ln.startswith("---")
)
# Find max line number for column width
max_line = 0
for line in lines:
if m := re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)", line):
max_line = max(max_line, int(m.group(1)), int(m.group(2)))
width = max(3, len(str(max_line + len(lines))))
formatted: list[str] = []
# Title header (═══ title ═══)
h = BOX_DOUBLE_HORIZ
if title:
formatted.append(f"[bold cyan]{h}{h}{h} {_escape_markup(title)} {h}{h}{h}[/bold cyan]")
formatted.append("")
# Stats header
stats_text = _build_stats_text(additions, deletions)
if stats_text:
formatted.extend([stats_text, ""])
old_num = new_num = 0
line_count = 0
for line in lines:
if max_lines is not None and line_count >= max_lines:
formatted.append(
f"\n[dim]... ({len(lines) - line_count} more lines)[/dim]"
)
break
# Skip file headers
if line.startswith(("---", "+++")):
continue
# Hunk headers — update line numbers, don't display
if m := re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)", line):
old_num, new_num = int(m.group(1)), int(m.group(2))
continue
content = line[1:] if line else ""
escaped = _escape_markup(content)
if line.startswith("-"):
gutter = f"[red bold]{GUTTER_BAR}[/red bold]"
ln = f"[dim]{old_num:>{width}}[/dim]"
body = f"[on #2d1515]{escaped}[/on #2d1515]"
formatted.append(f"{gutter}{ln} {body}")
old_num += 1
line_count += 1
elif line.startswith("+"):
gutter = f"[green bold]{GUTTER_BAR}[/green bold]"
ln = f"[dim]{new_num:>{width}}[/dim]"
body = f"[on #152d15]{escaped}[/on #152d15]"
formatted.append(f"{gutter}{ln} {body}")
new_num += 1
line_count += 1
elif line.startswith(" "):
formatted.append(
f"[dim]{BOX_VERTICAL}{old_num:>{width}}[/dim] {escaped}"
)
old_num += 1
new_num += 1
line_count += 1
elif line.strip() == "...":
formatted.append("[dim]...[/dim]")
line_count += 1
# Stats footer (matches upstream EnhancedDiff)
if stats_text:
formatted.extend(["", stats_text])
return "\n".join(formatted)
# ---------------------------------------------------------------------------
# High-level helper: build diff from edit_file tool args
# ---------------------------------------------------------------------------
def build_edit_diff(
file_path: str,
old_string: str,
new_string: str,
max_lines: int | None = None,
) -> str | None:
"""Construct a formatted diff from edit_file tool arguments.
Args:
file_path: Path shown in the diff header.
old_string: Original text that was replaced.
new_string: Replacement text.
max_lines: Max content lines before truncation. ``None`` (default)
means no truncation — callers handle collapsing if needed.
Returns:
Rich-markup formatted diff, or ``None`` if inputs are equal/empty.
"""
if old_string == new_string:
return None
if not old_string and not new_string:
return None
diff_lines = list(difflib.unified_diff(
old_string.splitlines(),
new_string.splitlines(),
fromfile=file_path,
tofile=file_path,
lineterm="",
n=3,
))
if not diff_lines:
return None
diff_text = "\n".join(diff_lines)
return format_diff_rich(diff_text, max_lines=max_lines, title=file_path)
+24 -3
View File
@@ -22,6 +22,7 @@ from ..paths import resolve_virtual_path
from .formatter import ToolResultFormatter
from .state import StreamState, SubAgentState, _build_todo_stats, _parse_todo_items, _INTERNAL_TOOLS
from .utils import DisplayLimits, ToolStatus, format_tool_compact, is_success
from .diff_format import build_edit_diff
from .events import stream_agent_events
# ---------------------------------------------------------------------------
@@ -76,10 +77,16 @@ def _format_single_todo(item: dict) -> Text:
# Tool result formatting
# ---------------------------------------------------------------------------
def format_tool_result_compact(_name: str, content: str, max_lines: int = 5) -> list:
def format_tool_result_compact(
_name: str,
content: str,
max_lines: int = 5,
tool_args: dict | None = None,
) -> list:
"""Format tool result as tree output.
Special handling for write_todos: shows formatted checklist with status symbols.
Special handling for edit_file: shows color-coded unified diff.
"""
elements = []
@@ -87,6 +94,17 @@ def format_tool_result_compact(_name: str, content: str, max_lines: int = 5) ->
elements.append(Text(" \u2514 (empty)", style="dim"))
return elements
# Special handling for edit_file: show diff
if _name == "edit_file" and tool_args and is_success(content):
old_str = tool_args.get("old_string", "")
new_str = tool_args.get("new_string", "")
path = tool_args.get("path", tool_args.get("file_path", ""))
if old_str and new_str and old_str != new_str:
diff_markup = build_edit_diff(path, old_str, new_str)
if diff_markup:
elements.append(Text.from_markup(diff_markup))
return elements
# Special handling for write_todos
if _name == "write_todos":
items = _parse_todo_items(content)
@@ -431,9 +449,10 @@ def create_streaming_display(
for tc, tr in completed_regular:
elements.append(_render_tool_call_line(tc, tr))
content = tr.get('content', '') if tr else ''
if tr and not is_success(content):
if tr and (not is_success(content) or tc.get('name') == 'edit_file'):
result_elements = format_tool_result_compact(
tr['name'], content, max_lines=10,
tool_args=tc.get('args'),
)
elements.extend(result_elements)
@@ -475,9 +494,10 @@ def create_streaming_display(
for tc, tr in visible:
elements.append(_render_tool_call_line(tc, tr))
content = tr.get('content', '') if tr else ''
if tr and not is_success(content):
if tr and (not is_success(content) or tc.get('name') == 'edit_file'):
result_elements = format_tool_result_compact(
tr['name'], content, max_lines=5,
tool_args=tc.get('args'),
)
elements.extend(result_elements)
@@ -638,6 +658,7 @@ def display_final_results(
tr['name'],
content,
max_lines=10,
tool_args=tc.get('args'),
)
for elem in result_elements:
console.print(elem)
+246
View File
@@ -0,0 +1,246 @@
"""Tests for EvoScientist.stream.diff_format module."""
from __future__ import annotations
import difflib
from unittest import mock
from EvoScientist.stream.diff_format import (
_detect_unicode_support,
_escape_markup,
build_edit_diff,
format_diff_rich,
)
# ---------------------------------------------------------------------------
# _escape_markup
# ---------------------------------------------------------------------------
class TestEscapeMarkup:
def test_escapes_brackets(self):
assert _escape_markup("[bold]text[/bold]") == r"\[bold\]text\[/bold\]"
def test_plain_text_unchanged(self):
assert _escape_markup("hello world") == "hello world"
def test_empty_string(self):
assert _escape_markup("") == ""
def test_nested_brackets(self):
assert _escape_markup("a[b[c]]d") == r"a\[b\[c\]\]d"
# ---------------------------------------------------------------------------
# _detect_unicode_support
# ---------------------------------------------------------------------------
class TestDetectUnicodeSupport:
def test_utf8_encoding(self):
with mock.patch("sys.stdout") as mock_stdout:
mock_stdout.encoding = "utf-8"
assert _detect_unicode_support() is True
def test_ascii_encoding_with_utf_lang(self):
with mock.patch("sys.stdout") as mock_stdout:
mock_stdout.encoding = "ascii"
with mock.patch.dict("os.environ", {"LANG": "en_US.UTF-8", "LC_ALL": ""}):
assert _detect_unicode_support() is True
def test_ascii_encoding_no_utf_lang(self):
with mock.patch("sys.stdout") as mock_stdout:
mock_stdout.encoding = "ascii"
with mock.patch.dict(
"os.environ", {"LANG": "C", "LC_ALL": ""}, clear=False
):
assert _detect_unicode_support() is False
# ---------------------------------------------------------------------------
# format_diff_rich
# ---------------------------------------------------------------------------
class TestFormatDiffRich:
def test_empty_diff_returns_dim_message(self):
result = format_diff_rich("")
assert "No changes detected" in result
assert "[dim]" in result
def test_single_line_change(self):
old = ["hello"]
new = ["world"]
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
result = format_diff_rich(diff)
# Should contain addition and deletion markers
assert "[green]+1[/green]" in result
assert "[red]-1[/red]" in result
# Deletion line (red background)
assert "2d1515" in result # red background color
# Addition line (green background)
assert "152d15" in result # green background color
def test_stats_header(self):
old = ["a", "b", "c"]
new = ["a", "x", "c", "d"]
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
result = format_diff_rich(diff)
# 1 deletion (b), 2 additions (x, d)
assert "[green]+2[/green]" in result
assert "[red]-1[/red]" in result
def test_max_lines_truncation(self):
old = [f"line{i}" for i in range(50)]
new = [f"LINE{i}" for i in range(50)]
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=0))
result = format_diff_rich(diff, max_lines=10)
assert "more lines" in result
def test_context_lines_dimmed(self):
old = ["a", "b", "c"]
new = ["a", "B", "c"]
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
result = format_diff_rich(diff)
# Context lines should use dim styling
assert "[dim]" in result
def test_title_header(self):
old = ["hello"]
new = ["world"]
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
result = format_diff_rich(diff, title="/path/to/file.py")
# Title should appear with bold cyan and box-drawing chars
assert "file.py" in result
assert "[bold cyan]" in result
def test_title_none_omits_header(self):
old = ["hello"]
new = ["world"]
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
result = format_diff_rich(diff, title=None)
assert "[bold cyan]" not in result
def test_stats_footer(self):
old = ["a"]
new = ["b"]
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=3))
result = format_diff_rich(diff)
# Stats should appear both at top and bottom
lines = result.splitlines()
# First non-empty line is stats header, last non-empty line is stats footer
non_empty = [ln for ln in lines if ln.strip()]
assert non_empty[0] == non_empty[-1] # header == footer
def test_max_lines_none_unlimited(self):
old = [f"line{i}" for i in range(200)]
new = [f"LINE{i}" for i in range(200)]
diff = "\n".join(difflib.unified_diff(old, new, lineterm="", n=0))
result = format_diff_rich(diff, max_lines=None)
# Should NOT have truncation marker
assert "more lines" not in result
# ---------------------------------------------------------------------------
# build_edit_diff
# ---------------------------------------------------------------------------
class TestBuildEditDiff:
def test_returns_none_when_equal(self):
assert build_edit_diff("/foo.py", "same", "same") is None
def test_returns_none_when_both_empty(self):
assert build_edit_diff("/foo.py", "", "") is None
def test_returns_formatted_markup_for_valid_diff(self):
result = build_edit_diff(
"/foo.py",
"old line",
"new line",
)
assert result is not None
assert "[green]" in result
assert "[red]" in result
def test_multiline_diff(self):
old = "line1\nline2\nline3"
new = "line1\nmodified\nline3\nline4"
result = build_edit_diff("/test.py", old, new)
assert result is not None
assert "+2" in result # 2 additions
assert "-1" in result # 1 deletion
def test_no_truncation_by_default(self):
old = "\n".join(f"old{i}" for i in range(100))
new = "\n".join(f"new{i}" for i in range(100))
result = build_edit_diff("/big.py", old, new)
assert result is not None
# Default max_lines=None means no truncation
assert "more lines" not in result
def test_explicit_max_lines_truncates(self):
old = "\n".join(f"old{i}" for i in range(100))
new = "\n".join(f"new{i}" for i in range(100))
result = build_edit_diff("/big.py", old, new, max_lines=5)
assert result is not None
assert "more lines" in result
def test_file_path_shown_as_title(self):
result = build_edit_diff("/my/file.py", "a", "b")
assert result is not None
# File path should appear as the title header
assert "file.py" in result
assert "[bold cyan]" in result
# ---------------------------------------------------------------------------
# Integration with format_tool_result_compact
# ---------------------------------------------------------------------------
class TestFormatToolResultCompactEditFile:
def test_edit_file_with_tool_args_shows_diff(self):
from EvoScientist.stream.display import format_tool_result_compact
result = format_tool_result_compact(
"edit_file",
"[OK] Successfully replaced 1 instance(s)",
tool_args={
"path": "/foo.py",
"old_string": "hello",
"new_string": "world",
},
)
# Should return markup elements with diff content
assert len(result) >= 1
plain = result[0].plain if hasattr(result[0], "plain") else str(result[0])
assert "+1" in plain or "world" in plain or "hello" in plain
def test_edit_file_without_tool_args_falls_through(self):
from EvoScientist.stream.display import format_tool_result_compact
result = format_tool_result_compact(
"edit_file",
"[OK] Successfully replaced 1 instance(s)",
)
# Without tool_args, falls through to normal rendering
assert len(result) >= 1
def test_edit_file_error_shows_error_not_diff(self):
from EvoScientist.stream.display import format_tool_result_compact
result = format_tool_result_compact(
"edit_file",
"[ERROR] File not found",
tool_args={
"path": "/foo.py",
"old_string": "hello",
"new_string": "world",
},
)
# Error content should show error, not diff
assert len(result) >= 1
def test_backward_compatible_no_tool_args(self):
from EvoScientist.stream.display import format_tool_result_compact
# Existing calls without tool_args should still work
result = format_tool_result_compact("read_file", "[OK] 42 lines")
assert len(result) >= 1