Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
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: Clément Siriex <clement.sirieix@mistral.ai>
Co-Authored-By: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-Authored-By: Thaddee Tyl <thaddee.tyl@gmail.com>
Co-Authored-By: David Brochart <david.brochart@gmail.com>
Co-Authored-By: Joseph Guhlin <joseph.guhlin@gmail.com>
Co-Authored-By: Thomas Kenbeek <thomaskenbeek@gmail.com>
Co-Authored-By: Remenby31 <baptiste.cruvellier31@gmail.com>
This commit is contained in:
Mathias Gesbert 2026-01-27 16:39:30 +01:00 committed by Mathias Gesbert
parent 79f215d91c
commit d33db9fff8
217 changed files with 16911 additions and 4305 deletions

View file

@ -0,0 +1,32 @@
from __future__ import annotations
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGatewayError,
WhoAmIGatewayUnauthorized,
WhoAmIResponse,
)
class FakeWhoAmIGateway:
def __init__(
self,
response: WhoAmIResponse | None = None,
*,
unauthorized: bool = False,
error: bool = False,
) -> None:
self._response = response
self._unauthorized = unauthorized
self._error = error
self.calls: list[str] = []
async def whoami(self, api_key: str) -> WhoAmIResponse:
self.calls.append(api_key)
if self._unauthorized:
raise WhoAmIGatewayUnauthorized()
if self._error:
raise WhoAmIGatewayError()
if self._response is None:
msg = "FakeWhoAmIGateway requires a response when no error is set."
raise RuntimeError(msg)
return self._response

View file

@ -0,0 +1,103 @@
from __future__ import annotations
import logging
import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import PlanOfferAction, decide_plan_offer
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
@pytest.mark.asyncio
async def test_proposes_upgrade_without_call_when_api_key_is_empty() -> None:
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=False,
)
)
action = await decide_plan_offer("", gateway)
assert action is PlanOfferAction.UPGRADE
assert gateway.calls == []
@pytest.mark.parametrize(
("response", "expected"),
[
(
WhoAmIResponse(
is_pro_plan=True,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=False,
),
PlanOfferAction.NONE,
),
(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=True,
prompt_switching_to_pro_plan=False,
),
PlanOfferAction.UPGRADE,
),
(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=True,
),
PlanOfferAction.SWITCH_TO_PRO_KEY,
),
],
ids=["with-a-pro-plan", "without-a-pro-plan", "with-a-non-pro-key"],
)
@pytest.mark.asyncio
async def test_proposes_an_action_based_on_current_plan_status(
response: WhoAmIResponse, expected: PlanOfferAction
) -> None:
gateway = FakeWhoAmIGateway(response)
action = await decide_plan_offer("api-key", gateway)
assert action is expected
assert gateway.calls == ["api-key"]
@pytest.mark.asyncio
async def test_proposes_nothing_when_nothing_is_suggested() -> None:
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=False,
)
)
action = await decide_plan_offer("api-key", gateway)
assert action is PlanOfferAction.NONE
assert gateway.calls == ["api-key"]
@pytest.mark.asyncio
async def test_proposes_upgrade_when_api_key_is_unauthorized() -> None:
gateway = FakeWhoAmIGateway(unauthorized=True)
action = await decide_plan_offer("bad-key", gateway)
assert action is PlanOfferAction.UPGRADE
assert gateway.calls == ["bad-key"]
@pytest.mark.asyncio
async def test_proposes_none_and_logs_warning_when_gateway_error_occurs(
caplog: pytest.LogCaptureFixture,
) -> None:
gateway = FakeWhoAmIGateway(error=True)
with caplog.at_level(logging.WARNING):
action = await decide_plan_offer("api-key", gateway)
assert action is PlanOfferAction.NONE
assert gateway.calls == ["api-key"]
assert "Failed to fetch plan status." in caplog.text

View file

