Co-authored-by: Albert Jiang <aj@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Laure Hugo <201583486+laure0303@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Mert Unsal <mert.unsal@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
maiengineering 2026-07-01 19:03:09 +02:00 committed by GitHub
parent 4e495f658d
commit ac8f1a09fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 1979 additions and 572 deletions

View file

@ -1,12 +1,17 @@
from __future__ import annotations
from pathlib import Path
import pytest
from textual.content import Content
from textual.highlight import HighlightTheme
from textual.widget import Widget
from vibe.cli.textual_ui.widgets.diff_rendering import (
DiffOccurrence,
_build_diff_line,
diff_border_colors,
edit_diff_inputs,
language_for_path,
render_edit_diff,
)
@ -20,14 +25,12 @@ def _build(
)
def _render(*args, **kwargs):
kwargs.setdefault("dark", True)
args = list(args)
# Tests pass a single start line as an int for readability; the renderer
# expects a list of occurrences.
if len(args) >= 4 and isinstance(args[3], int):
args[3] = [args[3]]
return render_edit_diff(*args, **kwargs)
def _render(old_string, new_string, language, start, *, ansi, dark=True):
# Tests pass the same old/new at a single start line (int/None) or, for
# replace_all, at a list of start lines; build one occurrence per location.
starts = start if isinstance(start, list) else [start]
occurrences = [DiffOccurrence(s, old_string, new_string) for s in starts]
return render_edit_diff(occurrences, language, ansi=ansi, dark=dark)
def _render_with_colors(*args, **kwargs):
@ -80,14 +83,20 @@ class TestBuildDiffLine:
styles = _styles_at(content, 0)
assert "$text-success" in styles
assert all("dim" not in s for s in styles)
assert all("bold" not in s for s in styles)
def test_removed_line_number_and_sign_bright_in_ansi(self) -> None:
def test_removed_line_number_not_bold_in_ansi(self) -> None:
content = _build("x = 1", "-", 10, "py", ansi=True)
lineno_styles = _styles_at(content, 0)
sign_styles = _styles_at(content, 5)
assert any("bold" in s and "$text-error" in s for s in lineno_styles)
assert any("bold" in s and "$text-error" in s for s in sign_styles)
assert any("$text-error" in s for s in lineno_styles)
assert all("bold" not in s for s in lineno_styles)
assert all("dim" not in s for s in lineno_styles)
def test_removed_sign_not_bold_in_ansi(self) -> None:
content = _build("x = 1", "-", 10, "py", ansi=True)
sign_styles = _styles_at(content, 5)
assert any("$text-error" in s for s in sign_styles)
assert all("bold" not in s for s in sign_styles)
assert all("dim" not in s for s in sign_styles)
def test_line_number_dimmed_for_unchanged_rows_in_ansi(self) -> None:
@ -139,6 +148,26 @@ class TestRenderEditDiff:
widgets = _render("x = 100", "x = 200", "py", 42, ansi=False)
assert any("42" in _plain(w) for w in widgets)
@pytest.mark.asyncio
async def test_leading_newline_snippet_gutter_matches_file_lines(
self, tmp_path: Path
) -> None:
# A leading-newline snippet starts modifying the previous line; the
# whole-line diff must show that line and number the hunk against the
# real file lines, not skip the leading newline and drift by one.
f = tmp_path / "f.py"
f.write_text("aa\nbab\ncc\n")
occurrences = await edit_diff_inputs(
str(f), "\nbab\nc", "Z\nbab\nC", replace_all=False
)
widgets = render_edit_diff(occurrences, "py", ansi=False, dark=True)
file_lines = ["aa", "bab", "cc"]
for w in widgets:
plain = _plain(w)
if plain[5:6] in (" ", "-"):
lineno = int(plain[:4])
assert plain[7:] == file_lines[lineno - 1]
def test_multi_hunk_line_numbers(self) -> None:
search = "A\nB\nC\nD\nE\nF\nG\nH"
replace = "Z\nB\nC\nD\nE\nF\nG\nY"
@ -164,31 +193,45 @@ class TestRenderEditDiff:
assert any(_plain(w).rstrip().endswith("Z") for w in added)
def test_replace_all_renders_each_occurrence(self) -> None:
widgets = render_edit_diff(
"foo", "bar", "py", [3, 10, 25], ansi=False, dark=True
)
widgets = _render("foo", "bar", "py", [3, 10, 25], ansi=False)
removed = [w for w in widgets if "diff-removed" in w.classes]
added = [w for w in widgets if "diff-added" in w.classes]
assert len(removed) == 3
assert len(added) == 3
def test_replace_all_uses_each_start_line(self) -> None:
widgets = render_edit_diff(
"foo", "bar", "py", [3, 10, 25], ansi=False, dark=True
)
widgets = _render("foo", "bar", "py", [3, 10, 25], ansi=False)
joined = "\n".join(_plain(w) for w in widgets)
assert "3" in joined
assert "10" in joined
assert "25" in joined
def test_replace_all_separates_occurrences_with_gap(self) -> None:
widgets = render_edit_diff("foo", "bar", "py", [3, 10], ansi=False, dark=True)
widgets = _render("foo", "bar", "py", [3, 10], ansi=False)
assert sum("diff-gap" in w.classes for w in widgets) == 1
def test_single_occurrence_has_no_gap(self) -> None:
widgets = render_edit_diff("foo", "bar", "py", [3], ansi=False, dark=True)
widgets = _render("foo", "bar", "py", [3], ansi=False)
assert all("diff-gap" not in w.classes for w in widgets)
def test_per_occurrence_full_lines(self) -> None:
# Each occurrence carries its own whole-line content with its own line.
widgets = render_edit_diff(
[
DiffOccurrence(2, "x = bar + 1", "x = qux + 1"),
DiffOccurrence(7, "y = bar - 2", "y = qux - 2"),
],
"py",
ansi=False,
dark=True,
)
removed = [_plain(w) for w in widgets if "diff-removed" in w.classes]
added = [_plain(w) for w in widgets if "diff-added" in w.classes]
assert any("x = bar + 1" in r for r in removed)
assert any("y = bar - 2" in r for r in removed)
assert any("x = qux + 1" in a for a in added)
assert any("y = qux - 2" in a for a in added)
class TestBorderColors:
def test_keys_index_into_widgets(self) -> None:

