Co-authored-by: Antoine <33425718+anth2o@users.noreply.github.com>
Co-authored-by: Bastien <bastien.baret@gmail.com>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Nelson PROIA <144663685+Nelson-PROIA@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: Robin Gullo <robin.gullo@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-04-28 17:44:07 +02:00 committed by GitHub
parent a83c81ecf5
commit 632ea8c032
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
253 changed files with 13965 additions and 2525 deletions

View file

@ -8,6 +8,7 @@ import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import (
PlanInfo,
WhoAmIPlanType,
decide_plan_offer,
resolve_api_key_for_plan,
@ -171,3 +172,52 @@ def test_resolve_api_key_for_plan_with_missing_env_var() -> None:
if previous_api_key is not None:
environ["MISTRAL_API_KEY"] = previous_api_key
@pytest.mark.parametrize(
("response", "expected"),
[
(
WhoAmIResponse(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=False,
),
True,
),
(
WhoAmIResponse(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=True,
),
False,
),
(
WhoAmIResponse(
plan_type=WhoAmIPlanType.API,
plan_name="FREE",
prompt_switching_to_pro_plan=False,
),
False,
),
(
WhoAmIResponse(
plan_type=WhoAmIPlanType.MISTRAL_CODE,
plan_name="E",
prompt_switching_to_pro_plan=False,
),
False,
),
],
ids=[
"chat-plan-is-eligible",
"chat-plan-requiring-key-switch-is-ineligible",
"api-plan-is-ineligible",
"mistral-code-enterprise-is-ineligible",
],
)
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

73
tests/cli/test_cache.py Normal file
View file

@ -0,0 +1,73 @@
from __future__ import annotations
from pathlib import Path
import tomllib
from vibe.cli.cache import read_cache, write_cache
class TestReadCache:
def test_reads_valid_toml(self, tmp_path: Path) -> None:
cache_path = tmp_path / "cache.toml"
cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n')
result = read_cache(cache_path)
assert result["update_cache"]["latest_version"] == "1.0.0"
def test_returns_empty_dict_when_missing(self, tmp_path: Path) -> None:
assert read_cache(tmp_path / "missing.toml") == {}
def test_returns_empty_dict_when_corrupted(self, tmp_path: Path) -> None:
cache_path = tmp_path / "cache.toml"
cache_path.write_text("{bad toml")
assert read_cache(cache_path) == {}
class TestWriteCache:
def test_writes_new_file(self, tmp_path: Path) -> None:
cache_path = tmp_path / "cache.toml"
write_cache(cache_path, "feedback", {"last_shown_at": 100.0})
with cache_path.open("rb") as f:
data = tomllib.load(f)
assert data["feedback"]["last_shown_at"] == 100.0
def test_merges_with_existing(self, tmp_path: Path) -> None:
cache_path = tmp_path / "cache.toml"
cache_path.write_text('[update_cache]\nlatest_version = "1.0.0"\n')
write_cache(cache_path, "feedback", {"last_shown_at": 200.0})
with cache_path.open("rb") as f:
data = tomllib.load(f)
assert data["update_cache"]["latest_version"] == "1.0.0"
assert data["feedback"]["last_shown_at"] == 200.0
def test_merges_within_section_and_leaves_other_sections_alone(
self, tmp_path: Path
) -> None:
cache_path = tmp_path / "cache.toml"
cache_path.write_text(
"[update_cache]\n"
'latest_version = "1.0.0"\n'
"stored_at_timestamp = 1\n"
'seen_whats_new_version = "1.0.0"\n\n'
"[feedback]\n"
"last_shown_at = 100.0\n"
)
write_cache(
cache_path,
"update_cache",
{"latest_version": "2.0.0", "stored_at_timestamp": 2},
)
with cache_path.open("rb") as f:
data = tomllib.load(f)
assert data["update_cache"]["latest_version"] == "2.0.0"
assert data["update_cache"]["stored_at_timestamp"] == 2
assert data["update_cache"]["seen_whats_new_version"] == "1.0.0"
assert data["feedback"]["last_shown_at"] == 100.0

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import base64
import subprocess
from types import SimpleNamespace
from typing import cast
from unittest.mock import MagicMock, mock_open, patch
@ -16,6 +17,7 @@ from vibe.cli.clipboard import (
_copy_xclip,
_read_clipboard,
copy_selection_to_clipboard,
copy_text_to_clipboard,
)
@ -174,6 +176,44 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No
)
@patch("vibe.cli.clipboard._copy_to_clipboard")
def test_copy_text_to_clipboard_success(
mock_copy_to_clipboard: MagicMock, mock_app: MagicMock
) -> None:
result = copy_text_to_clipboard(
mock_app, "assistant text", success_message="Agent message copied"
)
assert result == "assistant text"
mock_copy_to_clipboard.assert_called_once_with("assistant text")
mock_app.notify.assert_called_once_with(
"Agent message copied", severity="information", timeout=2, markup=False
)
@patch("vibe.cli.clipboard._copy_to_clipboard")
def test_copy_text_to_clipboard_shows_failure_when_clipboard_unavailable(
mock_copy_to_clipboard: MagicMock, mock_app: MagicMock
) -> None:
mock_copy_to_clipboard.side_effect = RuntimeError("All clipboard strategies failed")
result = copy_text_to_clipboard(mock_app, "assistant text")
assert result is None
mock_copy_to_clipboard.assert_called_once_with("assistant text")
mock_app.notify.assert_called_once_with(
"Failed to copy - clipboard not available", severity="warning", timeout=3
)
def test_copy_text_to_clipboard_returns_none_for_empty_text(
mock_app: MagicMock,
) -> None:
result = copy_text_to_clipboard(mock_app, "")
assert result is None
mock_app.notify.assert_not_called()
def test_copy_to_clipboard_stops_after_verified_copy() -> None:
"""Stops iterating once _read_clipboard confirms the text landed."""
mock_first = MagicMock()
@ -244,21 +284,28 @@ def test_read_clipboard_skips_failing_reader() -> None:
@patch("subprocess.run")
def test_copy_pbcopy(mock_run: MagicMock) -> None:
_copy_pbcopy("hello")
mock_run.assert_called_once_with(["pbcopy"], input=b"hello", check=True)
mock_run.assert_called_once_with(
["pbcopy"], input=b"hello", check=True, stderr=subprocess.DEVNULL
)
@patch("subprocess.run")
def test_copy_xclip(mock_run: MagicMock) -> None:
_copy_xclip("hello")
mock_run.assert_called_once_with(
["xclip", "-selection", "clipboard"], input=b"hello", check=True
["xclip", "-selection", "clipboard"],
input=b"hello",
check=True,
stderr=subprocess.DEVNULL,
)
@patch("subprocess.run")
def test_copy_wl_copy(mock_run: MagicMock) -> None:
_copy_wl_copy("hello")
mock_run.assert_called_once_with(["wl-copy"], input=b"hello", check=True)
mock_run.assert_called_once_with(
["wl-copy"], input=b"hello", check=True, stderr=subprocess.DEVNULL
)
def test_copy_methods_includes_available_commands() -> None:

