Co-authored-by: Alexis Tacnet <alexis@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: p.vezia <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Hiba Chaabnia <Hiba-Chaabnia@users.noreply.github.com>
Co-authored-by: Nikhil Bhima <nikhilbhima@users.noreply.github.com>
Co-authored-by: Nkipohcs <Nkipohcs@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-06-04 18:26:35 +02:00 committed by GitHub
parent ad0d5c9520
commit 3f8487f761
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
197 changed files with 10819 additions and 2830 deletions

View file

@ -12,6 +12,7 @@ from vibe.cli.plan_offer.decide_plan_offer import (
WhoAmIPlanType,
decide_plan_offer,
plan_offer_cta,
plan_title,
resolve_api_key_for_plan,
)
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
@ -263,3 +264,20 @@ def test_teleport_eligibility_depends_on_chat_plan_and_current_key(
response: WhoAmIResponse, expected: bool
) -> None:
assert PlanInfo.from_response(response).is_teleport_eligible() is expected
@pytest.mark.parametrize(
("payload", "expected_title"),
[
(PlanInfo(plan_type=WhoAmIPlanType.API, plan_name="FREE"), "Free"),
(
PlanInfo(plan_type=WhoAmIPlanType.API, plan_name="Scale plan"),
"[API] Scale plan",
),
],
ids=["free-api-plan", "paid-api-plan"],
)
def test_plan_title_uses_current_api_plan_labels(
payload: PlanInfo, expected_title: str
) -> None:
assert plan_title(payload) == expected_title

416
tests/cli/smoke_binary.py Normal file
View file