@ -0,0 +1,122 @@
from __future__ import annotations
import httpx
import pytest
import respx
from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway
from vibe.cli.plan_offer.ports.whoami_gateway import (
WhoAmIGatewayError,
WhoAmIGatewayUnauthorized,
WhoAmIResponse,
)
@pytest.mark.asyncio
async def test_returns_plan_flags(respx_mock: respx.MockRouter) -> None:
route = respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(
200,
json={
"is_pro_plan": True,
"advertise_pro_plan": False,
"prompt_switching_to_pro_plan": False,
},
)
)
gateway = HttpWhoAmIGateway(base_url="http://test")
response = await gateway.whoami("api-key")
assert route.called
request = route.calls.last.request
assert request.headers["Authorization"] == "Bearer api-key"
assert response.is_pro_plan is True
assert response.advertise_pro_plan is False
assert response.prompt_switching_to_pro_plan is False
@pytest.mark.asyncio
@pytest.mark.parametrize("status_code", [401, 403])
async def test_raises_on_unauthorized(
respx_mock: respx.MockRouter, status_code: int
) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(status_code, json={"error": "unauthorized"})
)
gateway = HttpWhoAmIGateway(base_url="http://test")
with pytest.raises(WhoAmIGatewayUnauthorized):
await gateway.whoami("bad-key")
@pytest.mark.asyncio
async def test_raises_on_non_success(respx_mock: respx.MockRouter) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(500, json={"error": "boom"})
)
gateway = HttpWhoAmIGateway(base_url="http://test")
with pytest.raises(WhoAmIGatewayError):
await gateway.whoami("api-key")
@pytest.mark.asyncio
async def test_incomplete_payload_defaults_missing_flags_to_false(
respx_mock: respx.MockRouter,
) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(200, json={"is_pro_plan": True})
)
gateway = HttpWhoAmIGateway(base_url="http://test")
response = await gateway.whoami("api-key")
assert response == WhoAmIResponse(
is_pro_plan=True, advertise_pro_plan=False, prompt_switching_to_pro_plan=False
)
@pytest.mark.asyncio
async def test_wraps_request_error(respx_mock: respx.MockRouter) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
side_effect=httpx.ConnectError("boom")
)
gateway = HttpWhoAmIGateway(base_url="http://test")
with pytest.raises(WhoAmIGatewayError):
await gateway.whoami("api-key")
@pytest.mark.asyncio
async def test_parses_boolean_strings(respx_mock: respx.MockRouter) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(
200,
json={
"is_pro_plan": "true",
"advertise_pro_plan": "false",
"prompt_switching_to_pro_plan": "true",
},
)
)
gateway = HttpWhoAmIGateway(base_url="http://test")
response = await gateway.whoami("api-key")
assert response == WhoAmIResponse(
is_pro_plan=True, advertise_pro_plan=False, prompt_switching_to_pro_plan=True
)
@pytest.mark.asyncio
async def test_raises_on_invalid_boolean_string(respx_mock: respx.MockRouter) -> None:
respx_mock.get("http://test/api/vibe/whoami").mock(
return_value=httpx.Response(200, json={"is_pro_plan": "yes"})
)
gateway = HttpWhoAmIGateway(base_url="http://test")
with pytest.raises(WhoAmIGatewayError):
await gateway.whoami("api-key")

View file