View file

@ -1,6 +1,20 @@
from __future__ import annotations
from vibe.cli.commands import Command, CommandRegistry
from vibe.cli.commands import Command, CommandAvailabilityContext, CommandRegistry
from vibe.cli.plan_offer.decide_plan_offer import PlanInfo
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType
def _eligible_teleport_context() -> CommandAvailabilityContext:
return CommandAvailabilityContext(
vibe_code_enabled=True,
is_active_model_mistral=True,
plan_info=PlanInfo(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=False,
),
)
class TestCommandRegistry:
@ -59,6 +73,26 @@ class TestCommandRegistry:
assert registry.parse_command("/exit") is None
assert registry.get_command_name("/help") == "help"
def test_teleport_command_hidden_without_eligible_context(self) -> None:
registry = CommandRegistry()
assert registry.get_command_name("/teleport") is None
assert registry.parse_command("/teleport") is None
def test_teleport_command_registration_uses_resolved_context(self) -> None:
registry = CommandRegistry(availability_context=_eligible_teleport_context())
assert registry.get_command_name("/teleport") == "teleport"
assert registry.has_command("teleport")
def test_teleport_help_text_uses_resolved_context(self) -> None:
registry = CommandRegistry()
assert "/teleport" not in registry.get_help_text()
eligible_registry = CommandRegistry(
availability_context=_eligible_teleport_context()
)
assert eligible_registry.get("teleport") is not None
assert "/teleport" in eligible_registry.get_help_text()
def test_resume_command_registration(self) -> None:
registry = CommandRegistry()
assert registry.get_command_name("/resume") == "resume"

View file

@ -0,0 +1,53 @@
from __future__ import annotations
from unittest.mock import patch
import pytest
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.widgets.messages import AssistantMessage
@pytest.mark.asyncio
async def test_copy_command_copies_last_assistant_message() -> None:
app = build_test_vibe_app()
second_reply = "\n```python\nprint('second reply')\n```\n"
stripped_second_reply = second_reply.strip()
async with app.run_test() as pilot:
await app._mount_and_scroll(AssistantMessage("first reply"))
await app._mount_and_scroll(AssistantMessage(second_reply))
with (
patch(
"vibe.cli.textual_ui.app.copy_text_to_clipboard",
return_value=stripped_second_reply,
) as mock_copy,
patch.object(
app.agent_loop.telemetry_client, "send_user_copied_text"
) as mock_telemetry,
):
handled = await app._handle_command("/copy")
await pilot.pause()
assert handled is True
assert stripped_second_reply != second_reply
mock_copy.assert_called_once_with(
app,
stripped_second_reply,
success_message="Last agent message copied to clipboard",
)
mock_telemetry.assert_called_once_with(stripped_second_reply)
@pytest.mark.asyncio
async def test_copy_command_warns_when_no_assistant_message() -> None:
app = build_test_vibe_app()
async with app.run_test() as pilot:
with patch.object(app, "notify") as mock_notify:
handled = await app._handle_command("/copy")
await pilot.pause()
assert handled is True
mock_notify.assert_called_once_with(
"No agent message available to copy", severity="warning", timeout=3
)

View file

@ -4,13 +4,13 @@ from unittest.mock import patch
import pytest
from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp
from tests.conftest import build_test_vibe_app, build_test_vibe_config
@pytest.mark.asyncio
async def test_ctrl_y_triggers_copy_selection() -> None:
"""Test that ctrl+y keybinding triggers copy_selection_to_clipboard."""
app = BaseSnapshotTestApp()
app = build_test_vibe_app()
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
async with app.run_test() as pilot:
@ -21,7 +21,7 @@ async def test_ctrl_y_triggers_copy_selection() -> None:
@pytest.mark.asyncio
async def test_ctrl_shift_c_triggers_copy_selection() -> None:
"""Test that ctrl+shift+c keybinding triggers copy_selection_to_clipboard."""
app = BaseSnapshotTestApp()
app = build_test_vibe_app()
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
async with app.run_test() as pilot:
@ -32,11 +32,7 @@ async def test_ctrl_shift_c_triggers_copy_selection() -> None:
@pytest.mark.asyncio
async def test_mouse_up_respects_autocopy_config_enabled() -> None:
"""Test that mouse up copies when autocopy_to_clipboard is True."""
from tests.snapshots.base_snapshot_test_app import default_config
config = default_config()
config.autocopy_to_clipboard = True
app = BaseSnapshotTestApp(config=config)
app = build_test_vibe_app(config=build_test_vibe_config(autocopy_to_clipboard=True))
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
async with app.run_test() as pilot:
@ -47,11 +43,9 @@ async def test_mouse_up_respects_autocopy_config_enabled() -> None:
@pytest.mark.asyncio
async def test_mouse_up_respects_autocopy_config_disabled() -> None:
"""Test that mouse up does not copy when autocopy_to_clipboard is False."""
from tests.snapshots.base_snapshot_test_app import default_config
config = default_config()
config.autocopy_to_clipboard = False
app = BaseSnapshotTestApp(config=config)
app = build_test_vibe_app(
config=build_test_vibe_config(autocopy_to_clipboard=False)
)
with patch("vibe.cli.textual_ui.app.copy_selection_to_clipboard") as mock_copy:
async with app.run_test() as pilot:

