Co-authored-by: Carlo <carloantonio.patti@mistral.ai>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com>
Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-authored-by: Thomas Kenbeek <thomas.kenbeek@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Quentin 2026-02-27 17:31:58 +01:00 committed by GitHub
parent a560a47ce8
commit 5d2e01a6d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
139 changed files with 7152 additions and 1457 deletions

View file

@ -0,0 +1,151 @@
from __future__ import annotations
from unittest.mock import MagicMock, PropertyMock
import pytest
from vibe.cli.textual_ui.notifications import (
NotificationContext,
TextualNotificationAdapter,
)
def _make_fake_app(*, is_headless: bool = False) -> MagicMock:
app = MagicMock()
type(app).is_headless = PropertyMock(return_value=is_headless)
return app
@pytest.fixture
def fake_app() -> MagicMock:
return _make_fake_app()
@pytest.fixture
def adapter_enabled(fake_app: MagicMock) -> TextualNotificationAdapter:
return TextualNotificationAdapter(
fake_app, get_enabled=lambda: True, default_title="Vibe"
)
@pytest.fixture
def adapter_disabled(fake_app: MagicMock) -> TextualNotificationAdapter:
return TextualNotificationAdapter(
fake_app, get_enabled=lambda: False, default_title="Vibe"
)
class TestTextualNotificationAdapter:
def test_no_notification_when_disabled(
self, adapter_disabled: TextualNotificationAdapter, fake_app: MagicMock
) -> None:
adapter_disabled.on_blur()
adapter_disabled.notify(NotificationContext.ACTION_REQUIRED)
fake_app.bell.assert_not_called()
def test_no_notification_when_focused(
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
) -> None:
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
fake_app.bell.assert_not_called()
def test_notification_sent_when_unfocused_and_enabled(
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
) -> None:
adapter_enabled.on_blur()
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
fake_app.bell.assert_called_once()
fake_app._driver.write.assert_called_once_with(
"\x1b]0;Vibe - Action Required\x07"
)
def test_throttle_prevents_rapid_notifications(
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
) -> None:
adapter_enabled.on_blur()
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
assert fake_app.bell.call_count == 1
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
assert fake_app.bell.call_count == 1
def test_contextual_title_for_action_required(
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
) -> None:
adapter_enabled.on_blur()
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
fake_app._driver.write.assert_called_once_with(
"\x1b]0;Vibe - Action Required\x07"
)
def test_contextual_title_for_complete(
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
) -> None:
adapter_enabled.on_blur()
adapter_enabled.notify(NotificationContext.COMPLETE)
fake_app._driver.write.assert_called_once_with(
"\x1b]0;Vibe - Task Complete\x07"
)
def test_restore_sets_default_title(
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
) -> None:
adapter_enabled.restore()
fake_app._driver.write.assert_called_once_with("\x1b]0;Vibe\x07")
def test_on_focus_restores_title(
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
) -> None:
adapter_enabled.on_blur()
adapter_enabled.on_focus()
fake_app._driver.write.assert_called_once_with("\x1b]0;Vibe\x07")
def test_on_focus_prevents_notifications(
self, adapter_enabled: TextualNotificationAdapter, fake_app: MagicMock
) -> None:
adapter_enabled.on_blur()
adapter_enabled.on_focus()
fake_app.reset_mock()
adapter_enabled.notify(NotificationContext.ACTION_REQUIRED)
fake_app.bell.assert_not_called()
def test_no_title_write_when_headless(self) -> None:
app = _make_fake_app(is_headless=True)
adapter = TextualNotificationAdapter(
app, get_enabled=lambda: True, default_title="Vibe"
)
adapter.on_blur()
adapter.notify(NotificationContext.ACTION_REQUIRED)
app.bell.assert_called_once()
app._driver.write.assert_not_called()
def test_enabled_callback_reads_live_value(self, fake_app: MagicMock) -> None:
enabled = True
adapter = TextualNotificationAdapter(
fake_app, get_enabled=lambda: enabled, default_title="Vibe"
)
adapter.on_blur()
adapter.notify(NotificationContext.ACTION_REQUIRED)
assert fake_app.bell.call_count == 1
enabled = False
adapter._last_notification_time = 0.0
adapter.notify(NotificationContext.ACTION_REQUIRED)
assert fake_app.bell.call_count == 1