@ -0,0 +1,416 @@
#!/usr/bin/env python3
"""Smoke tests for the built vibe binary.
Usage: python tests/cli/smoke_binary.py <binary-dir>
Tests:
1. --version exits successfully
2. Normal interactive launch starts far enough to load the main Textual app
3. --setup starts far enough to load bundled setup/Textual assets
4. Programmatic mode without an API key fails with the expected config error
5. Runtime data files are present in the bundle
6. The relocated bundle can be launched from PATH
7. (Linux) No ELF binaries require executable stack (GNU_STACK RWE)
"""
from __future__ import annotations
import os
from pathlib import Path
import platform
import shutil
import struct
import subprocess
import sys
import tempfile
import time
from typing import NoReturn
def _fail(msg: str) -> NoReturn:
print(f"FAIL: {msg}", file=sys.stderr)
sys.exit(1)
def _isolated_env(vibe_home: Path) -> dict[str, str]:
env = os.environ.copy()
env["VIBE_HOME"] = str(vibe_home)
env["TERM"] = env.get("TERM") or "xterm-256color"
env.pop("MISTRAL_API_KEY", None)
env.pop("VIBE_MISTRAL_API_KEY", None)
return env
def test_version(binary: Path) -> None:
result = subprocess.run(
[str(binary), "--version"], capture_output=True, text=True, timeout=30
)
if result.returncode != 0:
_fail(
f"--version exited with code {result.returncode}\nstderr: {result.stderr}"
)
print(f"PASS: --version -> {result.stdout.strip()}")
def test_interactive_launch_loads_bundled_assets(binary: Path) -> None:
if platform.system() == "Windows":
# Windows does not provide the Unix pty module used to drive Textual here.
print("SKIP: interactive pty smoke test (Windows)")
return
import pty
import select
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
workdir = tmp_path / "workdir"
workdir.mkdir()
master_fd, slave_fd = pty.openpty()
env = _isolated_env(tmp_path / "home")
env["MISTRAL_API_KEY"] = "smoke-test-mock-key"
proc = subprocess.Popen(
[str(binary), "--trust", "--workdir", str(workdir)],
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
env=env,
text=False,
)
os.close(slave_fd)
output = bytearray()
deadline = time.monotonic() + 30
try:
while time.monotonic() < deadline:
if proc.poll() is not None:
break
readable, _, _ = select.select([master_fd], [], [], 0.2)
if not readable:
continue
chunk = os.read(master_fd, 4096)
if not chunk:
break
output.extend(chunk)
decoded = output.decode("utf-8", errors="replace")
if "Traceback" in decoded or "StylesheetError" in decoded:
_fail(decoded)
# This pins the Textual app title used as the main-app smoke marker.
if "\x1b]0;Vibe\x07" in decoded and len(output) > 4096:
print("PASS: interactive launch loaded bundled UI assets")
return
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
os.close(master_fd)
_fail("interactive launch did not render expected UI output before timeout")
def test_setup_loads_bundled_assets(binary: Path) -> None:
if platform.system() == "Windows":
# Windows does not provide the Unix pty module used to drive Textual here.
print("SKIP: --setup pty smoke test (Windows)")
return
import pty
import select
with tempfile.TemporaryDirectory() as tmp:
workdir = Path(tmp) / "workdir"
workdir.mkdir()
master_fd, slave_fd = pty.openpty()
proc = subprocess.Popen(
[str(binary), "--setup", "--workdir", str(workdir)],
stdin=slave_fd,
stdout=slave_fd,
stderr=slave_fd,
env=_isolated_env(Path(tmp) / "home"),
text=False,
)
os.close(slave_fd)
output = bytearray()
deadline = time.monotonic() + 30
try:
while time.monotonic() < deadline:
if proc.poll() is not None:
break
readable, _, _ = select.select([master_fd], [], [], 0.2)
if not readable:
continue
chunk = os.read(master_fd, 4096)
if not chunk:
break
output.extend(chunk)
if b"Traceback" in output or b"StylesheetError" in output:
_fail(output.decode("utf-8", errors="replace"))
if b"Mistral" in output or b"API" in output or b"Welcome" in output:
print("PASS: --setup loaded bundled UI assets")
return
finally:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
os.close(master_fd)
_fail("--setup did not render expected setup UI text before timeout")
def test_programmatic_missing_api_key(binary: Path) -> None:
with tempfile.TemporaryDirectory() as tmp:
result = subprocess.run(
[str(binary), "-p", "hello"],
capture_output=True,
env=_isolated_env(Path(tmp) / ".vibe"),
text=True,
timeout=30,
)
if result.returncode == 0:
_fail("programmatic mode without API key unexpectedly succeeded")
output = f"{result.stdout}\n{result.stderr}"
if "Traceback" in output:
_fail(f"programmatic mode raised a traceback:\n{output}")
# This pins the user-facing guidance for the missing API key path.
if "Set the environment variable" not in output:
_fail(f"missing expected API key guidance:\n{output}")
print("PASS: programmatic mode reports missing API key")
def test_bundled_runtime_files(binary_dir: Path) -> None:
bundle_root = binary_dir / "_internal" / "vibe"
source_root = Path(__file__).resolve().parents[2] / "vibe"
if not bundle_root.is_dir():
_fail(f"bundled vibe package not found at {bundle_root}")
if not source_root.is_dir():
_fail(f"source vibe package not found at {source_root}")
required_exact = [
"whats_new.md",
"cli/textual_ui/app.tcss",
"setup/onboarding/onboarding.tcss",
"setup/trusted_folders/trust_folder_dialog.tcss",
]
missing_exact = [
relative
for relative in required_exact
if not (bundle_root / relative).is_file()
]
if missing_exact:
lines = ["Missing required bundled runtime file(s):"]
lines.extend(f" - vibe/{relative}" for relative in missing_exact)
_fail("\n".join(lines))
mirrored_globs = [
"**/*.tcss",
"**/*.md",
"core/tools/builtins/*.py",
"core/skills/builtins/*.py",
]
for pattern in mirrored_globs:
source_files = {
path.relative_to(source_root)
for path in source_root.glob(pattern)
if path.is_file()
}
bundled_files = {
path.relative_to(bundle_root)
for path in bundle_root.glob(pattern)
if path.is_file()
}
missing = sorted(source_files - bundled_files)
if missing:
lines = [f"Bundle is missing runtime file(s) for pattern {pattern}:"]
lines.extend(f" - vibe/{path}" for path in missing)
_fail("\n".join(lines))
print("PASS: bundled runtime files are present")
def test_installed_bundle_launches(binary_dir: Path, binary_name: str) -> None:
with tempfile.TemporaryDirectory() as tmp:
tmp_path = Path(tmp)
install_dir = tmp_path / "install" / "vibe"
workdir = tmp_path / "workdir"
vibe_home = tmp_path / "home"
workdir.mkdir()
shutil.copytree(binary_dir, install_dir)
installed_binary = install_dir / binary_name
if not installed_binary.exists():
_fail(f"installed binary not found at {installed_binary}")
if platform.system() != "Windows":
installed_binary.chmod(0o755)
env = _isolated_env(vibe_home)
env["PATH"] = f"{install_dir}{os.pathsep}{env.get('PATH', '')}"
result = subprocess.run(
[binary_name, "--version"],
capture_output=True,
cwd=workdir,
env=env,
text=True,
timeout=30,
)
if result.returncode != 0:
_fail(
"installed bundle --version exited with code "
f"{result.returncode}\nstderr: {result.stderr}"
)
if "Traceback" in f"{result.stdout}\n{result.stderr}":
_fail(f"installed bundle raised a traceback:\n{result.stdout}\n{result.stderr}")
print(f"PASS: installed bundle launches from PATH -> {result.stdout.strip()}")
_PT_GNU_STACK = 0x6474E551
_PF_X = 0x1
def _has_executable_stack(filepath: Path) -> bool | None:
try:
with filepath.open("rb") as f:
magic = f.read(4)
if magic != b"\x7fELF":
return None
ei_class = f.read(1)[0]
ei_data = f.read(1)[0]
match ei_data:
case 1:
endian = "<"
case 2:
endian = ">"
case _:
return None
if ei_class == 2:
f.seek(32)
(e_phoff,) = struct.unpack(f"{endian}Q", f.read(8))
f.seek(54)
(e_phentsize,) = struct.unpack(f"{endian}H", f.read(2))
(e_phnum,) = struct.unpack(f"{endian}H", f.read(2))
for i in range(e_phnum):
f.seek(e_phoff + i * e_phentsize)
(p_type,) = struct.unpack(f"{endian}I", f.read(4))
(p_flags,) = struct.unpack(f"{endian}I", f.read(4))
if p_type == _PT_GNU_STACK:
return bool(p_flags & _PF_X)
elif ei_class == 1:
f.seek(28)
(e_phoff,) = struct.unpack(f"{endian}I", f.read(4))
f.seek(42)
(e_phentsize,) = struct.unpack(f"{endian}H", f.read(2))
(e_phnum,) = struct.unpack(f"{endian}H", f.read(2))
for i in range(e_phnum):
off = e_phoff + i * e_phentsize
f.seek(off)
(p_type,) = struct.unpack(f"{endian}I", f.read(4))
f.seek(off + 24)
(p_flags,) = struct.unpack(f"{endian}I", f.read(4))
if p_type == _PT_GNU_STACK:
return bool(p_flags & _PF_X)
return False
except (OSError, struct.error):
return None
def test_no_executable_stack(binary_dir: Path, binary_name: str) -> None:
if platform.system() != "Linux":
print("SKIP: executable stack check (not Linux)")
return
internal_dir = binary_dir / "_internal"
if not internal_dir.exists():
_fail(f"_internal directory not found at {internal_dir}")
violations: list[Path] = []
checked = 0
candidates = [binary_dir / binary_name, *internal_dir.rglob("*")]
for filepath in candidates:
if not filepath.is_file():
continue
result = _has_executable_stack(filepath)
if result is None:
continue
checked += 1
if result:
violations.append(filepath)
if violations:
lines = [
f"Found {len(violations)} ELF file(s) with executable stack "
f"(GNU_STACK RWE) out of {checked} checked.",
"",
"These will fail on SELinux-enforcing or hardened kernels:",
]
for violation in violations:
lines.append(f" - {violation.relative_to(binary_dir)}")
lines.append("")
lines.append("Fix: run 'patchelf --clear-execstack' on these files.")
_fail("\n".join(lines))
print(f"PASS: no executable stack in {checked} ELF files")
def main() -> None:
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <binary-dir>")
sys.exit(1)
binary_dir = Path(sys.argv[1])
binary_name = "vibe.exe" if platform.system() == "Windows" else "vibe"
binary = binary_dir / binary_name
if not binary.exists():
_fail(f"binary not found at {binary}")
if platform.system() != "Windows":
binary.chmod(0o755)
print(f"Testing binary: {binary}\n")
test_version(binary)
test_bundled_runtime_files(binary_dir)
test_installed_bundle_launches(binary_dir, binary_name)
test_no_executable_stack(binary_dir, binary_name)
test_interactive_launch_loads_bundled_assets(binary)
test_setup_loads_bundled_assets(binary)
test_programmatic_missing_api_key(binary)
print("\nAll smoke tests passed!")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,193 @@
from __future__ import annotations
from pathlib import Path
from typing import Literal
import pytest
from vibe.cli import entrypoint as entrypoint_mod
from vibe.core.trusted_folders import trusted_folders_manager
from vibe.setup.trusted_folders.trust_folder_dialog import TrustDecision
def _make_git_repo(path: Path) -> None:
git_dir = path / ".git"
git_dir.mkdir()
(git_dir / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
def _git_sub_with_agents(
tmp_path: Path, *, agents_at_sub: bool = True
) -> tuple[Path, Path]:
repo = tmp_path / "repo"
repo.mkdir()
_make_git_repo(repo)
sub = repo / "src"
sub.mkdir()
if agents_at_sub:
(sub / "AGENTS.md").write_text("# Agents", encoding="utf-8")
else:
(repo / "AGENTS.md").write_text("# Agents", encoding="utf-8")
return repo, sub
def _patch_ask(
monkeypatch: pytest.MonkeyPatch, *, decision: TrustDecision = TrustDecision.DECLINE
) -> dict[str, object]:
captured: dict[str, object] = {"called": False}
def fake_ask(
cwd: Path,
repo_root: Path | None,
detected_files: list[str],
repo_detected_files: list[str] | None = None,
offer_repo_trust: bool = False,
repo_explicitly_untrusted: bool = False,
) -> TrustDecision:
captured["called"] = True
captured["cwd"] = cwd
captured["repo_root"] = repo_root
captured["detected_files"] = detected_files
captured["repo_detected_files"] = repo_detected_files
captured["offer_repo_trust"] = offer_repo_trust
captured["repo_explicitly_untrusted"] = repo_explicitly_untrusted
return decision
monkeypatch.setattr(entrypoint_mod, "ask_trust_folder", fake_ask)
return captured
@pytest.fixture
def away_from_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: tmp_path / "elsewhere"))
@pytest.mark.parametrize(
"setup",
[
pytest.param("repo_already_trusted", id="repo_already_trusted"),
pytest.param("cwd_explicitly_untrusted", id="cwd_explicitly_untrusted"),
],
)
def test_skips_trust_dialog(
setup: Literal["repo_already_trusted", "cwd_explicitly_untrusted"],
tmp_path: Path,
away_from_home: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
match setup:
case "repo_already_trusted":
repo, cwd = _git_sub_with_agents(tmp_path)
trusted_folders_manager.add_trusted(repo)
case "cwd_explicitly_untrusted":
cwd = tmp_path / "plain"
cwd.mkdir()
(cwd / "AGENTS.md").write_text("# Agents", encoding="utf-8")
trusted_folders_manager.add_untrusted(cwd)
captured = _patch_ask(monkeypatch)
entrypoint_mod.check_and_resolve_trusted_folder(cwd)
assert captured["called"] is False
def test_shows_trust_dialog_when_only_repo_context_files_exist(
tmp_path: Path, away_from_home: None, monkeypatch: pytest.MonkeyPatch
) -> None:
repo, cwd = _git_sub_with_agents(tmp_path, agents_at_sub=False)
captured = _patch_ask(monkeypatch)
entrypoint_mod.check_and_resolve_trusted_folder(cwd)
assert captured["called"] is True
assert captured["detected_files"] == []
assert captured["repo_detected_files"] == ["AGENTS.md"]
assert captured["repo_root"] == repo.resolve()
@pytest.mark.parametrize(
"untrusted_at,offer_repo_trust,repo_explicitly_untrusted",
[
pytest.param(None, True, False, id="repo_undecided"),
pytest.param("repo", False, True, id="repo_explicitly_untrusted"),
pytest.param("parent", True, False, id="parent_untrusted_only"),
],
)
def test_ask_trust_folder_args_in_git_repo(
untrusted_at: str | None,
offer_repo_trust: bool,
repo_explicitly_untrusted: bool,
tmp_path: Path,
away_from_home: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
if untrusted_at == "parent":
parent = tmp_path / "parent"
parent.mkdir()
repo = parent / "repo"
repo.mkdir()
_make_git_repo(repo)
cwd = repo / "src"
cwd.mkdir()
(cwd / "AGENTS.md").write_text("# Agents", encoding="utf-8")
trusted_folders_manager.add_untrusted(parent)
else:
repo, cwd = _git_sub_with_agents(tmp_path)
if untrusted_at == "repo":
trusted_folders_manager.add_untrusted(repo)
captured = _patch_ask(monkeypatch)
entrypoint_mod.check_and_resolve_trusted_folder(cwd)
assert captured["called"] is True
assert captured["cwd"] == cwd
assert captured["repo_root"] == repo.resolve()
assert captured["detected_files"] == ["AGENTS.md"]
assert captured["repo_detected_files"] == []
assert captured["offer_repo_trust"] is offer_repo_trust
assert captured["repo_explicitly_untrusted"] is repo_explicitly_untrusted
@pytest.mark.parametrize(
("decision", "sub_trusted", "repo_trusted", "sub_explicitly_untrusted"),
[
pytest.param(TrustDecision.TRUST_REPO, True, True, False, id="trust_repo"),
pytest.param(TrustDecision.TRUST_CWD, True, None, False, id="trust_cwd"),
pytest.param(TrustDecision.DECLINE, False, None, True, id="decline"),
],
)
def test_applies_trust_decision(
decision: TrustDecision,
sub_trusted: bool | None,
repo_trusted: bool | None,
sub_explicitly_untrusted: bool,
tmp_path: Path,
away_from_home: None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repo, sub = _git_sub_with_agents(tmp_path)
_patch_ask(monkeypatch, decision=decision)
entrypoint_mod.check_and_resolve_trusted_folder(sub)
assert trusted_folders_manager.is_trusted(sub) is sub_trusted
assert trusted_folders_manager.is_trusted(repo) is repo_trusted
assert (
trusted_folders_manager.is_explicitly_untrusted(sub) is sub_explicitly_untrusted
)
if decision == TrustDecision.TRUST_CWD:
assert trusted_folders_manager.is_trusted(repo / "other") is None
def test_no_git_repo_offers_no_repo_trust_and_decline_untrusts_cwd(
tmp_path: Path, away_from_home: None, monkeypatch: pytest.MonkeyPatch
) -> None:
cwd = tmp_path / "plain"
cwd.mkdir()
(cwd / "AGENTS.md").write_text("# Agents", encoding="utf-8")
captured = _patch_ask(monkeypatch, decision=TrustDecision.DECLINE)
entrypoint_mod.check_and_resolve_trusted_folder(cwd)
assert captured["called"] is True
assert captured["repo_root"] is None
assert captured["repo_detected_files"] == []
assert captured["offer_repo_trust"] is False
assert trusted_folders_manager.is_explicitly_untrusted(cwd) is True

View file

@ -18,6 +18,7 @@ from vibe.cli.clipboard import (
_read_clipboard,
copy_selection_to_clipboard,
copy_text_to_clipboard,
try_copy_text_to_clipboard,
)
@ -206,6 +207,32 @@ def test_copy_text_to_clipboard_shows_failure_when_clipboard_unavailable(
)
@patch("vibe.cli.clipboard._copy_to_clipboard")
def test_try_copy_text_to_clipboard_returns_true_when_copy_succeeds(
mock_copy_to_clipboard: MagicMock,
) -> None:
result = try_copy_text_to_clipboard("assistant text")
assert result is True
mock_copy_to_clipboard.assert_called_once_with("assistant text")
@patch("vibe.cli.clipboard._copy_to_clipboard")
def test_try_copy_text_to_clipboard_returns_false_when_clipboard_unavailable(
mock_copy_to_clipboard: MagicMock,
) -> None:
mock_copy_to_clipboard.side_effect = RuntimeError("All clipboard strategies failed")
result = try_copy_text_to_clipboard("assistant text")
assert result is False
mock_copy_to_clipboard.assert_called_once_with("assistant text")
def test_try_copy_text_to_clipboard_returns_false_for_empty_text() -> None:
assert try_copy_text_to_clipboard("") is False
def test_copy_text_to_clipboard_returns_none_for_empty_text(
mock_app: MagicMock,
) -> None:

View file

@ -246,7 +246,7 @@ class TestConnectorMenuOrdering:
)
ordered = _sort_connector_names_for_menu(
registry.get_connector_names(), registry
registry.get_connector_names(), registry, set()
)
assert ordered == ["alpha", "beta", "zeta"]
@ -261,11 +261,54 @@ class TestConnectorMenuOrdering:
)
ordered = _sort_connector_names_for_menu(
registry.get_connector_names(), registry
registry.get_connector_names(), registry, set()
)
assert ordered == ["alpha", "Beta", "Zeta"]
def test_disabled_connectors_sink_to_bottom(self) -> None:
registry = FakeConnectorRegistry(
connectors={
"alpha": [RemoteTool(name="lookup", description="Lookup")],
"beta": [RemoteTool(name="search", description="Search")],
"gamma": [],
}
)
# alpha is enabled+connected, beta is enabled+connected but disabled in
# config, gamma is enabled+disconnected. Expected: enabled-and-connected
# first, then enabled-but-disconnected, then disabled at the bottom.
ordered = _sort_connector_names_for_menu(
registry.get_connector_names(), registry, {"beta"}
)
assert ordered == ["alpha", "gamma", "beta"]
def test_full_ordering_enabled_then_connected_then_alpha(self) -> None:
registry = FakeConnectorRegistry(
connectors={
"d_disabled_connected": [RemoteTool(name="t", description="t")],
"b_enabled_disconnected": [],
"a_enabled_connected": [RemoteTool(name="t", description="t")],
"c_enabled_connected": [RemoteTool(name="t", description="t")],
"e_disabled_disconnected": [],
}
)
ordered = _sort_connector_names_for_menu(
registry.get_connector_names(),
registry,
{"d_disabled_connected", "e_disabled_disconnected"},
)
assert ordered == [
"a_enabled_connected",
"c_enabled_connected",
"b_enabled_disconnected",
"d_disabled_connected",
"e_disabled_disconnected",
]
class TestHelpBarChanges:
"""The help bar should show 'Enter Authenticate' for disconnected connectors."""

View file

@ -45,7 +45,7 @@ async def test_ui_displays_messages_when_resuming_session(
id="tool_call_1",
index=0,
function=FunctionCall(
name="read_file", arguments='{"path": "test.txt"}'
name="read", arguments='{"file_path": "test.txt"}'
),
)
],
@ -53,7 +53,7 @@ async def test_ui_displays_messages_when_resuming_session(
tool_result_msg = LLMMessage(
role=Role.tool,
content="File content here",
name="read_file",
name="read",
tool_call_id="tool_call_1",
)
@ -78,12 +78,12 @@ async def test_ui_displays_messages_when_resuming_session(
# Verify tool call message is displayed
tool_call_messages = app.query(ToolCallMessage)
assert len(tool_call_messages) == 1
assert tool_call_messages[0]._tool_name == "read_file"
assert tool_call_messages[0]._tool_name == "read"
# Verify tool result message is displayed
tool_result_messages = app.query(ToolResultMessage)
assert len(tool_result_messages) == 1
assert tool_result_messages[0].tool_name == "read_file"
assert tool_result_messages[0].tool_name == "read"
assert tool_result_messages[0]._content == "File content here"

View file

@ -0,0 +1,64 @@
from __future__ import annotations
import pytest
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer, ChatTextArea
@pytest.mark.asyncio
async def test_shift_backspace_deletes_character_like_backspace() -> None:
app = build_test_vibe_app()
async with app.run_test() as pilot:
await pilot.pause(0.1)
text_area = app.query_one(ChatTextArea)
text_area.focus()
await pilot.pause(0.1)
await pilot.press("a", "b", "c")
await pilot.pause(0.1)
assert app.query_one(ChatInputContainer).value == "abc"
await pilot.press("shift+backspace")
await pilot.pause(0.1)
assert app.query_one(ChatInputContainer).value == "ab"
@pytest.mark.asyncio
async def test_shift_delete_deletes_character_like_delete() -> None:
app = build_test_vibe_app()
async with app.run_test() as pilot:
await pilot.pause(0.1)
text_area = app.query_one(ChatTextArea)
text_area.focus()
await pilot.pause(0.1)
await pilot.press("a", "b", "c", "left")
await pilot.pause(0.1)
assert app.query_one(ChatInputContainer).value == "abc"
await pilot.press("shift+delete")
await pilot.pause(0.1)
assert app.query_one(ChatInputContainer).value == "ab"
@pytest.mark.asyncio
async def test_shift_backspace_resets_mode_when_empty() -> None:
app = build_test_vibe_app()
async with app.run_test() as pilot:
await pilot.pause(0.1)
text_area = app.query_one(ChatTextArea)
text_area.focus()
text_area.set_mode("!")
await pilot.pause(0.1)
assert text_area.input_mode == "!"
await pilot.press("shift+backspace")
await pilot.pause(0.1)
assert text_area.input_mode == ">"

View file

@ -0,0 +1,149 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.textual_ui.app import _ImageAttachmentRejection
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import ErrorMessage
from vibe.core.autocompletion.path_prompt import build_path_prompt_payload
from vibe.core.config import ModelConfig, ProviderConfig
from vibe.core.types import (
MAX_IMAGE_BYTES,
MAX_IMAGES_PER_MESSAGE,
Backend,
ImageAttachment,
)
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16
def _vision_config(*, supports_images: bool = True):
models = [
ModelConfig(
name="mistral-vibe-cli-latest",
provider="mistral",
alias="devstral-latest",
supports_images=supports_images,
)
]
providers = [
ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
)
]
return build_test_vibe_config(
active_model="devstral-latest", models=models, providers=providers
)
@pytest.mark.asyncio
async def test_build_image_attachments_happy_path(tmp_path: Path) -> None:
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
app = build_test_vibe_app(config=_vision_config())
result = await app._build_image_attachments(payload)
assert isinstance(result, list)
assert len(result) == 1
assert isinstance(result[0], ImageAttachment)
assert result[0].alias == "shot.png"
assert result[0].mime_type == "image/png"
@pytest.mark.asyncio
async def test_build_image_attachments_returns_empty_when_no_images(
tmp_path: Path,
) -> None:
(tmp_path / "notes.md").write_text("hi")
payload = build_path_prompt_payload("read @notes.md", base_dir=tmp_path)
app = build_test_vibe_app(config=_vision_config())
result = await app._build_image_attachments(payload)
assert result == []
@pytest.mark.asyncio
async def test_build_image_attachments_rejects_too_many_images(tmp_path: Path) -> None:
mentions = []
for i in range(MAX_IMAGES_PER_MESSAGE + 1):
name = f"img{i}.png"
(tmp_path / name).write_bytes(PNG_BYTES)
mentions.append(f"@{name}")
payload = build_path_prompt_payload(" ".join(mentions), base_dir=tmp_path)
app = build_test_vibe_app(config=_vision_config())
result = await app._build_image_attachments(payload)
assert isinstance(result, _ImageAttachmentRejection)
assert not result.no_vision
assert "Too many image attachments" in result.message
assert str(MAX_IMAGES_PER_MESSAGE) in result.message
@pytest.mark.asyncio
async def test_build_image_attachments_rejects_non_vision_model(tmp_path: Path) -> None:
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
app = build_test_vibe_app(config=_vision_config(supports_images=False))
result = await app._build_image_attachments(payload)
assert isinstance(result, _ImageAttachmentRejection)
assert result.no_vision
assert "does not support images" in result.message
assert "devstral-latest" in result.message
@pytest.mark.asyncio
async def test_build_image_attachments_rejects_oversize_image(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Patch the cap down so we don't need to write 10MB to disk.
monkeypatch.setattr("vibe.cli.textual_ui.app.MAX_IMAGE_BYTES", 32)
(tmp_path / "shot.png").write_bytes(PNG_BYTES + b"\x00" * 64)
payload = build_path_prompt_payload("look at @shot.png", base_dir=tmp_path)
app = build_test_vibe_app(config=_vision_config())
result = await app._build_image_attachments(payload)
assert isinstance(result, _ImageAttachmentRejection)
assert not result.no_vision
assert "shot.png" in result.message
assert "max" in result.message.lower()
def test_max_image_bytes_default_is_10_mib() -> None:
assert MAX_IMAGE_BYTES == 10 * 1024 * 1024
def test_max_images_per_message_default_is_8() -> None:
assert MAX_IMAGES_PER_MESSAGE == 8
@pytest.mark.asyncio
async def test_submit_restores_input_when_image_validation_fails(
tmp_path: Path,
) -> None:
(tmp_path / "shot.png").write_bytes(PNG_BYTES)
app = build_test_vibe_app(config=_vision_config(supports_images=False))
typed = f"look at @{tmp_path / 'shot.png'}"
async with app.run_test() as pilot:
chat_input = app.query_one(ChatInputContainer)
chat_input.value = typed
await pilot.press("enter")
await pilot.pause()
assert chat_input.value == typed
errors = list(app.query(ErrorMessage))
assert errors
assert all(not e._show_border for e in errors)

View file

@ -0,0 +1,221 @@
from __future__ import annotations
from pathlib import Path
import pytest
from textual import events
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.chat_input.paste_path import (
maybe_prepend_at_for_image_path,
rewrite_bare_image_paths_in_text,
)
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
def test_bare_absolute_image_path_gets_at_prefix(tmp_path: Path) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
rewritten = maybe_prepend_at_for_image_path(str(img))
assert rewritten == f"@{img}"
def test_quoted_image_path_with_spaces_is_unwrapped_and_quoted(tmp_path: Path) -> None:
img = tmp_path / "has space.png"
img.write_bytes(b"\x89PNG")
pasted = f"'{img}'"
rewritten = maybe_prepend_at_for_image_path(pasted)
assert rewritten == f"@'{img}'"
def test_backslash_escaped_image_path_is_unescaped_and_quoted(tmp_path: Path) -> None:
img = tmp_path / "has space.png"
img.write_bytes(b"\x89PNG")
pasted = str(img).replace(" ", "\\ ")
rewritten = maybe_prepend_at_for_image_path(pasted)
assert rewritten == f"@'{img}'"
def test_non_image_file_path_is_left_untouched(tmp_path: Path) -> None:
txt = tmp_path / "readme.md"
txt.write_text("hi")
rewritten = maybe_prepend_at_for_image_path(str(txt))
assert rewritten == str(txt)
def test_missing_image_path_is_left_untouched(tmp_path: Path) -> None:
missing = tmp_path / "nope.png"
rewritten = maybe_prepend_at_for_image_path(str(missing))
assert rewritten == str(missing)
def test_unresolvable_tilde_user_does_not_crash() -> None:
# `~a` raises RuntimeError from Path.expanduser() when user `a` does not
# exist; the rewrite hook must swallow it so every keystroke after `~`
# does not crash the TUI.
assert maybe_prepend_at_for_image_path("~a") == "~a"
assert rewrite_bare_image_paths_in_text("hello ~a world") == "hello ~a world"
def test_multiline_paste_is_left_untouched(tmp_path: Path) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
pasted = f"{img}\nother line"
assert maybe_prepend_at_for_image_path(pasted) == pasted
def test_relative_path_is_left_untouched(tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "shot.png").write_bytes(b"\x89PNG")
assert maybe_prepend_at_for_image_path("shot.png") == "shot.png"
def test_already_at_prefixed_path_is_left_untouched(tmp_path: Path) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
pasted = f"@{img}"
assert maybe_prepend_at_for_image_path(pasted) == pasted
@pytest.mark.asyncio
async def test_paste_event_inserts_at_prefixed_path_into_chat_input(
vibe_app: VibeApp, tmp_path: Path
) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
text_area = chat_input.query_one(ChatTextArea)
text_area.focus()
text_area.post_message(events.Paste(text=str(img)))
await pilot.pause()
assert chat_input.value == f"@{img}"
@pytest.mark.asyncio
async def test_paste_event_leaves_non_image_paths_untouched(
vibe_app: VibeApp, tmp_path: Path
) -> None:
txt = tmp_path / "notes.md"
txt.write_text("hi")
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
text_area = chat_input.query_one(ChatTextArea)
text_area.focus()
text_area.post_message(events.Paste(text=str(txt)))
await pilot.pause()
assert chat_input.value == str(txt)
def test_rewrite_bare_image_paths_handles_bare_path(tmp_path: Path) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
rewritten = rewrite_bare_image_paths_in_text(f"look at {img} please")
assert rewritten == f"look at @{img} please"
def test_rewrite_bare_image_paths_handles_quoted_path(tmp_path: Path) -> None:
img = tmp_path / "has space.png"
img.write_bytes(b"\x89PNG")
rewritten = rewrite_bare_image_paths_in_text(f"look at '{img}'")
assert rewritten == f"look at @'{img}'"
def test_rewrite_bare_image_paths_is_idempotent(tmp_path: Path) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
once = rewrite_bare_image_paths_in_text(f"see {img}")
twice = rewrite_bare_image_paths_in_text(once)
assert once == twice == f"see @{img}"
def test_rewrite_bare_image_paths_skips_non_image(tmp_path: Path) -> None:
txt = tmp_path / "notes.md"
txt.write_text("hi")
rewritten = rewrite_bare_image_paths_in_text(f"see {txt}")
assert rewritten == f"see {txt}"
def test_rewrite_bare_image_paths_fast_path_skips_stat_for_plain_text(
monkeypatch,
) -> None:
from pathlib import Path as _Path
calls = 0
original = _Path.is_file
def _counting_is_file(self):
nonlocal calls
calls += 1
return original(self)
monkeypatch.setattr(_Path, "is_file", _counting_is_file)
rewrite_bare_image_paths_in_text("hello world, nothing path-shaped here")
rewrite_bare_image_paths_in_text("multi\nline\ntext\nwith no slash")
assert calls == 0
@pytest.mark.asyncio
async def test_text_change_hook_rewrites_quoted_image_path(
vibe_app: VibeApp, tmp_path: Path
) -> None:
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
text_area = chat_input.query_one(ChatTextArea)
text_area.focus()
# Simulate a non-bracketed-paste insertion (terminal that does
# not emit Paste): set the text directly the way a bulk insert
# would land it.
text_area.text = f"'{img}'"
await pilot.pause()
# No spaces in the path -> the scanner emits an unquoted `@<path>`.
assert chat_input.value == f"@{img}"
@pytest.mark.asyncio
async def test_text_change_hook_rewrites_quoted_image_path_with_spaces(
vibe_app: VibeApp, tmp_path: Path
) -> None:
img = tmp_path / "has space.png"
img.write_bytes(b"\x89PNG")
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
text_area = chat_input.query_one(ChatTextArea)
text_area.focus()
text_area.text = f"'{img}'"
await pilot.pause()
assert chat_input.value == f"@'{img}'"

View file

@ -0,0 +1,18 @@
from __future__ import annotations
from vibe.cli.textual_ui.widgets.tool_widgets import _strip_line_numbers
def test_strips_numbered_prefixes() -> None:
content = " 1→first\n 42→second\n 100→third"
assert _strip_line_numbers(content) == "first\nsecond\nthird"
def test_leaves_warning_lines_untouched() -> None:
content = "<vibe_warning>Warning: the file exists but the contents are empty.</vibe_warning>"
assert _strip_line_numbers(content) == content
def test_preserves_arrows_inside_content() -> None:
content = " 1→a → b → c"
assert _strip_line_numbers(content) == "a → b → c"

View file

@ -0,0 +1,78 @@
from __future__ import annotations
from pathlib import Path
from weakref import WeakKeyDictionary
from vibe.cli.textual_ui.widgets.messages import UserMessage
from vibe.cli.textual_ui.windowing.history import build_history_widgets
from vibe.core.types import ImageAttachment, LLMMessage, Role
def _att(path: Path, alias: str) -> ImageAttachment:
path.write_bytes(b"\x89PNG")
return ImageAttachment(path=path, alias=alias, mime_type="image/png")
def test_attachments_footer_singular(tmp_path: Path) -> None:
att = _att(tmp_path / "shot.png", "shot.png")
rendered = UserMessage._format_attachments_footer([att])
assert "attached image:" in rendered
assert '[link="file://' in rendered
assert "]shot.png[/link]" in rendered
def test_attachments_footer_plural(tmp_path: Path) -> None:
a = _att(tmp_path / "a.png", "a.png")
b = _att(tmp_path / "b.png", "b.png")
rendered = UserMessage._format_attachments_footer([a, b])
assert "attached images:" in rendered
assert rendered.count('[link="file://') == 2
assert "a.png" in rendered
assert "b.png" in rendered
def test_attachments_footer_escapes_alias_brackets(tmp_path: Path) -> None:
att = _att(tmp_path / "shot.png", "weird [bracket].png")
rendered = UserMessage._format_attachments_footer([att])
# Rich's escape() turns "[" into "\[".
assert "\\[bracket]" in rendered
def test_resumed_user_message_with_images_renders_footer(tmp_path: Path) -> None:
att = _att(tmp_path / "shot.png", "shot.png")
msg = LLMMessage(role=Role.user, content="look at @shot.png", images=[att])
widgets = build_history_widgets(
[msg],
tool_call_map={},
start_index=0,
tools_collapsed=False,
history_widget_indices=WeakKeyDictionary(),
)
assert len(widgets) == 1
user_widget = widgets[0]
assert isinstance(user_widget, UserMessage)
assert user_widget._images == [att]
def test_resumed_user_message_with_images_only_still_mounts(tmp_path: Path) -> None:
att = _att(tmp_path / "shot.png", "shot.png")
msg = LLMMessage(role=Role.user, content="", images=[att])
widgets = build_history_widgets(
[msg],
tool_call_map={},
start_index=0,
tools_collapsed=False,
history_widget_indices=WeakKeyDictionary(),
)
assert len(widgets) == 1
assert isinstance(widgets[0], UserMessage)