Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Cyprien <courtot.c@gmail.com>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@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: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: josephine-delas <57808586+josephine-delas@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Laure Hugo 2026-06-25 16:08:45 +02:00 committed by GitHub
parent 725d3a56ce
commit e607ccbb00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
242 changed files with 7372 additions and 1974 deletions

View file

@ -1,6 +1,8 @@
from __future__ import annotations
import pytest
from textual.geometry import Offset
from textual.selection import Selection
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer, ChatTextArea
@ -62,3 +64,46 @@ async def test_shift_backspace_resets_mode_when_empty() -> None:
await pilot.pause(0.1)
assert text_area.input_mode == ">"
@pytest.mark.asyncio
async def test_selection_cleared_when_focus_leaves_text_area() -> 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)
text_area.load_text("hello world")
text_area.select_all()
await pilot.pause(0.1)
assert text_area.selected_text == "hello world"
app.query_one("#chat").focus()
await pilot.pause(0.1)
assert text_area.selected_text == ""
@pytest.mark.asyncio
async def test_blur_does_not_clear_other_widget_selection() -> 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.load_text("hello world")
text_area.select_all()
await pilot.pause(0.1)
chat = app.query_one("#chat")
sentinel = {chat: Selection(Offset(0, 0), Offset(0, 1))}
app.screen.selections = sentinel
text_area.screen.set_focus(chat)
await pilot.pause(0.1)
assert text_area.selected_text == ""
assert app.screen.selections == sentinel

View file

@ -0,0 +1,35 @@
from __future__ import annotations
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
def test_context_progress_shows_percentage_when_empty() -> None:
widget = ContextProgress()
widget.watch_tokens(TokenState(max_tokens=200_000, current_tokens=0))
assert str(widget.render()) == "0/200k tokens (0%)"
def test_context_progress_uses_compact_integer_format_for_used_tokens() -> None:
widget = ContextProgress()
widget.watch_tokens(TokenState(max_tokens=200_000, current_tokens=12_500))
assert str(widget.render()) == "12k/200k tokens (6%)"
def test_context_progress_uses_compact_integer_k_format() -> None:
widget = ContextProgress()
widget.watch_tokens(TokenState(max_tokens=568_000, current_tokens=170_000))
assert str(widget.render()) == "170k/568k tokens (30%)"
def test_context_progress_uses_compact_integer_m_format() -> None:
widget = ContextProgress()
widget.watch_tokens(TokenState(max_tokens=40_000_000, current_tokens=35_900_000))
assert str(widget.render()) == "35.9M/40.0M tokens (90%)"

View file

@ -0,0 +1,162 @@
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
def test_importing_tui_app_does_not_import_deferred_startup_modules() -> None:
code = """
import sys
import vibe.cli.textual_ui.app
blocked = [
"vibe.cli.textual_ui.widgets.connector_auth_app",
"vibe.cli.textual_ui.widgets.mcp_app",
"vibe.core.agent_loop",
"vibe.core.tools.connectors.connector_registry",
"vibe.core.tools.mcp.tools",
"mcp",
"git",
]
loaded = [name for name in blocked if name in sys.modules]
if loaded:
raise SystemExit(f"unexpected startup modules loaded: {loaded}")
"""
result = subprocess.run(
[sys.executable, "-c", code], check=False, capture_output=True, text=True
)
assert result.returncode == 0, result.stderr or result.stdout
def test_importing_agent_loop_does_not_import_remote_tool_modules() -> None:
code = """
import sys
import vibe.core.agent_loop
blocked = [
"vibe.core.tools.connectors.connector_registry",
"vibe.core.tools.mcp.tools",
"vibe.core.teleport.git",
"vibe.core.teleport.teleport",
"mcp",
"git",
]
loaded = [name for name in blocked if name in sys.modules]
if loaded:
raise SystemExit(f"unexpected agent loop modules loaded: {loaded}")
"""
result = subprocess.run(
[sys.executable, "-c", code], check=False, capture_output=True, text=True
)
assert result.returncode == 0, result.stderr or result.stdout
def test_importing_connector_registry_does_not_import_mcp_runtime() -> None:
code = """
import sys
import vibe.core.tools.connectors.connector_registry
blocked = [
"vibe.core.tools.mcp.tools",
"mcp",
]
loaded = [name for name in blocked if name in sys.modules]
if loaded:
raise SystemExit(f"unexpected connector registry modules loaded: {loaded}")
"""
result = subprocess.run(
[sys.executable, "-c", code], check=False, capture_output=True, text=True
)
assert result.returncode == 0, result.stderr or result.stdout
def test_importing_mcp_app_does_not_import_mcp_runtime() -> None:
code = """
import sys
import vibe.cli.textual_ui.widgets.mcp_app
blocked = [
"vibe.core.tools.mcp.tools",
"mcp",
]
loaded = [name for name in blocked if name in sys.modules]
if loaded:
raise SystemExit(f"unexpected mcp app modules loaded: {loaded}")
"""
result = subprocess.run(
[sys.executable, "-c", code], check=False, capture_output=True, text=True
)
assert result.returncode == 0, result.stderr or result.stdout
def test_constructing_deferred_agent_loop_does_not_import_mcp_package(
tmp_path: Path,
) -> None:
code = """
import sys
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.config.harness_files import (
init_harness_files_manager,
reset_harness_files_manager,
)
class Backend:
async def complete(self, **kwargs):
raise AssertionError
async def __aexit__(self, *args):
return None
init_harness_files_manager("user", "project")
try:
config = VibeConfig(
enable_connectors=False,
session_logging=SessionLoggingConfig(enabled=False),
)
loop = AgentLoop(
config=config,
backend=Backend(),
defer_heavy_init=True,
headless=True,
)
if loop._deferred_init_thread is not None:
loop._deferred_init_thread.join()
finally:
reset_harness_files_manager()
blocked = [
"vibe.core.tools.mcp.tools",
"mcp",
]
loaded = [name for name in blocked if name in sys.modules]
if loaded:
raise SystemExit(f"unexpected deferred agent loop modules loaded: {loaded}")
"""
result = subprocess.run(
[sys.executable, "-c", code],
check=False,
capture_output=True,
text=True,
env={
**os.environ,
"VIBE_HOME": str(tmp_path),
"VIBE_TEST_DISABLE_KEYRING": "1",
},
)
assert result.returncode == 0, result.stderr or result.stdout