View file

@ -52,3 +52,11 @@ class TestCommandRegistry:
assert registry.get_command_name("/exit") is None
assert registry.find_command("/exit") is None
assert registry.get_command_name("/help") == "help"
def test_resume_command_registration(self) -> None:
registry = CommandRegistry()
assert registry.get_command_name("/resume") == "resume"
assert registry.get_command_name("/continue") == "resume"
cmd = registry.find_command("/resume")
assert cmd is not None
assert cmd.handler == "_show_session_picker"

View file

@ -0,0 +1,109 @@
from __future__ import annotations
import pytest
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody, _PromptSpinner
from vibe.cli.textual_ui.widgets.messages import UserMessage
@pytest.mark.asyncio
async def test_submit_ignored_while_switching_mode() -> None:
"""Enter press during mode switch must not clear input or send a message."""
app = build_test_vibe_app()
async with app.run_test() as pilot:
await pilot.pause(0.1)
body = app.query_one(ChatInputBody)
body.switching_mode = True
await pilot.pause(0.1)
# Type some text and press enter
app.query_one(ChatInputContainer).value = "hello world"
await pilot.press("enter")
await pilot.pause(0.1)
# Text must remain in the input
assert app.query_one(ChatInputContainer).value == "hello world"
# No user message should have been posted
assert len(app.query(UserMessage)) == 0
@pytest.mark.asyncio
async def test_submit_works_after_switching_mode_ends() -> None:
"""After switching_mode is set back to False, Enter should work normally."""
app = build_test_vibe_app()
async with app.run_test() as pilot:
await pilot.pause(0.1)
body = app.query_one(ChatInputBody)
# Enable then disable switching mode
body.switching_mode = True
await pilot.pause(0.1)
body.switching_mode = False
await pilot.pause(0.1)
# Now submit should work
app.query_one(ChatInputContainer).value = "hello"
await pilot.press("enter")
await pilot.pause(0.1)
assert app.query_one(ChatInputContainer).value == ""
@pytest.mark.asyncio
async def test_spinner_shown_while_switching_mode() -> None:
"""Prompt widget is hidden and spinner is mounted when switching_mode is True."""
app = build_test_vibe_app()
async with app.run_test() as pilot:
await pilot.pause(0.1)
body = app.query_one(ChatInputBody)
prompt = body.prompt_widget
assert prompt is not None
assert prompt.display is True
assert len(body.query(_PromptSpinner)) == 0
body.switching_mode = True
await pilot.pause(0.1)
assert prompt.display is False
assert len(body.query(_PromptSpinner)) == 1
@pytest.mark.asyncio
async def test_spinner_removed_after_switching_mode_ends() -> None:
"""Prompt is restored and spinner removed when switching_mode becomes False."""
app = build_test_vibe_app()
async with app.run_test() as pilot:
await pilot.pause(0.1)
body = app.query_one(ChatInputBody)
body.switching_mode = True
await pilot.pause(0.1)
body.switching_mode = False
await pilot.pause(0.1)
assert body.prompt_widget is not None
assert body.prompt_widget.display is True
assert len(body.query(_PromptSpinner)) == 0
@pytest.mark.asyncio
async def test_rapid_switching_mode_no_duplicate_spinners() -> None:
"""Rapidly toggling switching_mode must never produce duplicate spinners."""
app = build_test_vibe_app()
async with app.run_test() as pilot:
await pilot.pause(0.1)
body = app.query_one(ChatInputBody)
# Rapidly toggle several times
for _ in range(5):
body.switching_mode = True
body.switching_mode = True # double set
await pilot.pause(0.1)
assert len(body.query(_PromptSpinner)) == 1

View file

