diff --git a/clients/terminal/tui/renderers/filesystem.py b/clients/terminal/tui/renderers/filesystem.py index 293c43c..24f83bf 100644 --- a/clients/terminal/tui/renderers/filesystem.py +++ b/clients/terminal/tui/renderers/filesystem.py @@ -60,7 +60,7 @@ args = msg.get("args") or {} action = args.get("action") - body = self._render_body(text, action, args, success, theme) + body = self._render_body(text, action, args, success, theme, msg.get("metadata") or {}) color = theme.tool_success if success else theme.tool_error panel = Panel( body, @@ -76,12 +76,20 @@ # ── dispatch ─────────────────────────────────────────────────────────── def _render_body( - self, text: str, action: str | None, args: dict, success: bool, theme: Theme + self, + text: str, + action: str | None, + args: dict, + success: bool, + theme: Theme, + metadata: dict | None = None, ) -> RenderableType: if not success: # Errors carry a human-readable message; do not attempt to parse. return Text(text, style=theme.tool_error.hex) + if action == "edit": + return self._render_edit(text, metadata or {}, theme) if action in _DIFF_ACTIONS: return self._render_diff(text, action, theme) if action == "list": @@ -119,6 +127,20 @@ highlight_unified_diff(diff, theme), ) + def _render_edit(self, text: str, metadata: dict, theme: Theme) -> RenderableType: + # ``edit`` keeps its model-facing output short (a one-line summary); the + # unified diff is carried in ``metadata["diff"]`` purely for display, so + # the model's context is unchanged. Render the summary dim, then the + # highlighted diff; fall back to plain when no diff is present. + diff = metadata.get("diff") if metadata else None + if not diff: + return Text(text, style=theme.text_dim.hex) + return Group( + Text(text, style=theme.text_dim.hex), + Text(""), + highlight_unified_diff(diff, theme), + ) + # ── read ─────────────────────────────────────────────────────────────── def _render_read(self, text: str, args: dict, theme: Theme) -> RenderableType: diff --git a/navi/tools/filesystem.py b/navi/tools/filesystem.py index 576d24f..3e92178 100644 --- a/navi/tools/filesystem.py +++ b/navi/tools/filesystem.py @@ -616,12 +616,18 @@ new_text = text.replace(old, new, 1) path.write_text(new_text, encoding="utf-8") delta = len(new_text.encode()) - len(text.encode()) + summary = ( + f"Edited {path}: replaced {len(old.encode())} B with " + f"{len(new.encode())} B (delta {delta:+d} B)." + ) + # Keep the model-facing output short; carry the unified diff in metadata + # for the TUI to render with red/green highlighting (the model does not + # see metadata, so its context stays unchanged). + diff = _unified_diff(text.splitlines(), new_text.splitlines(), path) return ToolResult( success=True, - output=( - f"Edited {path}: replaced {len(old.encode())} B with " - f"{len(new.encode())} B (delta {delta:+d} B)." - ), + output=summary, + metadata={"diff": diff} if diff else {}, ) def _edit_lines(self, path: Path, params: dict) -> ToolResult: diff --git a/tests/clients/test_filesystem_renderer.py b/tests/clients/test_filesystem_renderer.py index aee354c..122c63f 100644 --- a/tests/clients/test_filesystem_renderer.py +++ b/tests/clients/test_filesystem_renderer.py @@ -148,6 +148,49 @@ assert body.plain.startswith("Applied 1 operation(s)") +def test_edit_renders_diff_from_metadata() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = FilesystemToolResultRenderer() + msg = { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "edit"}, + "result": "Edited a.py: replaced 5 B with 5 B (delta +0 B).", + "success": True, + "metadata": { + "diff": "--- a.py\n+++ b.py\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n" + }, + } + body = _body(renderer.render(msg)) + # Group(summary, blank, diff) + renderables = body.renderables + summary = renderables[0] + diff = renderables[2] + assert isinstance(summary, Text) + assert "Edited a.py" in summary.plain + assert _span_styles_at(summary, "Edited a.py") == {theme.text_dim.hex} + assert isinstance(diff, Text) + assert _span_styles_at(diff, "+BETA") == {theme.success.hex} + assert _span_styles_at(diff, "-beta") == {theme.error.hex} + + +def test_edit_without_metadata_falls_back_to_plain() -> None: + set_active_theme("gnexus-dark") + theme = get_active_theme() + renderer = FilesystemToolResultRenderer() + msg = { + "type": "tool_call", + "tool": "filesystem", + "args": {"action": "edit"}, + "result": "Edited a.py: replaced 5 B with 5 B (delta +0 B).", + "success": True, + } + body = _body(renderer.render(msg)) + assert isinstance(body, Text) + assert _span_styles_at(body, "Edited a.py") == {theme.text_dim.hex} + + def test_diff_error_renders_plain_error_color() -> None: set_active_theme("gnexus-dark") theme = get_active_theme() diff --git a/tests/unit/tools/test_filesystem.py b/tests/unit/tools/test_filesystem.py index e8dde50..2b4fb96 100644 --- a/tests/unit/tools/test_filesystem.py +++ b/tests/unit/tools/test_filesystem.py @@ -131,6 +131,12 @@ assert result.success assert f.read_text() == "alpha\nBETA\ngamma\n" + # Model-facing output stays short; the unified diff rides in metadata for + # the TUI so the model's context is unchanged. + assert "replaced" in result.output + assert "@@" not in result.output + assert "-beta" in result.metadata["diff"] + assert "+BETA" in result.metadata["diff"] async def test_edit_requires_old_text(self, tool, tmp_path): f = tmp_path / "edit.txt"