View file

@ -6,27 +6,21 @@ from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
class TestFeedbackBarState:
def test_maybe_show_shows_when_random_below_threshold(self):
def test_show_activates(self):
bar = FeedbackBar()
bar.display = False
bar._set_active = MagicMock()
with patch(
"vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=0
):
bar.maybe_show()
bar.show()
bar._set_active.assert_called_once_with(True)
def test_maybe_show_does_not_show_when_random_above_threshold(self):
def test_show_skips_when_already_displayed(self):
bar = FeedbackBar()
bar.display = False
bar.display = True
bar._set_active = MagicMock()
with patch(
"vibe.cli.textual_ui.widgets.feedback_bar.random.random", return_value=1.0
):
bar.maybe_show()
bar.show()
bar._set_active.assert_not_called()

View file

@ -0,0 +1,149 @@
from __future__ import annotations
from pathlib import Path
import time
import tomllib
from unittest.mock import MagicMock, patch
from vibe.cli.textual_ui.widgets.feedback_bar_manager import (
_CACHE_SECTION,
_LAST_SHOWN_KEY,
FEEDBACK_COOLDOWN_SECONDS,
MIN_USER_MESSAGES_FOR_FEEDBACK,
FeedbackBarManager,
)
from vibe.core.types import LLMMessage, Role
def _patch_cache_file(tmp_path: Path):
from vibe.core.paths._vibe_home import GlobalPath
return patch(
"vibe.cli.textual_ui.widgets.feedback_bar_manager.CACHE_FILE",
GlobalPath(lambda: tmp_path / "cache.toml"),
)
def _patch_probability(value: float):
return patch(
"vibe.cli.textual_ui.widgets.feedback_bar_manager.FEEDBACK_PROBABILITY", value
)
def _make_agent_loop(
user_message_count: int = MIN_USER_MESSAGES_FOR_FEEDBACK,
telemetry_active: bool = True,
) -> MagicMock:
loop = MagicMock()
loop.telemetry_client.is_active.return_value = telemetry_active
messages = [
LLMMessage(role=Role.user, content=f"msg {i}")
for i in range(user_message_count)
]
loop.messages = messages
return loop
class TestShouldShow:
def test_shows_when_conditions_met(self, tmp_path: Path) -> None:
manager = FeedbackBarManager()
with (
_patch_cache_file(tmp_path),
_patch_probability(0.2),
patch(
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
return_value=0.0,
),
):
assert manager.should_show(_make_agent_loop()) is True
def test_does_not_show_when_random_misses(self, tmp_path: Path) -> None:
manager = FeedbackBarManager()
with (
_patch_cache_file(tmp_path),
_patch_probability(0.2),
patch(
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
return_value=1.0,
),
):
assert manager.should_show(_make_agent_loop()) is False
def test_does_not_show_within_cooldown(self, tmp_path: Path) -> None:
(tmp_path / "cache.toml").write_text(
f"[{_CACHE_SECTION}]\n{_LAST_SHOWN_KEY} = {int(time.time()) - 60}\n"
)
manager = FeedbackBarManager()
with (
_patch_cache_file(tmp_path),
_patch_probability(0.2),
patch(
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
return_value=0.0,
),
):
assert manager.should_show(_make_agent_loop()) is False
def test_shows_after_cooldown_expires(self, tmp_path: Path) -> None:
(tmp_path / "cache.toml").write_text(
f"[{_CACHE_SECTION}]\n{_LAST_SHOWN_KEY} = {int(time.time()) - FEEDBACK_COOLDOWN_SECONDS - 1}\n"
)
manager = FeedbackBarManager()
with (
_patch_cache_file(tmp_path),
_patch_probability(0.2),
patch(
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
return_value=0.0,
),
):
assert manager.should_show(_make_agent_loop()) is True
def test_does_not_show_when_telemetry_inactive(self, tmp_path: Path) -> None:
manager = FeedbackBarManager()
with _patch_cache_file(tmp_path), _patch_probability(0.2):
assert (
manager.should_show(_make_agent_loop(telemetry_active=False)) is False
)
def test_does_not_show_when_too_few_user_messages(self, tmp_path: Path) -> None:
manager = FeedbackBarManager()
with (
_patch_cache_file(tmp_path),
_patch_probability(0.2),
patch(
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
return_value=0.0,
),
):
assert manager.should_show(_make_agent_loop(user_message_count=1)) is False
def test_skips_injected_messages_in_count(self, tmp_path: Path) -> None:
loop = _make_agent_loop(user_message_count=0)
loop.messages = [
LLMMessage(role=Role.user, content="real"),
LLMMessage(role=Role.user, content="injected", injected=True),
LLMMessage(role=Role.assistant, content="reply"),
]
manager = FeedbackBarManager()
with (
_patch_cache_file(tmp_path),
_patch_probability(0.2),
patch(
"vibe.cli.textual_ui.widgets.feedback_bar_manager.random.random",
return_value=0.0,
),
):
# Only 1 non-injected user message, below MIN_USER_MESSAGES_FOR_FEEDBACK
assert manager.should_show(loop) is False
class TestRecordFeedbackAsked:
def test_writes_timestamp_to_cache(self, tmp_path: Path) -> None:
manager = FeedbackBarManager()
before = int(time.time())
with _patch_cache_file(tmp_path):
manager.record_feedback_asked()
with (tmp_path / "cache.toml").open("rb") as f:
data = tomllib.load(f)
assert data[_CACHE_SECTION][_LAST_SHOWN_KEY] >= before