View file

@ -0,0 +1,500 @@
from __future__ import annotations
import importlib
from pathlib import Path
import platform
import subprocess
from unittest.mock import patch
import pytest
from textual import events
from textual.app import App, ComposeResult
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.commands import CommandRegistry
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input import paste_image
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.chat_input.paste_image import (
_read_macos,
_read_macos_class,
handle_clipboard_image_paste,
read_clipboard_image,
write_clipboard_image,
)
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.core.config import ModelConfig, ProviderConfig
from vibe.core.types import Backend
_PNG_HEADER = b"\x89PNG\r\n\x1a\n"
_FAKE_PNG = _PNG_HEADER + b"fake-image-payload"
def _build_vision_app(*, supports_images: bool = True) -> VibeApp:
config = build_test_vibe_config(
active_model="devstral-latest",
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_app(config=config)
def _completed(stdout: bytes = b"", returncode: int = 0) -> subprocess.CompletedProcess:
return subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout)
@pytest.fixture
def _force_supported_platform(monkeypatch) -> None:
monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True)
async def _press_ctrl_v_under_platform(monkeypatch, system: str) -> tuple[bool, list]:
# The ctrl+v binding is registered at ChatTextArea class-definition time only
# on macOS, so the result of pressing it depends on the OS. Reload the module
# under a forced platform.system() to get a deterministically-defined class,
# then exercise it in a minimal app (the full app shares binding maps across
# instances via a shallow copy, which makes a host-dependent setup flaky).
monkeypatch.setattr(platform, "system", lambda: system)
text_area_module = importlib.import_module(
"vibe.cli.textual_ui.widgets.chat_input.text_area"
)
importlib.reload(text_area_module)
try:
chat_text_area = text_area_module.ChatTextArea
has_binding = any(b.key == "ctrl+v" for b in chat_text_area.BINDINGS)
class _MiniApp(App):
def compose(self) -> ComposeResult:
yield chat_text_area(command_registry=CommandRegistry())
posted: list = []
async with _MiniApp().run_test() as pilot:
text_area = pilot.app.query_one(chat_text_area)
text_area.focus()
await pilot.pause()
original_post = text_area.post_message
def wrapped(message):
if isinstance(message, chat_text_area.ClipboardImagePasted):
posted.append(message)
return original_post(message)
text_area.post_message = wrapped
await pilot.press("ctrl+v")
await pilot.pause()
return has_binding, posted
finally:
monkeypatch.undo()
importlib.reload(text_area_module)
def test_read_clipboard_image_returns_none_when_no_readers(
monkeypatch, _force_supported_platform
) -> None:
monkeypatch.setattr(paste_image, "_readers_for_platform", lambda: [])
assert read_clipboard_image() is None
def test_read_clipboard_image_skips_non_png_bytes(
monkeypatch, _force_supported_platform
) -> None:
monkeypatch.setattr(
paste_image, "_readers_for_platform", lambda: [lambda: b"not-a-png"]
)
assert read_clipboard_image() is None
def test_read_clipboard_image_returns_first_png(
monkeypatch, _force_supported_platform
) -> None:
monkeypatch.setattr(
paste_image,
"_readers_for_platform",
lambda: [lambda: None, lambda: _FAKE_PNG, lambda: b"unused"],
)
assert read_clipboard_image() == _FAKE_PNG
def test_read_clipboard_image_swallows_reader_exceptions(
monkeypatch, _force_supported_platform
) -> None:
def _raises() -> bytes | None:
raise RuntimeError("boom")
monkeypatch.setattr(
paste_image, "_readers_for_platform", lambda: [_raises, lambda: _FAKE_PNG]
)
assert read_clipboard_image() == _FAKE_PNG
def test_macos_class_reader_returns_bytes_when_osascript_succeeds(monkeypatch) -> None:
def fake_run(cmd, **kwargs):
# osascript wrote the temp file before returning 0; mimic that.
script_text = cmd[2]
prefix = 'set targetFile to POSIX file "'
start = script_text.index(prefix) + len(prefix)
end = script_text.index('"', start)
Path(script_text[start:end]).write_bytes(_FAKE_PNG)
return _completed()
monkeypatch.setattr(subprocess, "run", fake_run)
assert _read_macos_class("PNGf") == _FAKE_PNG
def test_macos_class_reader_returns_none_when_osascript_fails(monkeypatch) -> None:
monkeypatch.setattr(subprocess, "run", lambda *a, **k: _completed(returncode=1))
assert _read_macos_class("PNGf") is None
def test_macos_reader_falls_back_to_tiff_when_png_missing(monkeypatch) -> None:
fake_tiff = b"II*\x00fake-tiff"
calls: list[str] = []
# `sips` only exists on macOS; force the which() probe so the conversion
# path runs (and hits the mocked subprocess.run) on Linux CI too.
monkeypatch.setattr(paste_image.shutil, "which", lambda _name: "/usr/bin/sips")
def fake_run(cmd, **kwargs):
if cmd[0] == "osascript":
script_text = cmd[2]
if "PNGf" in script_text:
calls.append("PNGf")
return _completed(returncode=1)
calls.append("TIFF")
prefix = 'set targetFile to POSIX file "'
start = script_text.index(prefix) + len(prefix)
end = script_text.index('"', start)
Path(script_text[start:end]).write_bytes(fake_tiff)
return _completed()
# sips invocation
calls.append("sips")
src = Path(cmd[cmd.index("--out") - 1])
out = Path(cmd[cmd.index("--out") + 1])
# Mimic conversion: write a fake PNG to the --out path.
assert src.read_bytes() == fake_tiff
out.write_bytes(_FAKE_PNG)
return _completed()
monkeypatch.setattr(subprocess, "run", fake_run)
assert _read_macos() == _FAKE_PNG
assert calls == ["PNGf", "TIFF", "sips"]
def test_reader_timeout_is_swallowed(monkeypatch) -> None:
def fake_run(*args, **kwargs):
raise subprocess.TimeoutExpired(cmd=args[0], timeout=1.0)
monkeypatch.setattr(subprocess, "run", fake_run)
monkeypatch.setattr(paste_image, "_readers_for_platform", lambda: [_read_macos])
monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True)
assert read_clipboard_image() is None
def test_read_clipboard_image_returns_none_on_unsupported_platform(monkeypatch) -> None:
monkeypatch.setattr(
paste_image, "is_clipboard_image_paste_supported", lambda: False
)
# Even if a reader were defined, the platform gate short-circuits.
monkeypatch.setattr(
paste_image, "_readers_for_platform", lambda: [lambda: _FAKE_PNG]
)
assert read_clipboard_image() is None
def test_write_clipboard_image_uses_filename_without_whitespace(tmp_path) -> None:
path = write_clipboard_image(_FAKE_PNG, session_dir=tmp_path)
assert path.parent == tmp_path / "attachments"
assert not any(char.isspace() for char in path.name)
@pytest.mark.asyncio
async def test_empty_paste_posts_clipboard_image_pasted_message(
vibe_app: VibeApp,
) -> None:
posted: list[ChatTextArea.ClipboardImagePasted] = []
def capture(message) -> None:
if isinstance(message, ChatTextArea.ClipboardImagePasted):
posted.append(message)
async with vibe_app.run_test() as pilot:
text_area = vibe_app.query_one(ChatTextArea)
text_area.focus()
original_post = text_area.post_message
def wrapped(message):
capture(message)
return original_post(message)
text_area.post_message = wrapped # type: ignore[method-assign]
text_area.post_message(events.Paste(text=""))
await pilot.pause()
assert len(posted) == 1
@pytest.mark.asyncio
async def test_non_empty_paste_does_not_post_clipboard_image_pasted(
vibe_app: VibeApp,
) -> None:
posted: list[ChatTextArea.ClipboardImagePasted] = []
async with vibe_app.run_test() as pilot:
text_area = vibe_app.query_one(ChatTextArea)
text_area.focus()
original_post = text_area.post_message
def wrapped(message):
if isinstance(message, ChatTextArea.ClipboardImagePasted):
posted.append(message)
return original_post(message)
text_area.post_message = wrapped # type: ignore[method-assign]
text_area.post_message(events.Paste(text="hello world"))
await pilot.pause()
assert posted == []
@pytest.mark.asyncio
async def test_handle_clipboard_image_paste_writes_file_and_inserts_token() -> None:
app = _build_vision_app()
notifications: list[str] = []
with (
patch(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
return_value=_FAKE_PNG,
),
patch(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
return_value=True,
),
):
async with app.run_test() as pilot:
text_area = app.query_one(ChatTextArea)
text_area.focus()
with patch.object(
app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg)
):
await handle_clipboard_image_paste(app, notify_when_empty=False)
await pilot.pause()
chat_input = app.query_one(ChatInputContainer)
value = chat_input.value
assert value.startswith("@")
assert value.endswith(".png ")
assert "clipboard-" in value
assert len(notifications) == 1
assert notifications[0].startswith("Image pasted as clipboard-")
assert notifications[0].endswith(".png (26 Bytes)")
@pytest.mark.asyncio
async def test_handle_clipboard_image_paste_warns_when_token_insert_fails(
tmp_path, monkeypatch
) -> None:
app = _build_vision_app()
path = tmp_path / "clipboard.png"
notifications: list[tuple[str, str | None]] = []
def fake_write_clipboard_image(data: bytes, *, session_dir: Path | None) -> Path:
path.write_bytes(data)
return path
def fake_query_one(*_args, **_kwargs):
raise RuntimeError("not mounted")
monkeypatch.setattr(paste_image, "is_clipboard_image_paste_supported", lambda: True)
monkeypatch.setattr(paste_image, "read_clipboard_image", lambda: _FAKE_PNG)
monkeypatch.setattr(
paste_image, "write_clipboard_image", fake_write_clipboard_image
)
monkeypatch.setattr(app, "query_one", fake_query_one)
monkeypatch.setattr(
app,
"notify",
lambda msg, **kwargs: notifications.append((msg, kwargs.get("severity"))),
)
await handle_clipboard_image_paste(app, notify_when_empty=False)
assert not path.exists()
assert notifications == [("Failed to paste image into prompt.", "warning")]
@pytest.mark.asyncio
async def test_handle_clipboard_image_paste_noop_when_clipboard_empty() -> None:
app = _build_vision_app()
with (
patch(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
return_value=None,
),
patch(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
return_value=True,
),
):
async with app.run_test() as pilot:
text_area = app.query_one(ChatTextArea)
text_area.focus()
await handle_clipboard_image_paste(app, notify_when_empty=False)
await pilot.pause()
assert app.query_one(ChatInputContainer).value == ""
@pytest.mark.asyncio
async def test_handle_clipboard_image_paste_blocks_when_model_no_vision() -> None:
app = _build_vision_app(supports_images=False)
with (
patch(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
return_value=_FAKE_PNG,
),
patch(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
return_value=True,
),
):
async with app.run_test() as pilot:
text_area = app.query_one(ChatTextArea)
text_area.focus()
await handle_clipboard_image_paste(app, notify_when_empty=False)
await pilot.pause()
assert app.query_one(ChatInputContainer).value == ""
@pytest.mark.asyncio
async def test_handle_clipboard_image_paste_rejects_oversize(monkeypatch) -> None:
app = _build_vision_app()
notifications: list[str] = []
monkeypatch.setattr(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.MAX_IMAGE_BYTES", 10
)
with (
patch(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
return_value=_FAKE_PNG,
),
patch(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
return_value=True,
),
):
async with app.run_test() as pilot:
text_area = app.query_one(ChatTextArea)
text_area.focus()
with patch.object(
app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg)
):
await handle_clipboard_image_paste(app, notify_when_empty=False)
await pilot.pause()
assert app.query_one(ChatInputContainer).value == ""
assert notifications == ["Clipboard image is 26 Bytes; max is 10 Bytes."]
def test_paste_image_slash_command_hidden_on_unsupported_platform(monkeypatch) -> None:
from vibe.cli.commands import CommandRegistry
monkeypatch.setattr("platform.system", lambda: "Linux")
registry = CommandRegistry()
assert not registry.has_command("paste-image")
def test_paste_image_slash_command_available_on_darwin(monkeypatch) -> None:
from vibe.cli.commands import CommandRegistry
monkeypatch.setattr("platform.system", lambda: "Darwin")
registry = CommandRegistry()
assert registry.has_command("paste-image")
def test_ctrl_v_binding_absent_on_unsupported_platform(monkeypatch) -> None:
# Bindings are evaluated at class-definition time, so we have to
# re-import the module after patching platform.system().
import importlib
from vibe.cli.textual_ui.widgets.chat_input import text_area as text_area_module
monkeypatch.setattr("platform.system", lambda: "Linux")
reloaded = importlib.reload(text_area_module)
try:
assert all(b.key != "ctrl+v" for b in reloaded.ChatTextArea.BINDINGS)
finally:
importlib.reload(text_area_module)
@pytest.mark.asyncio
@pytest.mark.parametrize("notify_when_empty", [True, False])
async def test_handle_clipboard_image_paste_is_silent_on_unsupported_platform(
notify_when_empty: bool,
) -> None:
app = _build_vision_app()
notifications: list[str] = []
with patch(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
return_value=False,
):
async with app.run_test() as pilot:
with patch.object(
app, "notify", side_effect=lambda msg, **_kw: notifications.append(msg)
):
await handle_clipboard_image_paste(
app, notify_when_empty=notify_when_empty
)
await pilot.pause()
assert notifications == []
@pytest.mark.asyncio
async def test_ctrl_v_keybinding_triggers_image_paste_with_notify_on_darwin(
monkeypatch,
) -> None:
has_binding, posted = await _press_ctrl_v_under_platform(monkeypatch, "Darwin")
assert has_binding
assert len(posted) == 1
assert posted[0].notify_when_empty is True
@pytest.mark.asyncio
async def test_ctrl_v_keybinding_does_not_paste_image_on_linux(monkeypatch) -> None:
has_binding, posted = await _press_ctrl_v_under_platform(monkeypatch, "Linux")
assert not has_binding
assert posted == []
@pytest.mark.asyncio
async def test_insert_image_token_adds_leading_space_when_needed() -> None:
app = _build_vision_app()
with (
patch(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.read_clipboard_image",
return_value=_FAKE_PNG,
),
patch(
"vibe.cli.textual_ui.widgets.chat_input.paste_image.is_clipboard_image_paste_supported",
return_value=True,
),
):
async with app.run_test() as pilot:
text_area = app.query_one(ChatTextArea)
text_area.focus()
text_area.text = "look at this:"
text_area.move_cursor((0, len("look at this:")))
await handle_clipboard_image_paste(app, notify_when_empty=False)
await pilot.pause()
value = app.query_one(ChatInputContainer).value
assert " @" in value and value.startswith("look at this: @")