@ -0,0 +1,63 @@
from __future__ import annotations
import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from tests.stubs.fake_backend import FakeBackend
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIResponse
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.messages import PlanOfferMessage
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
def _make_app(gateway: FakeWhoAmIGateway, config: VibeConfig | None = None) -> VibeApp:
config = config or VibeConfig(
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
)
agent_loop = AgentLoop(config=config, backend=FakeBackend())
return VibeApp(agent_loop=agent_loop, plan_offer_gateway=gateway)
@pytest.mark.asyncio
async def test_app_shows_upgrade_offer_in_plan_offer_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=True,
prompt_switching_to_pro_plan=False,
)
)
app = _make_app(gateway)
async with app.run_test() as pilot:
await pilot.pause(0.1)
offer = app.query_one(PlanOfferMessage)
assert "Upgrade to" in offer.get_text()
assert "Pro" in offer.get_text()
assert gateway.calls == ["api-key"]
@pytest.mark.asyncio
async def test_app_shows_switch_to_pro_key_offer_in_plan_offer_message(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("MISTRAL_API_KEY", "api-key")
gateway = FakeWhoAmIGateway(
WhoAmIResponse(
is_pro_plan=False,
advertise_pro_plan=False,
prompt_switching_to_pro_plan=True,
)
)
app = _make_app(gateway)
async with app.run_test() as pilot:
await pilot.pause(0.1)
offer = app.query_one(PlanOfferMessage)
assert "Switch to your" in offer.get_text()
assert "Pro API key" in offer.get_text()
assert gateway.calls == ["api-key"]

View file

@ -102,7 +102,10 @@ def test_copy_selection_to_clipboard_success(
mock_copy_fn.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard', severity="information", timeout=2
'"selected text" copied to clipboard',
severity="information",
timeout=2,
markup=False,
)
@ -126,7 +129,10 @@ def test_copy_selection_to_clipboard_tries_all(
fn_2.assert_called_once_with("selected text")
fn_3.assert_called_once_with("selected text")
mock_app.notify.assert_called_once_with(
'"selected text" copied to clipboard', severity="information", timeout=2
'"selected text" copied to clipboard',
severity="information",
timeout=2,
markup=False,
)
@ -175,6 +181,7 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
'"first selection⏎second selection" copied to clipboard',
severity="information",
timeout=2,
markup=False,
)
@ -266,8 +273,13 @@ def test_get_copy_fns_no_system_tools(mock_which: MagicMock, mock_app: App) -> N
assert copy_fns[2] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_xclip(mock_which: MagicMock, mock_app: App) -> None:
def test_get_copy_fns_with_xclip(
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
) -> None:
mock_platform_system.return_value = "Linux"
def which_side_effect(cmd: str) -> str | None:
return "/usr/bin/xclip" if cmd == "xclip" else None
@ -282,8 +294,13 @@ def test_get_copy_fns_with_xclip(mock_which: MagicMock, mock_app: App) -> None:
assert copy_fns[3] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_wl_copy(mock_which: MagicMock, mock_app: App) -> None:
def test_get_copy_fns_with_wl_copy(
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
) -> None:
mock_platform_system.return_value = "Linux"
def which_side_effect(cmd: str) -> str | None:
return "/usr/bin/wl-copy" if cmd == "wl-copy" else None
@ -298,10 +315,13 @@ def test_get_copy_fns_with_wl_copy(mock_which: MagicMock, mock_app: App) -> None
assert copy_fns[3] == mock_app.copy_to_clipboard
@patch("vibe.cli.clipboard.platform.system")
@patch("vibe.cli.clipboard.shutil.which")
def test_get_copy_fns_with_both_system_tools(
mock_which: MagicMock, mock_app: App
mock_which: MagicMock, mock_platform_system: MagicMock, mock_app: App
) -> None:
mock_platform_system.return_value = "Linux"
def which_side_effect(cmd: str) -> str | None:
match cmd:
case "wl-copy":

View file

@ -0,0 +1,72 @@
"""Tests for the external editor module."""
from __future__ import annotations
from unittest.mock import patch
from vibe.cli.textual_ui.external_editor import ExternalEditor
class TestGetEditor:
def test_returns_visual_first(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim", "EDITOR": "nvim"}, clear=True):
assert ExternalEditor.get_editor() == "vim"
def test_falls_back_to_editor(self) -> None:
with patch.dict("os.environ", {"EDITOR": "nvim"}, clear=True):
assert ExternalEditor.get_editor() == "nvim"
def test_falls_back_when_no_editor(self) -> None:
with patch.dict("os.environ", {}, clear=True):
assert ExternalEditor.get_editor() == "nano"
class TestEdit:
def test_returns_modified_content(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run") as mock_run:
with patch("pathlib.Path.read_text", return_value="modified"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
result = editor.edit("original")
assert result == "modified"
mock_run.assert_called_once()
def test_returns_none_when_content_unchanged(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run"):
with patch("pathlib.Path.read_text", return_value="same"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
result = editor.edit("same")
assert result is None
def test_strips_trailing_whitespace(self) -> None:
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run"):
with patch("pathlib.Path.read_text", return_value="content\n\n"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
result = editor.edit("original")
assert result == "content"
def test_handles_editor_with_args(self) -> None:
with patch.dict("os.environ", {"VISUAL": "code --wait"}, clear=True):
with patch("subprocess.run") as mock_run:
with patch("pathlib.Path.read_text", return_value="edited"):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
editor.edit("original")
call_args = mock_run.call_args[0][0]
assert call_args[0] == "code"
assert call_args[1] == "--wait"
def test_returns_none_on_subprocess_error(self) -> None:
import subprocess as sp
with patch.dict("os.environ", {"VISUAL": "vim"}, clear=True):
with patch("subprocess.run", side_effect=sp.CalledProcessError(1, "vim")):
with patch("pathlib.Path.unlink"):
editor = ExternalEditor()
result = editor.edit("test")
assert result is None

View file

@ -0,0 +1,513 @@
from __future__ import annotations
import pytest
from vibe.core.tools.builtins.ask_user_question import (
AskUserQuestionArgs,
Choice,
Question,
)
@pytest.fixture
def single_question_args():
return AskUserQuestionArgs(
questions=[
Question(
question="Which database?",
header="DB",
options=[
Choice(label="PostgreSQL", description="Relational DB"),
Choice(label="MongoDB", description="Document DB"),
],
)
]
)
@pytest.fixture
def multi_question_args():
return AskUserQuestionArgs(
questions=[
Question(
question="Which database?",
header="DB",
options=[Choice(label="PostgreSQL"), Choice(label="MongoDB")],
),
Question(
question="Which framework?",
header="Framework",
options=[Choice(label="FastAPI"), Choice(label="Django")],
),
]
)
@pytest.fixture
def multi_select_args():
return AskUserQuestionArgs(
questions=[
Question(
question="Which features?",
header="Features",
options=[
Choice(label="Auth"),
Choice(label="Caching"),
Choice(label="Logging"),
],
multi_select=True,
)
]
)
class TestQuestionAppState:
def test_init_state(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app.current_question_idx == 0
assert app.selected_option == 0
assert len(app.answers) == 0
assert len(app.other_texts) == 0
def test_total_options_single_select(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
# 2 options + Other = 3 (no Submit for single-select)
assert app._total_options == 3
def test_total_options_multi_select_includes_submit(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# 3 options + Other + Submit = 5
assert app._total_options == 5
assert app._other_option_idx == 3
assert app._submit_option_idx == 4
def test_is_other_selected(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app._is_other_selected is False
app.selected_option = 2 # Other option
assert app._is_other_selected is True
def test_is_submit_selected(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
assert app._is_submit_selected is False
app.selected_option = 4 # Submit option
assert app._is_submit_selected is True
def test_is_submit_selected_false_for_single_select(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
# Even if selected_option is 3, is_submit_selected is False for single-select
app.selected_option = 3
assert app._is_submit_selected is False
def test_store_other_text_per_question(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
# Store text for question 0
app.other_texts[0] = "Custom DB"
# Switch to question 1
app.current_question_idx = 1
app.other_texts[1] = "Custom Framework"
# Verify both stored separately
assert app._get_other_text(0) == "Custom DB"
assert app._get_other_text(1) == "Custom Framework"
def test_save_regular_option_answer(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 0 # PostgreSQL
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "PostgreSQL"
assert is_other is False
def test_save_other_option_answer(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = "SQLite"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "SQLite"
assert is_other is True
def test_save_other_option_empty_does_not_save(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = "" # Empty
app._save_current_answer()
assert 0 not in app.answers
def test_all_answered_false_initially(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
assert app._all_answered() is False
def test_all_answered_true_when_complete(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
app.answers[0] = ("PostgreSQL", False)
app.answers[1] = ("FastAPI", False)
assert app._all_answered() is True
def test_multi_select_toggle(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# Initially no selections
assert len(app.multi_selections.get(0, set())) == 0
# Add selection
app.multi_selections.setdefault(0, set()).add(0)
assert 0 in app.multi_selections[0]
# Add another
app.multi_selections[0].add(2)
assert 2 in app.multi_selections[0]
# Remove first
app.multi_selections[0].discard(0)
assert 0 not in app.multi_selections[0]
assert 2 in app.multi_selections[0]
def test_multi_select_save_answer(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.multi_selections[0] = {0, 2} # Auth and Logging
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
assert "Logging" in answer_text
assert is_other is False
def test_multi_select_with_other(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.multi_selections[0] = {0, 3} # Auth and Other
app.other_texts[0] = "Custom Feature"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
assert "Custom Feature" in answer_text
assert is_other is True
class TestQuestionAppActions:
def test_action_move_down(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app.selected_option == 0
app.action_move_down()
assert app.selected_option == 1
app.action_move_down()
assert app.selected_option == 2 # Other
app.action_move_down()
assert app.selected_option == 0 # Wraps around
def test_action_move_up(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
assert app.selected_option == 0
app.action_move_up()
assert app.selected_option == 2 # Wraps to Other
app.action_move_up()
assert app.selected_option == 1
def test_switch_question_preserves_other_text(self, multi_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_question_args)
app.other_texts[0] = "Text for Q1"
app._switch_question(1)
assert app.current_question_idx == 1
assert app._get_other_text(0) == "Text for Q1"
assert app._get_other_text(1) == ""
class TestMultiSelectOtherBehavior:
def test_multi_select_other_does_not_advance_on_save(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.selected_option = 3 # Other option (3 options + Other)
app.other_texts[0] = "Custom feature"
# Save should not advance for multi-select
app._save_current_answer()
# Should stay on same question
assert app.current_question_idx == 0
def test_multi_select_other_toggle_adds_to_selections(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options) # 3
# Initially no selections
assert len(app.multi_selections.get(0, set())) == 0
# Add Other to selections
app.multi_selections.setdefault(0, set()).add(other_idx)
app.other_texts[0] = "Custom"
# Can still add regular options
app.multi_selections[0].add(0) # Auth
app.multi_selections[0].add(1) # Caching
assert other_idx in app.multi_selections[0]
assert 0 in app.multi_selections[0]
assert 1 in app.multi_selections[0]
def test_multi_select_save_with_other_and_regular_options(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options)
# Select Auth (0), Logging (2), and Other (3)
app.multi_selections[0] = {0, 2, other_idx}
app.other_texts[0] = "Custom Feature"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
assert "Logging" in answer_text
assert "Custom Feature" in answer_text
assert is_other is True
def test_multi_select_other_without_text_not_in_answer(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options)
# Select Auth (0) and Other (3) but no text for Other
app.multi_selections[0] = {0, other_idx}
app.other_texts[0] = ""
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert "Auth" in answer_text
# Empty Other should not appear
assert is_other is False # No valid Other text
def test_multi_select_can_toggle_after_selecting_other(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
other_idx = len(app._current_question.options)
# Select Other first
app.multi_selections[0] = {other_idx}
app.other_texts[0] = "Custom"
# Now navigate to and toggle Auth
app.selected_option = 0
selections = app.multi_selections.setdefault(0, set())
selections.add(0) # Toggle Auth on
assert 0 in app.multi_selections[0]
assert other_idx in app.multi_selections[0]
# Toggle Auth off
selections.discard(0)
assert 0 not in app.multi_selections[0]
assert other_idx in app.multi_selections[0]
def test_multi_select_empty_selections_does_not_save(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# No selections
app._save_current_answer()
assert 0 not in app.answers
class TestSingleSelectOtherBehavior:
def test_single_select_other_with_text_saves(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = "Custom DB"
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "Custom DB"
assert is_other is True
def test_single_select_other_without_text_does_not_save(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 2 # Other
app.other_texts[0] = ""
app._save_current_answer()
assert 0 not in app.answers
def test_single_select_regular_option_saves_immediately(self, single_question_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(single_question_args)
app.selected_option = 1 # MongoDB
app._save_current_answer()
assert 0 in app.answers
answer_text, is_other = app.answers[0]
assert answer_text == "MongoDB"
assert is_other is False
class TestMultiSelectAutoSelect:
def test_typing_auto_selects_other(self, multi_select_args):
from unittest.mock import MagicMock
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.other_input = MagicMock()
app.other_input.value = "Custom text"
# Initially no selections
assert app._other_option_idx not in app.multi_selections.get(0, set())
# Simulate input change
from textual.widgets import Input
app.on_input_changed(Input.Changed(app.other_input, "Custom text"))
# Other should now be selected
assert app._other_option_idx in app.multi_selections[0]
def test_clearing_auto_deselects_other(self, multi_select_args):
from unittest.mock import MagicMock
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.other_input = MagicMock()
# Start with Other selected and text
app.multi_selections[0] = {app._other_option_idx}
app.other_input.value = "" # Cleared
# Simulate input change with empty value
from textual.widgets import Input
app.on_input_changed(Input.Changed(app.other_input, ""))
# Other should now be deselected
assert app._other_option_idx not in app.multi_selections[0]
def test_auto_select_preserves_other_selections(self, multi_select_args):
from unittest.mock import MagicMock
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.other_input = MagicMock()
app.other_input.value = "Custom"
# Pre-select Auth and Logging
app.multi_selections[0] = {0, 2}
# Simulate typing
from textual.widgets import Input
app.on_input_changed(Input.Changed(app.other_input, "Custom"))
# All selections should be preserved plus Other
assert 0 in app.multi_selections[0]
assert 2 in app.multi_selections[0]
assert app._other_option_idx in app.multi_selections[0]
class TestMultiSelectSubmit:
def test_navigate_to_submit(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
# Navigate down through all options to Submit
for _ in range(4): # 0->1->2->3(Other)->4(Submit)
app.action_move_down()
assert app.selected_option == 4
assert app._is_submit_selected is True
def test_submit_wraps_around(self, multi_select_args):
from vibe.cli.textual_ui.widgets.question_app import QuestionApp
app = QuestionApp(multi_select_args)
app.selected_option = 4 # Submit
app.action_move_down()
assert app.selected_option == 0

View file

@ -0,0 +1,51 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from textual.selection import Selection
from textual.widget import Widget
from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.textual_ui.app import VibeApp
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
class ClipboardSelectionWidget(Widget):
def __init__(self, selected_text: str) -> None:
super().__init__()
self._selected_text = selected_text
@property
def text_selection(self) -> Selection | None:
return Selection(None, None)
def get_selection(self, selection: Selection) -> tuple[str, str] | None:
return (self._selected_text, "\n")
@pytest.mark.asyncio
async def test_ui_clipboard_notification_does_not_crash_on_markup_text(
monkeypatch: pytest.MonkeyPatch,
) -> None:
agent_loop = AgentLoop(
config=VibeConfig(
session_logging=SessionLoggingConfig(enabled=False),
enable_update_checks=False,
)
)
app = VibeApp(agent_loop=agent_loop)
async with app.run_test(notifications=True) as pilot:
await app.mount(ClipboardSelectionWidget("[/]"))
with patch("vibe.cli.clipboard._get_copy_fns") as mock_get_copy_fns:
mock_get_copy_fns.return_value = [MagicMock()]
copy_selection_to_clipboard(app)
await pilot.pause(0.1)
notifications = list(app._notifications)
assert notifications
notification = notifications[-1]
assert notification.markup is False
assert "copied to clipboard" in notification.message

View file

@ -0,0 +1,135 @@
from __future__ import annotations
import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.messages import AssistantMessage, UserMessage
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.types import FunctionCall, LLMMessage, Role, ToolCall
@pytest.fixture
def vibe_config() -> VibeConfig:
return VibeConfig(
session_logging=SessionLoggingConfig(enabled=False), enable_update_checks=False
)
@pytest.mark.asyncio
async def test_ui_displays_messages_when_resuming_session(
vibe_config: VibeConfig,
) -> None:
"""Test that messages are properly displayed when resuming a session."""
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
# Simulate a previous session with messages
user_msg = LLMMessage(role=Role.user, content="Hello, how are you?")
assistant_msg = LLMMessage(
role=Role.assistant,
content="I'm doing well, thank you!",
tool_calls=[
ToolCall(
id="tool_call_1",
index=0,
function=FunctionCall(
name="read_file", arguments='{"path": "test.txt"}'
),
)
],
)
tool_result_msg = LLMMessage(
role=Role.tool,
content="File content here",
name="read_file",
tool_call_id="tool_call_1",
)
agent_loop.messages.extend([user_msg, assistant_msg, tool_result_msg])
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
async with app.run_test() as pilot:
# Wait for the app to initialize and rebuild history
await pilot.pause(0.5)
# Verify user message is displayed
user_messages = app.query(UserMessage)
assert len(user_messages) == 1
assert user_messages[0]._content == "Hello, how are you?"
# Verify assistant message is displayed
assistant_messages = app.query(AssistantMessage)
assert len(assistant_messages) == 1
assert assistant_messages[0]._content == "I'm doing well, thank you!"
# 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"
# 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]._content == "File content here"
@pytest.mark.asyncio
async def test_ui_does_not_display_messages_when_only_system_messages_exist(
vibe_config: VibeConfig,
) -> None:
"""Test that no messages are displayed when only system messages exist."""
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
# Only system messages
system_msg = LLMMessage(role=Role.system, content="System prompt")
agent_loop.messages.append(system_msg)
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
async with app.run_test() as pilot:
await pilot.pause(0.5)
# Verify no user or assistant messages are displayed
user_messages = app.query(UserMessage)
assert len(user_messages) == 0
assistant_messages = app.query(AssistantMessage)
assert len(assistant_messages) == 0
@pytest.mark.asyncio
async def test_ui_displays_multiple_user_assistant_turns(
vibe_config: VibeConfig,
) -> None:
"""Test that multiple conversation turns are properly displayed."""
agent_loop = AgentLoop(config=vibe_config, enable_streaming=False)
# Multiple conversation turns
messages = [
LLMMessage(role=Role.user, content="First question"),
LLMMessage(role=Role.assistant, content="First answer"),
LLMMessage(role=Role.user, content="Second question"),
LLMMessage(role=Role.assistant, content="Second answer"),
]
agent_loop.messages.extend(messages)
app = VibeApp(agent_loop=agent_loop, plan_offer_gateway=FakeWhoAmIGateway())
async with app.run_test() as pilot:
await pilot.pause(0.5)
# Verify all messages are displayed
user_messages = app.query(UserMessage)
assert len(user_messages) == 2
assert user_messages[0]._content == "First question"
assert user_messages[1]._content == "Second question"
assistant_messages = app.query(AssistantMessage)
assert len(assistant_messages) == 2
assert assistant_messages[0]._content == "First answer"
assert assistant_messages[1]._content == "Second answer"