Co-authored-by: Hdandria <henri.dandria@mistral.ai>
Co-authored-by: Liam Lyons <65613603+lyons-liam@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: allansimon-mistral <allan.simon@ext.mistral.ai>
Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-06-15 17:36:21 +02:00 committed by GitHub
parent cafb6d4147
commit c2cb612ac1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 2704 additions and 197 deletions

View file

@ -195,8 +195,20 @@ def test_resolve_api_key_for_plan_with_missing_env_var() -> None:
),
"### Unlock more with Vibe - [Upgrade to Vibe Pro](https://chat.mistral.ai/code/extensions?focus=key)",
),
(
PlanInfo(
plan_type=WhoAmIPlanType.CHAT,
plan_name="FREE",
prompt_switching_to_pro_plan=False,
),
"### Unlock more with Vibe - [Upgrade to Vibe Pro](https://chat.mistral.ai/code/extensions?focus=key)",
),
],
ids=[
"switch-to-vibe-pro-key",
"upgrade-api-to-vibe-pro",
"upgrade-free-vibe-to-pro",
],
ids=["switch-to-vibe-pro-key", "upgrade-to-vibe-pro"],
)
def test_plan_offer_cta_routes_users_to_vibe_api_key_extensions(
plan_info: PlanInfo, expected_cta: str
@ -236,6 +248,22 @@ def test_plan_offer_cta_uses_configured_vibe_url() -> None:
),
False,
),
(
WhoAmIResponse(
plan_type=WhoAmIPlanType.CHAT,
plan_name="FREE",
prompt_switching_to_pro_plan=False,
),
False,
),
(
WhoAmIResponse(
plan_type=WhoAmIPlanType.CHAT,
plan_name="UNKNOWN",
prompt_switching_to_pro_plan=False,
),
False,
),
(
WhoAmIResponse(
plan_type=WhoAmIPlanType.API,
@ -256,6 +284,8 @@ def test_plan_offer_cta_uses_configured_vibe_url() -> None:
ids=[
"chat-plan-is-eligible",
"chat-plan-requiring-key-switch-is-ineligible",
"free-vibe-plan-is-ineligible",
"unknown-chat-plan-is-ineligible",
"api-plan-is-ineligible",
"mistral-code-enterprise-is-ineligible",
],
@ -270,14 +300,26 @@ def test_teleport_eligibility_depends_on_chat_plan_and_current_key(
("payload", "expected_title"),
[
(PlanInfo(plan_type=WhoAmIPlanType.API, plan_name="FREE"), "Free"),
(PlanInfo(plan_type=WhoAmIPlanType.CHAT, plan_name="FREE"), "Free"),
(
PlanInfo(plan_type=WhoAmIPlanType.CHAT, plan_name="INDIVIDUAL"),
"[Subscription] Pro",
),
(PlanInfo(plan_type=WhoAmIPlanType.CHAT, plan_name="UNKNOWN"), None),
(
PlanInfo(plan_type=WhoAmIPlanType.API, plan_name="Scale plan"),
"[API] Scale plan",
),
],
ids=["free-api-plan", "paid-api-plan"],
ids=[
"free-api-plan",
"free-vibe-plan",
"chat-pro-plan",
"unknown-chat-plan",
"paid-api-plan",
],
)
def test_plan_title_uses_current_api_plan_labels(
def test_plan_title_uses_current_plan_labels(
payload: PlanInfo, expected_title: str
) -> None:
assert plan_title(payload) == expected_title

View file

@ -37,7 +37,7 @@ async def test_mouse_up_respects_autocopy_config_enabled() -> None:
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
async with app.run_test() as pilot:
await pilot.click()
mock_copy.assert_called_once_with(app, show_toast=True)
mock_copy.assert_called_once_with(app, show_toast=False)
@pytest.mark.asyncio

View file

@ -0,0 +1,74 @@
from __future__ import annotations
import os
from unittest.mock import patch
import pytest
from vibe.cli.terminal_detect import Terminal, detect_terminal
def _detect_with(env: dict[str, str]) -> Terminal:
with patch.dict(os.environ, env, clear=True):
return detect_terminal()
def test_detects_vscode() -> None:
assert _detect_with({"TERM_PROGRAM": "vscode"}) is Terminal.VSCODE
def test_detects_vscode_insiders() -> None:
assert (
_detect_with({"TERM_PROGRAM": "vscode", "TERM_PROGRAM_VERSION": "1.2-insider"})
is Terminal.VSCODE_INSIDERS
)
def test_detects_cursor_from_vscode_environment() -> None:
assert (
_detect_with({
"TERM_PROGRAM": "vscode",
"VSCODE_IPC_HOOK_CLI": "/Applications/Cursor.app/hook",
})
is Terminal.CURSOR
)
@pytest.mark.parametrize(
("term_program", "terminal"),
[
("iterm.app", Terminal.ITERM2),
("wezterm", Terminal.WEZTERM),
("ghostty", Terminal.GHOSTTY),
("alacritty", Terminal.ALACRITTY),
("kitty", Terminal.KITTY),
("hyper", Terminal.HYPER),
],
)
def test_detects_term_program_mapping(term_program: str, terminal: Terminal) -> None:
assert _detect_with({"TERM_PROGRAM": term_program}) is terminal
@pytest.mark.parametrize(
("env_var", "terminal"),
[
("WEZTERM_PANE", Terminal.WEZTERM),
("GHOSTTY_RESOURCES_DIR", Terminal.GHOSTTY),
("KITTY_WINDOW_ID", Terminal.KITTY),
("ALACRITTY_SOCKET", Terminal.ALACRITTY),
("ALACRITTY_LOG", Terminal.ALACRITTY),
("WT_SESSION", Terminal.WINDOWS_TERMINAL),
],
)
def test_detects_environment_marker_fallback(env_var: str, terminal: Terminal) -> None:
assert _detect_with({env_var: "1"}) is terminal
def test_detects_jetbrains_environment_fallback() -> None:
assert (
_detect_with({"TERMINAL_EMULATOR": "JetBrains-JediTerm"}) is Terminal.JETBRAINS
)
def test_returns_unknown_without_markers() -> None:
assert _detect_with({}) is Terminal.UNKNOWN

View file

@ -0,0 +1,181 @@
from __future__ import annotations
from textual.content import Content
from textual.highlight import HighlightTheme
from textual.widgets import Static
from vibe.cli.textual_ui.widgets.diff_rendering import (
_build_diff_line,
diff_border_colors,
language_for_path,
render_edit_diff,
)
def _build(
code: str, prefix: str, lineno: int | None, language: str, *, ansi: bool
) -> Content:
return _build_diff_line(
code, prefix, lineno, language, ansi=ansi, theme=HighlightTheme
)
def _render(*args, **kwargs):
kwargs.setdefault("dark", True)
return render_edit_diff(*args, **kwargs)
def _render_with_colors(*args, **kwargs):
widgets = _render(*args, **kwargs)
return widgets, diff_border_colors(widgets)
def _plain(widget: Static) -> str:
visual = widget.render()
return visual.plain if isinstance(visual, Content) else str(visual)
def _styles_at(content: Content, index: int) -> list[str]:
return [str(s.style) for s in content.spans if s.start <= index < s.end]
class TestLanguageForPath:
def test_extension(self) -> None:
assert language_for_path("/src/main.py") == "py"
def test_no_extension_falls_back_to_text(self) -> None:
assert language_for_path("/src/Makefile") == "text"
class TestBuildDiffLine:
def test_line_number_in_content(self) -> None:
content = _build("x = 1", "+", 42, "py", ansi=False)
assert "42" in content.plain
def test_no_line_number(self) -> None:
content = _build("x = 1", "+", None, "py", ansi=False)
assert content.plain.startswith("+ ")
def test_sign_is_colored_in_both_modes(self) -> None:
for ansi in (False, True):
content = _build("x = 1", "-", 10, "py", ansi=ansi)
# " 10 - x = 1": the gutter is 5 chars, so "-" sits at index 5.
assert any("$text-error" in s for s in _styles_at(content, 5))
def test_line_number_dimmed_uncolored_in_non_ansi(self) -> None:
content = _build("x = 1", "-", 10, "py", ansi=False)
styles = _styles_at(content, 0)
assert any("dim" in s and "$text-muted" in s for s in styles)
assert all("$text-error" not in s for s in styles)
def test_added_line_number_colored_undimmed_in_ansi(self) -> None:
content = _build("x = 1", "+", 10, "py", ansi=True)
styles = _styles_at(content, 0)
assert "$text-success" in styles
assert all("dim" not in s for s in styles)
def test_removed_line_number_and_sign_bright_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 all("dim" not in s for s in lineno_styles)
assert all("dim" not in s for s in sign_styles)
def test_line_number_dimmed_for_unchanged_rows_in_ansi(self) -> None:
content = _build("x = 1", " ", 10, "py", ansi=True)
styles = _styles_at(content, 0)
assert any("dim" in s and "$text-muted" in s for s in styles)
def test_removed_body_dimmed_in_ansi(self) -> None:
content = _build("foo", "-", 10, "py", ansi=True)
# body starts after " 10 - " (7 chars)
assert any("dim" in s for s in _styles_at(content, 7))
def test_removed_body_not_dimmed_in_non_ansi(self) -> None:
content = _build("foo", "-", 10, "py", ansi=False)
assert all("dim" not in s for s in _styles_at(content, 7))
class TestRenderEditDiff:
def test_simple_replacement(self) -> None:
widgets = _render("x = 100", "x = 200", "py", 1, ansi=False)
classes = [w.classes for w in widgets]
assert any("diff-removed" in c for c in classes)
assert any("diff-added" in c for c in classes)
def test_no_hunk_header_rendered(self) -> None:
widgets = _render("a\nb\nc\nd\ne\nf", "a\nb\nX\nd\ne\nf", "py", 1, ansi=False)
for w in widgets:
assert not _plain(w).startswith("@@")
def test_gap_separator_between_hunks(self) -> None:
search = "A\nB\nC\nD\nE\nF\nG\nH"
replace = "Z\nB\nC\nD\nE\nF\nG\nY"
widgets = _render(search, replace, "py", 1, ansi=False)
assert any("diff-gap" in w.classes for w in widgets)
def test_no_leading_gap_for_single_hunk(self) -> None:
widgets = _render("x = 100", "x = 200", "py", 1, ansi=False)
assert all("diff-gap" not in w.classes for w in widgets)
def test_pure_insertion(self) -> None:
widgets = _render("x = 1", "x = 1\ny = 2", "py", 1, ansi=False)
assert any("diff-added" in w.classes for w in widgets)
def test_pure_deletion(self) -> None:
widgets = _render("x = 1\ny = 2", "x = 1", "py", 1, ansi=False)
assert any("diff-removed" in w.classes for w in widgets)
def test_line_numbers_use_start_line_offset(self) -> None:
widgets = _render("x = 100", "x = 200", "py", 42, ansi=False)
assert any("42" in _plain(w) for w in widgets)
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"
widgets = _render(search, replace, "py", 10, ansi=False)
joined = "\n".join(_plain(w) for w in widgets)
assert "10" in joined
assert "17" in joined
def test_no_line_numbers_without_start_line(self) -> None:
widgets = _render("x = 100", "x = 200", "py", None, ansi=False)
for w in widgets:
plain = _plain(w)
if plain.startswith(("- ", "+ ")):
assert not plain[0].isdigit()
def test_blank_lines_preserved(self) -> None:
search = "a\n\nb\nc\nd"
replace = "a\n\nb\nc\nZ"
widgets = _render(search, replace, "py", 1, 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 any(_plain(w).rstrip().endswith("d") for w in removed)
assert any(_plain(w).rstrip().endswith("Z") for w in added)
class TestBorderColors:
def test_keys_index_into_widgets(self) -> None:
widgets, colors = _render_with_colors("x = 100", "x = 200", "py", 1, ansi=False)
assert colors and all(0 <= k < len(widgets) for k in colors)
def test_added_lines_get_bright_success(self) -> None:
widgets, colors = _render_with_colors("x = 100", "x = 200", "py", 1, ansi=False)
added_keys = [i for i, w in enumerate(widgets) if "diff-added" in w.classes]
assert added_keys and all(colors[i] == "not dim $success" for i in added_keys)
def test_removed_lines_get_bright_error(self) -> None:
widgets, colors = _render_with_colors("x = 100", "x = 200", "py", 1, ansi=False)
removed_keys = [i for i, w in enumerate(widgets) if "diff-removed" in w.classes]
assert removed_keys and all(colors[i] == "not dim $error" for i in removed_keys)
def test_context_and_gap_not_in_dict(self) -> None:
search = "A\nB\nC\nD\nE\nF\nG\nH"
replace = "Z\nB\nC\nD\nE\nF\nG\nY"
widgets, colors = _render_with_colors(search, replace, "py", 1, ansi=False)
for i, w in enumerate(widgets):
if "diff-context" in w.classes or "diff-gap" in w.classes:
assert i not in colors