View file

@ -0,0 +1,88 @@
from __future__ import annotations
import asyncio
from unittest.mock import AsyncMock, Mock
import pytest
from tests.conftest import build_test_vibe_app
from vibe.cli.plan_offer.decide_plan_offer import PlanInfo
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
@pytest.mark.asyncio
async def test_startup_prompt_waits_for_startup_resume_picker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(initial_prompt="continue the work")
app._show_resume_picker = True
process_prompt = Mock()
monkeypatch.setattr(app, "_resolve_plan", AsyncMock())
monkeypatch.setattr(app, "_check_and_show_whats_new", AsyncMock())
monkeypatch.setattr(app, "_schedule_update_notification", Mock())
monkeypatch.setattr(app, "_process_initial_prompt", process_prompt)
await app._complete_post_ready_startup()
process_prompt.assert_not_called()
@pytest.mark.asyncio
async def test_startup_prompt_runs_after_startup_resume_picker_selection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(initial_prompt="continue the work")
app._show_resume_picker = True
app._startup_command_availability_ready.set()
process_prompt = Mock()
monkeypatch.setattr(app, "_switch_to_input_app", AsyncMock())
monkeypatch.setattr(app, "_resume_local_session", AsyncMock())
monkeypatch.setattr(app, "_process_initial_prompt", process_prompt)
await app.on_session_picker_app_session_selected(
SessionPickerApp.SessionSelected("local:session-1", "session-1")
)
assert app._show_resume_picker is False
process_prompt.assert_called_once_with()
@pytest.mark.asyncio
async def test_startup_teleport_waits_for_plan_resolution_after_session_selection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = build_test_vibe_app(initial_prompt="continue the work")
app._show_resume_picker = True
app._teleport_on_start = True
run_worker = Mock()
handle_teleport = Mock(return_value=object())
handle_user_message = Mock(return_value=object())
monkeypatch.setattr(app, "_switch_to_input_app", AsyncMock())
monkeypatch.setattr(app, "_resume_local_session", AsyncMock())
monkeypatch.setattr(app, "run_worker", run_worker)
monkeypatch.setattr(app, "_handle_teleport_command", handle_teleport)
monkeypatch.setattr(app, "_handle_user_message", handle_user_message)
monkeypatch.setattr(app.commands, "has_command", lambda name: name == "teleport")
task = asyncio.create_task(
app.on_session_picker_app_session_selected(
SessionPickerApp.SessionSelected("local:session-1", "session-1")
)
)
await asyncio.sleep(0)
handle_teleport.assert_not_called()
handle_user_message.assert_not_called()
app._plan_info = PlanInfo(WhoAmIPlanType.CHAT)
app._refresh_command_registry()
app._startup_command_availability_ready.set()
await task
handle_teleport.assert_called_once_with("continue the work")
handle_user_message.assert_not_called()
run_worker.assert_called_once_with(handle_teleport.return_value, exclusive=False)