View file

@ -49,12 +49,23 @@ def _call_event() -> ToolCallEvent:
)
def _error_result() -> ToolResultEvent:
def _error_result(error: str = "boom") -> ToolResultEvent:
return ToolResultEvent(
tool_name="stub_tool",
tool_class=FakeTool,
result=None,
error="boom",
error=error,
tool_call_id="a",
)
def _skipped_result() -> ToolResultEvent:
return ToolResultEvent(
tool_name="stub_tool",
tool_class=FakeTool,
result=None,
skipped=True,
skip_reason="User declined",
tool_call_id="a",
)
@ -87,6 +98,40 @@ async def test_error_renders_muted_then_escalates_icon_and_styling() -> None:
assert not result_widget.has_class("error-text")
@pytest.mark.asyncio
async def test_declined_call_renders_muted_square() -> None:
app = _ToolApp(_call_event(), _skipped_result())
async with app.run_test() as pilot:
await pilot.pause()
call_widget = app.call_widget
assert call_widget is not None
icon = call_widget._indicator_widget
assert icon is not None
assert _rendered(icon).plain == ""
assert icon.has_class("muted")
assert not icon.has_class("error")
@pytest.mark.asyncio
async def test_error_with_square_brackets_does_not_raise_markup_error() -> None:
error = (
"Validation error in tool ask_user_question: 1 validation error for "
"AskUserQuestionArgs\nquestions.0.header\n Value error "
"[type=value_error, input_value={'questions[0].header': 'x'}, "
"input_type=dict]"
)
app = _ToolApp(_call_event(), _error_result(error))
async with app.run_test() as pilot:
await pilot.pause()
result_widget = app.result_widget
assert result_widget is not None
detail = result_widget.query_one(CollapsibleSection).query_one(Static)
content = _rendered(detail)
assert content.plain == f"Error: {error}"
@pytest.mark.asyncio
async def test_folded_error_detail_colors_only_the_error_word() -> None:
app = _ToolApp(_call_event(), _error_result())