Skip to content

Commit 965e38c

Browse files
committed
feat(tui): render Claude-Code-style diffs for every text-file edit
Editing a file used to show only "wrote file X" — the actual change was invisible unless you went and opened the file yourself. Every text-file edit the GAIA agent performs (write_file, edit_file, write_python_file, edit_python_file, write_markdown_file, replace_function, generate_diff — any file type, not just Python) now renders as a colored unified diff in the TUI, with a per-line number gutter and a file-header title, capped so a huge rewrite can't blow out the transcript. - gaia.agents.tools.diff_utils centralizes unified-diff computation for every write/edit tool, so the diff a user sees is identical regardless of which tool produced it. Binary files are detected and skipped with a size summary instead of a garbled diff; a wire-safety cap truncates pathologically large diffs before they ever leave the process. - The `diff` card (tui/internal/ui/cards/diff.go) completes the generic render primitive the SSE contract already reserved for it — parses hunk headers for line numbers, colors +/- lines via the existing theme, and folds anything past ~40 lines into a "+N more (truncated)" footer. - SSEOutputHandler now forwards a file-edit result's diff fields onto the wire (it previously collapsed to a bare summary/success pair for any tool without a declared render card) — the gap a Go-side-only fix would have missed entirely, caught by tracing the pipeline end to end rather than trusting unit tests of each layer in isolation. - Fixed a pre-existing bug where edit_python_file/replace_function's diff text was double-spaced (an extra blank line between every hunk line). Test plan: - [x] `cd tui && go build ./... && go vet ./... && go test ./...` — passes (3 pre-existing failures in tui/test are unrelated to this change, confirmed present on the unmodified base branch) - [x] `python -m pytest tests/unit/ -k diff` — passes (pre-existing, unrelated collection errors in this environment for missing pytest-asyncio/tomli_w, confirmed present on the unmodified base branch and unaffected by --continue-on-collection-errors) - [x] No regressions in tests/unit/test_file_write_guardrails.py, tests/unit/agents/test_builder_agent.py, tests/unit/test_starter_skills.py
1 parent 2b8272c commit 965e38c

13 files changed