View file

@ -150,9 +150,7 @@ class TestMCPAppInit:
app = MCPApp(mcp_servers=servers, tool_manager=mgr)
app._viewing_server = "srv"
render_calls: list[str | None] = []
app._refresh_view = lambda server_name, *, kind=None: render_calls.append(
server_name
)
app._refresh_view = lambda server_name, **_kw: render_calls.append(server_name)
app.action_back()
assert render_calls == [None]
@ -161,9 +159,7 @@ class TestMCPAppInit:
app = MCPApp(mcp_servers=[], tool_manager=mgr)
app._viewing_server = None
render_calls: list[str | None] = []
app._refresh_view = lambda server_name, *, kind=None: render_calls.append(
server_name
)
app._refresh_view = lambda server_name, **_kw: render_calls.append(server_name)
app.action_back()
assert render_calls == []
@ -176,17 +172,13 @@ class TestMCPAppInit:
mcp_servers=servers, tool_manager=mgr, refresh_callback=refresh_callback
)
app._viewing_server = "srv"
render_calls: list[tuple[str | None, str | None]] = []
app._refresh_view = lambda server_name, *, kind=None: render_calls.append((
server_name,
kind,
))
app._set_help_text = MagicMock()
app.run_worker = MagicMock()
await app.action_refresh()
assert app._status_message == "Refreshing..."
assert render_calls == [("srv", None)]
assert app._refreshing is True
app.run_worker.assert_called_once()
def test_on_worker_state_changed_updates_after_refresh(self) -> None:

View file