View file

@ -0,0 +1,236 @@
from __future__ import annotations
import pytest
from textual.app import App, ComposeResult
from vibe.cli.textual_ui.widgets.links import (
LinkStatic,
link_markup,
linkify_urls_in_text,
)
from vibe.cli.textual_ui.widgets.tool_widgets import WebSearchResultWidget
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage
from vibe.core.tools.builtins.websearch import WebSearchResult, WebSearchSource
def test_link_markup_encodes_url_in_action_and_renders_label() -> None:
# The action arg is percent-encoded; the visible label is the page name.
assert (
link_markup("Example", "https://example.com")
== "[@click=open_url('https%3A%2F%2Fexample.com')]Example[/]"
)
def test_link_markup_only_links_http_schemes() -> None:
# Non-http(s) schemes render as the plain label, not a clickable @click span.
for url in ("file:///etc/passwd", "javascript:alert(1)", "vscode://x"):
assert link_markup(url, url) == url
assert "@click" not in link_markup(url, url)
def test_link_markup_handles_previously_unsafe_urls() -> None:
# Brackets, quotes, parens — all encoded into the action, never a fallback.
for url in (
"https://e.org/x[1]",
"https://e.org/it's",
"https://e.org/x)",
"https://en.wikipedia.org/wiki/Python_(programming_language)",
):
markup = link_markup(url, url)
assert markup.startswith("[@click=open_url('")
assert "[1]" not in markup.split("]", 1)[0] # raw bracket not in the tag
class _Harness(App):
def __init__(self, url: str = "https://example.com") -> None:
super().__init__()
self.url = url
self.opened: list[str] = []
def compose(self) -> ComposeResult:
yield LinkStatic(link_markup(self.url, self.url))
def open_url(self, url: str, *, new_tab: bool = True) -> None:
self.opened.append(url)
@pytest.mark.asyncio
async def test_clicking_link_span_opens_url() -> None:
app = _Harness()
async with app.run_test() as pilot:
await pilot.pause(0.1)
await pilot.click(LinkStatic)
await pilot.pause(0.1)
assert app.opened == ["https://example.com"]
@pytest.mark.asyncio
@pytest.mark.parametrize(
"url",
[
"https://en.wikipedia.org/wiki/Python_(programming_language)",
"https://httpbin.org/anything/test[1]",
"https://e.org/it's",
],
)
async def test_clicking_decodes_back_to_original_url(url: str) -> None:
app = _Harness(url)
async with app.run_test() as pilot:
await pilot.pause(0.1)
await pilot.click(LinkStatic)
await pilot.pause(0.1)
assert app.opened == [url]
@pytest.mark.asyncio
async def test_action_open_url_ignores_non_http_scheme() -> None:
from urllib.parse import quote
app = _Harness()
async with app.run_test() as pilot:
await pilot.pause(0.1)
app.query_one(LinkStatic).action_open_url(quote("file:///etc/passwd", safe=""))
await pilot.pause(0.1)
assert app.opened == []
@pytest.mark.asyncio
@pytest.mark.parametrize("scheme", ["javascript", "file", "data"])
async def test_unsafe_schemes_are_rejected(scheme: str) -> None:
app = _Harness(f"{scheme}:payload")
async with app.run_test() as pilot:
await pilot.pause(0.1)
await pilot.click(LinkStatic)
await pilot.pause(0.1)
assert app.opened == []
def test_linkify_urls_in_text_auto_detects_url() -> None:
# Once a tool is opted into linkification, URLs are found in the message
# itself — the call site doesn't have to point at the URL span.
markup = linkify_urls_in_text("Fetched https://example.com (10 chars, text/html)")
assert markup.startswith("Fetched ")
assert (
"[@click=open_url('https%3A%2F%2Fexample.com')]https://example.com[/]" in markup
)
def test_linkify_urls_in_text_handles_multiple_urls() -> None:
markup = linkify_urls_in_text("see https://a.com and https://b.com here")
assert "[@click=open_url('https%3A%2F%2Fa.com')]https://a.com[/]" in markup
assert "[@click=open_url('https%3A%2F%2Fb.com')]https://b.com[/]" in markup
def test_linkify_urls_in_text_keeps_balanced_parens_in_url() -> None:
# Wikipedia-style URLs with `(…)` were the reason the @click action is
# percent-encoded; Rich's URL detector already keeps them in the span.
url = "https://en.wikipedia.org/wiki/Python_(programming_language)"
markup = linkify_urls_in_text(f"see {url} for details")
assert link_markup(url, url) in markup
def test_linkify_urls_in_text_escapes_and_keeps_plain_when_no_url() -> None:
# Brackets must be escaped so raw tool text can't break the markup.
assert (
linkify_urls_in_text("Searched '[a]' (2 sources)")
== "Searched '\\[a]' (2 sources)"
)
async def _rendered_lines(widget: WebSearchResultWidget) -> list[str]:
from textual.widgets import Static
class _H(App):
def compose(self) -> ComposeResult:
yield widget
async with _H().run_test() as pilot:
await pilot.pause(0.1)
return [str(w.render()) for w in widget.query(Static)]
@pytest.mark.asyncio
async def test_websearch_single_source_is_bulleted_without_header() -> None:
result = WebSearchResult(
query="uv",
answer="ans",
sources=[WebSearchSource(title="Docs", url="https://x.com")],
)
lines = await _rendered_lines(
WebSearchResultWidget(result, success=True, message="m")
)
joined = "\n".join(lines)
assert "• Docs" in joined # bulleted, page name as the link label
assert "https://x.com" not in joined # url lives in the @click action, not text
assert "Source:" not in joined # singular "Source:" prefix dropped
assert "Sources:" not in joined # no header for a lone source
assert "WebSearchSource(" not in joined # raw `sources: [...]` dump dropped
@pytest.mark.asyncio
async def test_websearch_multiple_sources_is_bulleted_plural() -> None:
result = WebSearchResult(
query="uv",
answer="ans",
sources=[
WebSearchSource(title="A", url="https://a.com"),
WebSearchSource(title="B", url="https://b.com"),
],
)
lines = await _rendered_lines(
WebSearchResultWidget(result, success=True, message="m")
)
joined = "\n".join(lines)
assert "Sources:" in joined
assert "• A" in joined
assert "• B" in joined
assert "https://a.com" not in joined # url lives in the @click action, not text
assert "WebSearchSource(" not in joined
@pytest.mark.asyncio
async def test_tool_call_message_set_result_text_renders_clickable_url() -> None:
call = ToolCallMessage(tool_name="web_fetch")
class _H(App):
def compose(self) -> ComposeResult:
yield call
async with _H().run_test() as pilot:
await pilot.pause(0.1)
call.set_result_text(
"Fetched https://example.com (10 chars, text/html)", linkify=True
)
await pilot.pause(0.1)
rendered = str(call._text_widget.render()) if call._text_widget else ""
# The URL becomes a clickable span in the status line; surrounding text stays.
assert "https://example.com" in rendered
assert "Fetched" in rendered
@pytest.mark.asyncio
async def test_tool_call_message_set_result_text_escapes_when_linkify_off() -> None:
call = ToolCallMessage(tool_name="bash")
class _H(App):
def compose(self) -> ComposeResult:
yield call
async with _H().run_test() as pilot:
await pilot.pause(0.1)
# Bash isn't in the linkify whitelist; URLs must stay plain text and
# brackets in the message must not be interpreted as markup.
call.set_result_text("ran: see https://example.com [exit 0]")
await pilot.pause(0.1)
rendered = str(call._text_widget.render()) if call._text_widget else ""
assert "@click=open_url" not in rendered
assert "https://example.com" in rendered
assert "[exit 0]" in rendered