@ -144,8 +144,9 @@ async def test_ui_session_incremental_loader_load_more_batches_until_done(
app.post_message(HistoryLoadMoreRequested())
await _wait_until(
pilot.pause,
lambda current_count=current_count: len(app.query(UserMessage))
> current_count,
lambda current_count=current_count: (
len(app.query(UserMessage)) > current_count
),
)
await _wait_until(

View file

View file

@ -0,0 +1,131 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import pytest
from vibe.cli.textual_ui.widgets.session_picker import (
SessionPickerApp,
_format_relative_time,
)
from vibe.core.session.session_loader import SessionInfo
@pytest.fixture
def sample_sessions() -> list[SessionInfo]:
return [
SessionInfo(
session_id="session-a",
cwd="/test",
title="Session A",
end_time=(datetime.now(UTC) - timedelta(minutes=5)).isoformat(),
),
SessionInfo(
session_id="session-b",
cwd="/test",
title="Session B",
end_time=(datetime.now(UTC) - timedelta(hours=1)).isoformat(),
),
SessionInfo(
session_id="session-c",
cwd="/test",
title="Session C",
end_time=(datetime.now(UTC) - timedelta(days=1)).isoformat(),
),
]
@pytest.fixture
def sample_latest_messages() -> dict[str, str]:
return {
"session-a": "Help me fix this bug",
"session-b": "Refactor the authentication module",
"session-c": "Add unit tests for the API",
}
class TestFormatRelativeTime:
def test_just_now(self) -> None:
now = datetime.now(UTC).isoformat()
assert _format_relative_time(now) == "just now"
def test_minutes_ago(self) -> None:
time_5m_ago = (datetime.now(UTC) - timedelta(minutes=5)).isoformat()
assert _format_relative_time(time_5m_ago) == "5m ago"
def test_hours_ago(self) -> None:
time_2h_ago = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
assert _format_relative_time(time_2h_ago) == "2h ago"
def test_days_ago(self) -> None:
time_3d_ago = (datetime.now(UTC) - timedelta(days=3)).isoformat()
assert _format_relative_time(time_3d_ago) == "3d ago"
def test_weeks_ago(self) -> None:
time_2w_ago = (datetime.now(UTC) - timedelta(weeks=2)).isoformat()
assert _format_relative_time(time_2w_ago) == "2w ago"
def test_none_returns_unknown(self) -> None:
assert _format_relative_time(None) == "unknown"
def test_invalid_format_returns_unknown(self) -> None:
assert _format_relative_time("not-a-date") == "unknown"
def test_handles_z_suffix(self) -> None:
time_str = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
assert _format_relative_time(time_str) == "just now"
def test_boundary_59_seconds(self) -> None:
time_59s_ago = (datetime.now(UTC) - timedelta(seconds=59)).isoformat()
assert _format_relative_time(time_59s_ago) == "just now"
def test_boundary_60_seconds(self) -> None:
time_60s_ago = (datetime.now(UTC) - timedelta(seconds=60)).isoformat()
assert _format_relative_time(time_60s_ago) == "1m ago"
class TestSessionPickerAppInit:
def test_init_sets_properties(
self, sample_sessions: list[SessionInfo], sample_latest_messages: dict[str, str]
) -> None:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
assert picker._sessions == sample_sessions
assert picker._latest_messages == sample_latest_messages
def test_id_is_sessionpicker_app(self) -> None:
picker = SessionPickerApp(sessions=[], latest_messages={})
assert picker.id == "sessionpicker-app"
def test_can_focus_children_is_true(self) -> None:
assert SessionPickerApp.can_focus_children is True
class TestSessionPickerMessages:
def test_session_selected_stores_session_id(self) -> None:
msg = SessionPickerApp.SessionSelected("test-session-id")
assert msg.session_id == "test-session-id"
def test_session_selected_with_full_uuid(self) -> None:
session_id = "abc12345-6789-0123-4567-89abcdef0123"
msg = SessionPickerApp.SessionSelected(session_id)
assert msg.session_id == session_id
def test_cancelled_can_be_instantiated(self) -> None:
msg = SessionPickerApp.Cancelled()
assert isinstance(msg, SessionPickerApp.Cancelled)
class TestSessionPickerAppBindings:
def _get_binding_keys(self) -> list[str]:
keys = []
for binding in SessionPickerApp.BINDINGS:
if isinstance(binding, tuple) and len(binding) >= 1:
keys.append(binding[0])
else:
keys.append(binding.key)
return keys
def test_has_escape_binding(self) -> None:
assert "escape" in self._get_binding_keys()