@ -0,0 +1,194 @@
from __future__ import annotations
import argparse
from pathlib import Path
import pytest
from vibe.cli import cli as cli_mod, entrypoint as entrypoint_mod
from vibe.core.config import MissingAPIKeyError
from vibe.core.trusted_folders import trusted_folders_manager
def _make_args(**overrides: object) -> argparse.Namespace:
base: dict[str, object] = {
"initial_prompt": None,
"prompt": "hello",
"max_turns": None,
"max_price": None,
"enabled_tools": None,
"output": "text",
"agent": "default",
"setup": False,
"workdir": None,
"trust": False,
"teleport": False,
"continue_session": False,
"resume": None,
}
base.update(overrides)
return argparse.Namespace(**base)
def test_programmatic_mode_does_not_run_onboarding_on_missing_api_key(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
def boom() -> None:
raise MissingAPIKeyError("MISTRAL_API_KEY", "mistral")
monkeypatch.setattr(cli_mod.VibeConfig, "load", staticmethod(boom))
sentinel: dict[str, bool] = {"called": False}
def fail_onboarding(*_args: object, **_kwargs: object) -> None:
sentinel["called"] = True
monkeypatch.setattr(cli_mod, "run_onboarding", fail_onboarding)
with pytest.raises(SystemExit) as exc_info:
cli_mod.load_config_or_exit(interactive=False)
assert exc_info.value.code == 1
assert sentinel["called"] is False
err = capsys.readouterr().err
assert "MISTRAL_API_KEY" in err
assert "vibe --setup" in err
def test_interactive_mode_still_runs_onboarding_on_missing_api_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Replace VibeConfig.load with a stub that fails the first time.
state = {"raised": False}
def fake_load() -> object:
if not state["raised"]:
state["raised"] = True
raise MissingAPIKeyError("MISTRAL_API_KEY", "mistral")
return "config-sentinel"
monkeypatch.setattr(cli_mod.VibeConfig, "load", staticmethod(fake_load))
onboarding_called: list[bool] = []
monkeypatch.setattr(
cli_mod, "run_onboarding", lambda *a, **k: onboarding_called.append(True)
)
result = cli_mod.load_config_or_exit(interactive=True)
assert onboarding_called == [True]
assert result == "config-sentinel"
def test_warn_if_workdir_untrusted_writes_stderr_when_project_config_present(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
project = tmp_path / "proj"
project.mkdir()
(project / "AGENTS.md").write_text("hello", encoding="utf-8")
monkeypatch.chdir(project)
cli_mod.warn_if_workdir_trust_is_unset()
err = " ".join(capsys.readouterr().err.split())
assert "not trusted" in err
assert "AGENTS.md" in err
assert "--trust" in err
def test_warn_if_workdir_untrusted_silent_when_already_trusted(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
project = tmp_path / "proj"
project.mkdir()
(project / "AGENTS.md").write_text("hello", encoding="utf-8")
monkeypatch.chdir(project)
trusted_folders_manager.add_trusted(project)
cli_mod.warn_if_workdir_trust_is_unset()
assert capsys.readouterr().err == ""
def test_warn_if_workdir_untrusted_silent_when_no_project_config(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
project = tmp_path / "proj"
project.mkdir()
monkeypatch.chdir(project)
cli_mod.warn_if_workdir_trust_is_unset()
assert capsys.readouterr().err == ""
def test_trust_flag_trusts_cwd_for_session_only(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project = tmp_path / "proj"
project.mkdir()
monkeypatch.chdir(project)
args = _make_args(trust=True, prompt=None)
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
monkeypatch.setattr(
entrypoint_mod, "check_and_resolve_trusted_folder", lambda _cwd: None
)
monkeypatch.setattr(
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
)
# Stop main() before it runs the actual CLI.
monkeypatch.setattr(
"vibe.cli.cli.run_cli", lambda _args: (_ for _ in ()).throw(SystemExit(0))
)
with pytest.raises(SystemExit) as exc_info:
entrypoint_mod.main()
assert exc_info.value.code == 0
assert trusted_folders_manager.is_trusted(project) is True
# --trust must NOT persist to trusted_folders.toml.
assert trusted_folders_manager._trusted == []
assert str(project.resolve()) in trusted_folders_manager._session_trusted
def test_trust_flag_works_in_programmatic_mode(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project = tmp_path / "proj"
project.mkdir()
monkeypatch.chdir(project)
args = _make_args(trust=True, prompt="run")
monkeypatch.setattr(entrypoint_mod, "parse_arguments", lambda: args)
monkeypatch.setattr(
entrypoint_mod,
"check_and_resolve_trusted_folder",
lambda _cwd: pytest.fail("must not prompt in -p mode"),
)
monkeypatch.setattr(
entrypoint_mod, "init_harness_files_manager", lambda *a, **k: None
)
monkeypatch.setattr(
"vibe.cli.cli.run_cli", lambda _args: (_ for _ in ()).throw(SystemExit(0))
)
with pytest.raises(SystemExit):
entrypoint_mod.main()
assert trusted_folders_manager.is_trusted(project) is True
assert trusted_folders_manager._trusted == []
def test_session_trust_does_not_write_to_disk(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
trust_file = tmp_path / "trusted_folders.toml"
monkeypatch.setattr(trusted_folders_manager, "_file_path", trust_file)
project = tmp_path / "proj"
project.mkdir()
trusted_folders_manager.trust_for_session(project)
assert trusted_folders_manager.is_trusted(project) is True
assert not trust_file.exists()

View file

@ -0,0 +1,73 @@
from __future__ import annotations
import os
import sys
from unittest.mock import patch
import pytest
from vibe.cli.stderr_guard import stderr_guard
_FORCE_ACTIVE = patch("vibe.cli.stderr_guard._is_stderr_a_tty", return_value=True)
@pytest.mark.skipif(sys.platform == "win32", reason="fd redirect is Unix-only")
class TestStderrGuard:
def test_fd2_redirected_to_devnull_inside_guard(self) -> None:
with _FORCE_ACTIVE:
with stderr_guard():
devnull_stat = os.stat(os.devnull)
fd2_stat = os.fstat(2)
assert fd2_stat.st_rdev == devnull_stat.st_rdev
def test_fd2_restored_after_guard(self) -> None:
stat_before = os.fstat(2)
with _FORCE_ACTIVE:
with stderr_guard():
stat_inside = os.fstat(2)
assert stat_inside.st_ino != stat_before.st_ino
stat_after = os.fstat(2)
assert stat_after.st_ino == stat_before.st_ino
def test_sys_stderr_restored_after_exit(self) -> None:
original_stderr = sys.stderr
original_dunder = sys.__stderr__
with _FORCE_ACTIVE:
with stderr_guard():
assert sys.__stderr__ is not original_dunder
assert sys.stderr is original_stderr
assert sys.__stderr__ is original_dunder
def test_restores_stderr_when_render_flush_raises_value_error(self) -> None:
original_stderr = sys.stderr
original_dunder = sys.__stderr__
stat_before = os.fstat(2)
with _FORCE_ACTIVE:
with stderr_guard():
assert sys.__stderr__ is not None
sys.__stderr__.close()
stat_after = os.fstat(2)
assert stat_after.st_ino == stat_before.st_ino
assert sys.stderr is original_stderr
assert sys.__stderr__ is original_dunder
def test_render_file_is_writable_inside_guard(self) -> None:
with _FORCE_ACTIVE:
with stderr_guard():
assert sys.__stderr__ is not None
assert sys.__stderr__.writable()
def test_noop_when_stderr_is_not_a_tty(self) -> None:
original_stderr = sys.stderr
with patch("vibe.cli.stderr_guard._is_stderr_a_tty", return_value=False):
with stderr_guard():
assert sys.stderr is original_stderr
def test_native_write_to_fd2_goes_to_devnull(self) -> None:
with _FORCE_ACTIVE:
with stderr_guard():
# Should not raise; bytes vanish into /dev/null.
written = os.write(2, b"stray MallocStackLogging message\n")
assert written > 0

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from unittest.mock import patch
from unittest.mock import AsyncMock, patch
import pytest
@ -8,7 +8,8 @@ from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.textual_ui.app import BottomApp
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp
from vibe.core.config._settings import ModelConfig
from vibe.cli.textual_ui.widgets.thinking_picker import ThinkingPickerApp
from vibe.core.config._settings import THINKING_LEVELS, ModelConfig
def _make_config_with_models():
@ -60,7 +61,8 @@ async def test_config_toggle_autocopy() -> None:
await app._show_config()
await pilot.pause(0.2)
# Navigate down to Auto-copy (second item) and toggle
# Navigate down to Auto-copy (third item, after Model + Thinking) and toggle
await pilot.press("down")
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.1)
@ -80,7 +82,8 @@ async def test_config_escape_saves_changes() -> None:
await app._show_config()
await pilot.pause(0.2)
# Toggle auto-copy
# Toggle auto-copy (skip Model + Thinking rows)
await pilot.press("down")
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.1)
@ -268,13 +271,15 @@ async def test_config_pending_changes_saved_before_model_picker() -> None:
await app._show_config()
await pilot.pause(0.2)
# Toggle auto-copy (second row)
# Toggle auto-copy (third row, after Model + Thinking)
await pilot.press("down")
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.1)
# Go back up to model row and open model picker
await pilot.press("up")
await pilot.press("up")
with patch("vibe.cli.textual_ui.app.VibeConfig.save_updates") as mock_save:
await pilot.press("enter")
await pilot.pause(0.3)
@ -282,3 +287,151 @@ async def test_config_pending_changes_saved_before_model_picker() -> None:
mock_save.assert_called_once()
changes = mock_save.call_args[0][0]
assert changes["autocopy_to_clipboard"] is True
# --- /thinking command ---
@pytest.mark.asyncio
async def test_thinking_opens_thinking_picker() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_thinking()
await pilot.pause(0.2)
assert app._current_bottom_app == BottomApp.ThinkingPicker
assert len(app.query(ThinkingPickerApp)) == 1
@pytest.mark.asyncio
async def test_thinking_picker_shows_all_levels() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_thinking()
await pilot.pause(0.2)
picker = app.query_one(ThinkingPickerApp)
assert picker._thinking_levels == THINKING_LEVELS
assert picker._current_thinking == "off"
@pytest.mark.asyncio
async def test_thinking_picker_escape_returns_to_input() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_thinking()
await pilot.pause(0.2)
await pilot.press("escape")
await pilot.pause(0.2)
assert app._current_bottom_app == BottomApp.Input
assert len(app.query(ThinkingPickerApp)) == 0
@pytest.mark.asyncio
async def test_thinking_picker_select_level() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_thinking()
await pilot.pause(0.2)
# Navigate down to "low" (second item) and select
await pilot.press("down")
with patch.object(app, "_reload_config", new=AsyncMock()):
await pilot.press("enter")
await pilot.pause(0.2)
assert app._current_bottom_app == BottomApp.Input
assert len(app.query(ThinkingPickerApp)) == 0
assert app.config.get_active_model().thinking == "low"
@pytest.mark.asyncio
async def test_thinking_picker_select_high() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_thinking()
await pilot.pause(0.2)
# Navigate to "high" (4th item = 3 downs from "off")
await pilot.press("down")
await pilot.press("down")
await pilot.press("down")
with patch.object(app, "_reload_config", new=AsyncMock()):
await pilot.press("enter")
await pilot.pause(0.2)
assert app.config.get_active_model().thinking == "high"
# --- config -> thinking picker flow ---
@pytest.mark.asyncio
async def test_config_thinking_entry_opens_thinking_picker() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_config()
await pilot.pause(0.2)
# Thinking row is the second item (after Model). Navigate down and press enter.
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.3)
assert app._current_bottom_app == BottomApp.ThinkingPicker
assert len(app.query(ThinkingPickerApp)) == 1
assert len(app.query(ConfigApp)) == 0
@pytest.mark.asyncio
async def test_config_to_thinking_picker_escape_returns_to_input() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_config()
await pilot.pause(0.2)
# Open thinking picker from config
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.3)
# Escape thinking picker
await pilot.press("escape")
await pilot.pause(0.2)
assert app._current_bottom_app == BottomApp.Input
assert len(app.query(ThinkingPickerApp)) == 0
assert len(app.query(ConfigApp)) == 0
@pytest.mark.asyncio
async def test_config_to_thinking_picker_select_returns_to_input() -> None:
app = build_test_vibe_app(config=_make_config_with_models())
async with app.run_test() as pilot:
await pilot.pause(0.1)
await app._show_config()
await pilot.pause(0.2)
# Open thinking picker from config
await pilot.press("down")
await pilot.press("enter")
await pilot.pause(0.3)
# Select "medium" (3rd item = 2 downs from "off")
await pilot.press("down")
await pilot.press("down")
with patch.object(app, "_reload_config", new=AsyncMock()):
await pilot.press("enter")
await pilot.pause(0.2)
assert app._current_bottom_app == BottomApp.Input
assert app.config.get_active_model().thinking == "medium"

View file

@ -0,0 +1,188 @@
from __future__ import annotations
import time
from unittest.mock import AsyncMock, patch
import pytest
from tests.cli.plan_offer.adapters.fake_whoami_gateway import FakeWhoAmIGateway
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIPlanType, WhoAmIResponse
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
from vibe.core.types import Backend
def _chat_plan_gateway(*, prompt_switching_to_pro_plan: bool) -> FakeWhoAmIGateway:
return FakeWhoAmIGateway(
WhoAmIResponse(
plan_type=WhoAmIPlanType.CHAT,
plan_name="INDIVIDUAL",
prompt_switching_to_pro_plan=prompt_switching_to_pro_plan,
)
)
def _vibe_code_enabled_config() -> VibeConfig:
return build_test_vibe_config(vibe_code_enabled=True)
async def _wait_until(pause, predicate, timeout: float = 2.0) -> None:
start = time.monotonic()
while time.monotonic() - start < timeout:
if predicate():
return
await pause(0.02)
raise AssertionError("Condition was not met within the timeout")
@pytest.mark.asyncio
async def test_teleport_command_visible_for_paid_chat_users() -> None:
app = build_test_vibe_app(
config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False),
)
async with app.run_test() as pilot:
await _wait_until(
pilot.pause,
lambda: app.commands.get_command_name("/teleport") == "teleport",
)
assert app.commands.get_command_name("/teleport") == "teleport"
assert "/teleport" in app.commands.get_help_text()
input_widget = app.query_one(ChatInputContainer).input_widget
assert input_widget is not None
assert "&" in input_widget.mode_characters
@pytest.mark.asyncio
async def test_teleport_command_hidden_when_current_key_is_not_eligible() -> None:
app = build_test_vibe_app(
config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
)
async with app.run_test() as pilot:
await pilot.pause(0.2)
assert app.commands.get_command_name("/teleport") is None
assert "/teleport" not in app.commands.get_help_text()
input_widget = app.query_one(ChatInputContainer).input_widget
assert input_widget is not None
assert "&" not in input_widget.mode_characters
@pytest.mark.asyncio
async def test_hidden_teleport_command_falls_through_as_user_text() -> None:
app = build_test_vibe_app(
config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
)
async with app.run_test() as pilot:
await pilot.pause(0.2)
app._handle_teleport_command = AsyncMock()
app._handle_user_message = AsyncMock()
await app.on_chat_input_container_submitted(
ChatInputContainer.Submitted("/teleport")
)
app._handle_teleport_command.assert_not_awaited()
app._handle_user_message.assert_awaited_once_with("/teleport")
@pytest.mark.asyncio
async def test_hidden_ampersand_teleport_shortcut_falls_through_as_user_text() -> None:
app = build_test_vibe_app(
config=_vibe_code_enabled_config(),
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=True),
)
async with app.run_test() as pilot:
await pilot.pause(0.2)
app._handle_teleport_command = AsyncMock()
app._handle_user_message = AsyncMock()
await app.on_chat_input_container_submitted(
ChatInputContainer.Submitted("&continue")
)
app._handle_teleport_command.assert_not_awaited()
app._handle_user_message.assert_awaited_once_with("&continue")
@pytest.mark.asyncio
async def test_teleport_command_hides_after_switching_to_non_mistral_model(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("OPENAI_API_KEY", "mock-openai-key")
config = build_test_vibe_config(
vibe_code_enabled=True,
providers=[
ProviderConfig(
name="mistral",
api_base="https://api.mistral.ai/v1",
api_key_env_var="MISTRAL_API_KEY",
backend=Backend.MISTRAL,
),
ProviderConfig(
name="openai",
api_base="https://api.openai.com/v1",
api_key_env_var="OPENAI_API_KEY",
backend=Backend.GENERIC,
),
],
models=[
ModelConfig(
name="mistral-vibe-cli-latest", provider="mistral", alias="devstral"
),
ModelConfig(name="gpt-4.1", provider="openai", alias="gpt"),
],
active_model="devstral",
)
app = build_test_vibe_app(
config=config,
plan_offer_gateway=_chat_plan_gateway(prompt_switching_to_pro_plan=False),
)
non_mistral_config = build_test_vibe_config(
vibe_code_enabled=True,
providers=config.providers,
models=config.models,
active_model="gpt",
)
async def fake_reload_with_initial_messages(*, base_config) -> None:
app.agent_loop._base_config = base_config
app.agent_loop.agent_manager.invalidate_config()
async with app.run_test() as pilot:
await _wait_until(
pilot.pause,
lambda: app.commands.get_command_name("/teleport") == "teleport",
)
with (
patch(
"vibe.cli.textual_ui.app.VibeConfig.load",
return_value=non_mistral_config,
),
patch.object(
app.agent_loop,
"reload_with_initial_messages",
new=AsyncMock(side_effect=fake_reload_with_initial_messages),
),
):
await app._reload_config()
await _wait_until(
pilot.pause, lambda: app.commands.get_command_name("/teleport") is None
)
assert app.commands.get_command_name("/teleport") is None
input_widget = app.query_one(ChatInputContainer).input_widget
assert input_widget is not None
assert "&" not in input_widget.mode_characters

View file

@ -0,0 +1,12 @@
from __future__ import annotations
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
def test_rendered_text_length_uses_terminal_cell_width() -> None:
# "你" and "🙂" both occupy 2 terminal cells in Rich (+2 for separator).
assert CompletionPopup.rendered_text_length("@你", "🙂") == 6
def test_rendered_text_length_keeps_description_separator() -> None:
assert CompletionPopup.rendered_text_length("@abc", "def") == 8

View file

@ -0,0 +1,81 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
import vibe.cli.textual_ui.handlers.event_handler as event_handler_module
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.core.hooks.models import (
HookEndEvent,
HookMessageSeverity,
HookRunEndEvent,
HookRunStartEvent,
)
class FakeHookRunContainer:
def __init__(self) -> None:
self.display = False
self.remove = AsyncMock()
async def add_message(self, _widget: object) -> None:
self.display = True
@pytest.mark.asyncio
async def test_hook_run_end_removes_empty_container(
monkeypatch: pytest.MonkeyPatch,
) -> None:
created_containers: list[FakeHookRunContainer] = []
def make_container() -> FakeHookRunContainer:
container = FakeHookRunContainer()
created_containers.append(container)
return container
monkeypatch.setattr(event_handler_module, "HookRunContainer", make_container)
mount_callback = AsyncMock()
handler = EventHandler(
mount_callback=mount_callback, get_tools_collapsed=lambda: False
)
await handler.handle_event(HookRunStartEvent())
await handler.handle_event(HookRunEndEvent())
assert len(created_containers) == 1
created_containers[0].remove.assert_awaited_once()
assert handler._hook_run_container is None
@pytest.mark.asyncio
async def test_hook_run_end_keeps_container_with_messages(
monkeypatch: pytest.MonkeyPatch,
) -> None:
created_containers: list[FakeHookRunContainer] = []
def make_container() -> FakeHookRunContainer:
container = FakeHookRunContainer()
created_containers.append(container)
return container
monkeypatch.setattr(event_handler_module, "HookRunContainer", make_container)
mount_callback = AsyncMock()
handler = EventHandler(
mount_callback=mount_callback, get_tools_collapsed=lambda: False
)
await handler.handle_event(HookRunStartEvent())
await handler.handle_event(
HookEndEvent(
hook_name="post-turn", status=HookMessageSeverity.OK, content="Hook output"
)
)
await handler.handle_event(HookRunEndEvent())
assert len(created_containers) == 1
created_containers[0].remove.assert_not_awaited()
assert created_containers[0].display is True
assert handler._hook_run_container is None

View file

@ -0,0 +1,169 @@
from __future__ import annotations
import time
from unittest.mock import MagicMock, patch
import pytest
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.quit_manager import QUIT_CONFIRM_DELAY, QuitManager
@pytest.fixture
def app() -> VibeApp:
return build_test_vibe_app()
@pytest.fixture
def qm() -> QuitManager:
mock_app = MagicMock()
mock_app.query_one.side_effect = Exception("not mounted")
mock_app.set_timer.return_value = MagicMock()
return QuitManager(mock_app)
class TestQuitManager:
def test_not_confirmed_initially(self, qm: QuitManager) -> None:
assert qm.is_confirmed("Ctrl+C") is False
assert qm.is_confirmed("Ctrl+D") is False
def test_confirmed_within_delay(self, qm: QuitManager) -> None:
qm.request_confirmation("Ctrl+C")
assert qm.is_confirmed("Ctrl+C") is True
def test_wrong_key_not_confirmed(self, qm: QuitManager) -> None:
qm.request_confirmation("Ctrl+C")
assert qm.is_confirmed("Ctrl+D") is False
def test_expired_not_confirmed(self, qm: QuitManager) -> None:
qm.request_confirmation("Ctrl+C")
qm._confirm_time = time.monotonic() - QUIT_CONFIRM_DELAY - 0.1
assert qm.is_confirmed("Ctrl+C") is False
def test_request_resets_timer_on_key_switch(self, qm: QuitManager) -> None:
qm.request_confirmation("Ctrl+C")
qm._confirm_time = time.monotonic() - QUIT_CONFIRM_DELAY + 0.05
qm.request_confirmation("Ctrl+D")
assert qm.is_confirmed("Ctrl+D") is True
def test_confirm_key_property(self, qm: QuitManager) -> None:
assert qm.confirm_key is None
qm.request_confirmation("Ctrl+D")
assert qm.confirm_key == "Ctrl+D"
def test_request_schedules_cancel_timer(self, qm: QuitManager) -> None:
qm.request_confirmation("Ctrl+D")
mock_app = qm._app
assert isinstance(mock_app, MagicMock)
mock_app.set_timer.assert_called_once_with(
QUIT_CONFIRM_DELAY, qm.cancel_confirmation
)
def test_request_stops_previous_timer(self, qm: QuitManager) -> None:
qm.request_confirmation("Ctrl+C")
first_timer = qm._confirm_timer
assert isinstance(first_timer, MagicMock)
qm.request_confirmation("Ctrl+D")
first_timer.stop.assert_called_once()
def test_cancel_confirmation_resets_state(self, qm: QuitManager) -> None:
qm.request_confirmation("Ctrl+C")
qm.cancel_confirmation()
assert qm.is_confirmed("Ctrl+C") is False
assert qm.confirm_key is None
assert qm._confirm_timer is None
def test_cancel_confirmation_noop_when_idle(self, qm: QuitManager) -> None:
qm.cancel_confirmation()
assert qm.confirm_key is None
class TestActionInterruptOrQuit:
def test_clears_input_when_has_value(self, app: VibeApp) -> None:
mock_container = MagicMock()
mock_container.value = "some text"
with patch.object(app, "_get_chat_input", return_value=mock_container):
app.action_interrupt_or_quit()
assert mock_container.value == ""
def test_skips_empty_input(self, app: VibeApp) -> None:
mock_container = MagicMock()
mock_container.value = ""
with (
patch.object(app, "_get_chat_input", return_value=mock_container),
patch.object(app, "_try_interrupt", return_value=False),
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
):
app.action_interrupt_or_quit()
mock_confirm.assert_called_once_with("Ctrl+C")
def test_quits_on_confirmed(self, app: VibeApp) -> None:
app._quit_manager._confirm_time = time.monotonic()
app._quit_manager._confirm_key = "Ctrl+C"
with (
patch.object(app, "_get_chat_input", return_value=None),
patch.object(app, "_force_quit") as mock_quit,
):
app.action_interrupt_or_quit()
mock_quit.assert_called_once()
def test_interrupts_before_requesting_confirmation(self, app: VibeApp) -> None:
with (
patch.object(app, "_get_chat_input", return_value=None),
patch.object(app, "_try_interrupt", return_value=True) as mock_interrupt,
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
):
app.action_interrupt_or_quit()
mock_interrupt.assert_called_once()
mock_confirm.assert_not_called()
def test_requests_confirmation_when_nothing_to_interrupt(
self, app: VibeApp
) -> None:
with (
patch.object(app, "_get_chat_input", return_value=None),
patch.object(app, "_try_interrupt", return_value=False),
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
):
app.action_interrupt_or_quit()
mock_confirm.assert_called_once_with("Ctrl+C")
class TestActionDeleteRightOrQuit:
def test_deletes_right_when_input_has_value(self, app: VibeApp) -> None:
mock_input = MagicMock()
mock_container = MagicMock()
mock_container.value = "some text"
mock_container.input_widget = mock_input
with patch.object(app, "_get_chat_input", return_value=mock_container):
app.action_delete_right_or_quit()
mock_input.action_delete_right.assert_called_once()
def test_skips_empty_input(self, app: VibeApp) -> None:
mock_container = MagicMock()
mock_container.value = ""
with (
patch.object(app, "_get_chat_input", return_value=mock_container),
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
):
app.action_delete_right_or_quit()
mock_confirm.assert_called_once_with("Ctrl+D")
def test_quits_on_confirmed(self, app: VibeApp) -> None:
app._quit_manager._confirm_time = time.monotonic()
app._quit_manager._confirm_key = "Ctrl+D"
with (
patch.object(app, "_get_chat_input", return_value=None),
patch.object(app, "_force_quit") as mock_quit,
):
app.action_delete_right_or_quit()
mock_quit.assert_called_once()
def test_requests_confirmation_when_no_input(self, app: VibeApp) -> None:
with (
patch.object(app, "_get_chat_input", return_value=None),
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
):
app.action_delete_right_or_quit()
mock_confirm.assert_called_once_with("Ctrl+D")