View file

@ -1,11 +1,23 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from weakref import WeakKeyDictionary
import pytest
from textual.app import App, ComposeResult
from textual.widgets import Link
from vibe.cli.textual_ui.widgets.messages import UserMessage
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.windowing.history import build_history_widgets
from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role
from vibe.core.types import (
FileImageSource,
ImageAttachment,
InlineImageSource,
LLMMessage,
Role,
)
def _att(path: Path, alias: str) -> ImageAttachment:
@ -15,35 +27,92 @@ def _att(path: Path, alias: str) -> ImageAttachment:
)
def test_attachments_footer_singular(tmp_path: Path) -> None:
att = _att(tmp_path / "shot.png", "shot.png")
class _UserMessageApp(App[None]):
CSS = """
.user-message-wrapper,
.user-message-container,
.user-message-attachments,
.user-message-attachment-line {
width: 100%;
height: auto;
}
rendered = UserMessage._format_attachments_footer([att])
.user-message-attachment-label,
.user-message-attachment-link {
width: auto;
height: auto;
}
"""
assert "attached image:" in rendered
assert '[link="file://' in rendered
assert "]shot.png[/link]" in rendered
def __init__(self, message: UserMessage) -> None:
super().__init__()
self._message = message
def compose(self) -> ComposeResult:
yield self._message
def test_attachments_footer_plural(tmp_path: Path) -> None:
@pytest.mark.asyncio
async def test_attachments_render_one_clickable_link_per_image(tmp_path: Path) -> None:
a = _att(tmp_path / "a.png", "a.png")
b = _att(tmp_path / "b.png", "b.png")
app = _UserMessageApp(UserMessage("look", images=[a, b]))
opened: list[str] = []
rendered = UserMessage._format_attachments_footer([a, b])
a_uri = (tmp_path / "a.png").as_uri()
b_uri = (tmp_path / "b.png").as_uri()
assert "attached images:" in rendered
assert rendered.count('[link="file://') == 2
assert "a.png" in rendered
assert "b.png" in rendered
async with app.run_test() as pilot:
links = list(app.query(Link))
assert [link.text for link in links] == ["a.png", "b.png"]
assert [link.url for link in links] == [a_uri, b_uri]
with patch.object(
app, "open_url", side_effect=lambda url, **_kwargs: opened.append(url)
):
await pilot.click(links[0])
assert opened == [a_uri]
def test_attachments_footer_escapes_alias_brackets(tmp_path: Path) -> None:
@pytest.mark.asyncio
async def test_inline_attachment_renders_label_without_link() -> None:
att = ImageAttachment(
source=InlineImageSource(data="aW1n"), alias="pasted.png", mime_type="image/png"
)
app = _UserMessageApp(UserMessage("look", images=[att]))
async with app.run_test():
assert list(app.query(Link)) == []
label = app.query_one(".user-message-attachment-link")
assert isinstance(label, NoMarkupStatic)
@pytest.mark.asyncio
async def test_attachment_link_renders_alias_brackets_literally(tmp_path: Path) -> None:
att = _att(tmp_path / "shot.png", "weird [bracket].png")
app = _UserMessageApp(UserMessage("look", images=[att]))
rendered = UserMessage._format_attachments_footer([att])
async with app.run_test():
link = app.query_one(Link)
# Rich's escape() turns "[" into "\[".
assert "\\[bracket]" in rendered
assert link.text == "weird [bracket].png"
@pytest.mark.asyncio
async def test_attachment_link_shortens_home_absolute_alias() -> None:
path = Path.home() / "Pictures" / "shot.png"
att = ImageAttachment(
source=FileImageSource(path=path), alias=str(path), mime_type="image/png"
)
app = _UserMessageApp(UserMessage("look", images=[att]))
async with app.run_test():
link = app.query_one(Link)
assert link.text == "~/Pictures/shot.png"
assert link.url == path.as_uri()
def test_resumed_user_message_with_images_renders_footer(tmp_path: Path) -> None: