From aad431785c1603b45b472f2e3e7ef5635eacf2ed Mon Sep 17 00:00:00 2001 From: X-iZhang Date: Mon, 9 Mar 2026 22:18:54 +0000 Subject: [PATCH] feat(diff): implement unified diff formatting for edit_file tool results --- EvoScientist/cli/widgets/tool_call_widget.py | 39 ++- EvoScientist/stream/__init__.py | 4 + EvoScientist/stream/diff_format.py | 207 ++++++++++++++++ EvoScientist/stream/display.py | 27 +- tests/test_diff_format.py | 246 +++++++++++++++++++ 5 files changed, 519 insertions(+), 4 deletions(-) create mode 100644 EvoScientist/stream/diff_format.py create mode 100644 tests/test_diff_format.py diff --git a/EvoScientist/cli/widgets/tool_call_widget.py b/EvoScientist/cli/widgets/tool_call_widget.py index 5e8ed31..1df3984 100644 --- a/EvoScientist/cli/widgets/tool_call_widget.py +++ b/EvoScientist/cli/widgets/tool_call_widget.py @@ -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 diff --git a/EvoScientist/stream/__init__.py b/EvoScientist/stream/__init__.py index d53caa0..ec33528 100644 --- a/EvoScientist/stream/__init__.py +++ b/EvoScientist/stream/__init__.py @@ -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", diff --git a/EvoScientist/stream/diff_format.py b/EvoScientist/stream/diff_format.py new file mode 100644 index 0000000..dfaa3d6 --- /dev/null +++ b/EvoScientist/stream/diff_format.py @@ -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) diff --git a/EvoScientist/stream/display.py b/EvoScientist/stream/display.py index 41833f9..92b618f 100644 --- a/EvoScientist/stream/display.py +++ b/EvoScientist/stream/display.py @@ -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) diff --git a/tests/test_diff_format.py b/tests/test_diff_format.py new file mode 100644 index 0000000..d7362c2 --- /dev/null +++ b/tests/test_diff_format.py @@ -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