Lines changed: 1525 additions & 85 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
#!/usr/bin/env python
2+
# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved.
3+
# SPDX-License-Identifier: MIT
4+
"""Shared unified-diff helper for file-editing tools.
5+
6+
Every ``file_io_tools`` write/edit tool that mutates a text file on disk wants
7+
the same three things: a unified diff of what changed, a one-line summary for
8+
the activity log, and honest behavior when the "before" content turns out to
9+
be binary. Centralizing it here means the diff a user sees in the TUI is
10+
computed identically whether the agent called ``write_file``, ``edit_file``,
11+
or ``write_python_file`` — a format that drifted between tools is a card the
12+
renderer would have to special-case.
13+
14+
The unified diff this module returns is transport data, not prose — the TUI's
15+
``diff`` render card parses it structurally (``tui/internal/ui/cards/diff.go``,
16+
contract §4.3 in ``docs/spec/agent-ui-query-sse-contract.md``). Nothing here
17+
formats it for direct display.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
import difflib
23+
import os
24+
from typing import Any, Dict, Optional, Tuple
25+
26+
#: Hard cap on unified-diff lines carried over the stdout/JSONL pipe to the
27+
#: TUI (stdio.py) or the SSE payload. The TUI's own ``diff`` card applies a
28+
#: much tighter DISPLAY cap on top of this (maxDiffCardRows in
29+
#: tui/internal/ui/cards/diff.go) — this ceiling exists purely so one
30+
#: pathological tool_result (a multi-thousand-line rewrite) can't blow up the
31+
#: transport itself.
32+
DIFF_MAX_LINES = 4000
33+
34+
35+
def read_text_or_binary(path: str) -> Tuple[Optional[str], Optional[int]]:
36+
"""Read *path* as UTF-8 text.
37+
38+
Returns ``(text, None)`` when *path* exists and decodes as UTF-8,
39+
``(None, size_bytes)`` when it exists but is not valid UTF-8 (a binary
40+
file — image, archive, compiled artifact), or ``(None, None)`` when it
41+
does not exist at all.
42+
43+
Every write/edit tool that needs "the file's current content, if any"
44+
goes through this rather than a bare ``open(...).read()``: the bare
45+
form's ``UnicodeDecodeError`` on a binary file used to surface as an
46+
opaque ``str(e)`` tool error instead of the honest "this is binary, diff
47+
skipped" outcome the diff-display feature requires.
48+
"""
49+
if not os.path.exists(path):
50+
return None, None
51+
try:
52+
with open(path, "r", encoding="utf-8") as f:
53+
return f.read(), None
54+
except UnicodeDecodeError:
55+
return None, os.path.getsize(path)
56+
57+
58+
def format_size(size_bytes: int) -> str:
59+
"""``2_400_000`` -> ``"2.3 MB"``."""
60+
size = float(size_bytes)
61+
for unit in ("B", "KB", "MB"):
62+
if size < 1024:
63+
return f"{int(size)} B" if unit == "B" else f"{size:.1f} {unit}"
64+
size /= 1024
65+
return f"{size:.1f} GB"
66+
67+
68+
def build_diff(
69+
file_path: str,
70+
before: Optional[str],
71+
after: str,
72+
*,
73+
context_lines: int = 3,
74+
max_lines: int = DIFF_MAX_LINES,
75+
) -> Dict[str, Any]:
76+
"""Compute the unified diff and card-ready summary for one text edit.
77+
78+
``before`` is ``None`` for a file that did not exist before this call (a
79+
new file — the whole ``after`` renders as additions). Returns the fields
80+
both the tool_result payload and the TUI's ``diff`` render card want:
81+
``diff`` (unified text, possibly truncated), ``additions``,
82+
``deletions``, ``has_changes``, ``is_new_file``, ``diff_truncated``, and
83+
``summary`` (the one-line activity-log outcome).
84+
"""
85+
before_text = before if before is not None else ""
86+
is_new_file = before is None
87+
88+
diff_lines = list(
89+
difflib.unified_diff(
90+
before_text.splitlines(keepends=True),
91+
after.splitlines(keepends=True),
92+
fromfile="/dev/null" if is_new_file else file_path,
93+
tofile=file_path,
94+
n=context_lines,
95+
)
96+
)
97+
98+
additions = sum(
99+
1 for line in diff_lines if line.startswith("+") and not line.startswith("+++")
100+
)
101+
deletions = sum(
102+
1 for line in diff_lines if line.startswith("-") and not line.startswith("---")
103+
)
104+
has_changes = before_text != after
105+
106+
truncated = False
107+
if len(diff_lines) > max_lines:
108+
hidden = len(diff_lines) - max_lines
109+
diff_lines = diff_lines[:max_lines]
110+
diff_lines.append(
111+
f"... ({hidden} more diff line{'s' if hidden != 1 else ''} not "
112+
"shown — truncated for transport)\n"
113+
)
114+
truncated = True
115+
116+
if is_new_file:
117+
line_count = len(after.splitlines())
118+
summary = f"new file, {line_count} line{'s' if line_count != 1 else ''}"
119+
elif not has_changes:
120+
summary = "no changes"
121+
else:
122+
summary = f"+{additions} -{deletions}"
123+
124+
return {
125+
"diff": "".join(diff_lines),
126+
"additions": additions,
127+
"deletions": deletions,
128+
"has_changes": has_changes,
129+
"is_new_file": is_new_file,
130+
"diff_truncated": truncated,
131+
"summary": summary,
132+
}
133+
134+
135+
def binary_skip_result(size_bytes: int) -> Dict[str, Any]:
136+
"""Fields to merge into a *write* tool's result when the prior file was binary.
137+
138+
Diffing raw bytes as if they were text produces garbage a terminal cannot
139+
usefully show, so the diff is skipped outright and the card degrades to a
140+
size summary instead — the write itself still proceeds, since a full
141+
overwrite never needs to read the old content as text.
142+
"""
143+
return {
144+
"diff": "",
145+
"is_binary": True,
146+
"size_bytes": size_bytes,
147+
"has_changes": True,
148+
"is_new_file": False,
149+
"diff_truncated": False,
150+
"summary": f"binary file ({format_size(size_bytes)}) — diff skipped",
151+
}
152+
153+
154+
def diff_fields_for_overwrite(file_path: str, new_content: str) -> Dict[str, Any]:
155+
"""Diff fields for a tool that fully overwrites *file_path* with *new_content*.
156+
157+
Reads whatever is currently on disk (if anything) and dispatches to
158+
:func:`build_diff` or :func:`binary_skip_result` — the one call a
159+
``write_*`` tool needs before it writes the new content. Read the old
160+
content BEFORE writing; this must be called prior to the overwrite.
161+
"""
162+
before_text, binary_size = read_text_or_binary(file_path)
163+
if binary_size is not None:
164+
return binary_skip_result(binary_size)
165+
return build_diff(file_path, before_text, new_content)
166+
167+
168+
def read_text_for_edit(
169+
file_path: str,
170+
) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
171+
"""Read *file_path* as text for an in-place (search/replace) edit.
172+
173+
Returns ``(text, None)`` on success, or ``(None, error_result)`` when the
174+
file is binary — *error_result* is ready to return directly from the
175+
calling tool (``status="error"``, ``is_binary=True``, ``size_bytes`` set,
176+
and an actionable message pointing at ``write_file`` as the fix, since a
177+
content-replacement edit has no way to locate ``old_content`` inside
178+
bytes that aren't text).
179+
"""
180+
text, size = read_text_or_binary(file_path)
181+
if size is not None:
182+
return None, {
183+
"status": "error",
184+
"error": (
185+
f"Cannot edit {file_path}: binary content "
186+
f"({format_size(size)}) — content-replacement edits require "
187+
"text. Use write_file to replace it entirely."
188+
),
189+
"is_binary": True,
190+
"size_bytes": size,
191+
}
192+
return text, None
193+
194+
195+
__all__ = [
196+
"DIFF_MAX_LINES",
197+
"binary_skip_result",
198+
"build_diff",
199+
"diff_fields_for_overwrite",
200+
"format_size",
201+
"read_text_for_edit",
202+
"read_text_or_binary",
203+
]

0 commit comments

Comments
 (0)