Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Guillaume LE GOFF <guillaume.lgf@gmail.com>
Co-authored-by: Hdandria <henri.dandria@mistral.ai>
Co-authored-by: Ivana Dunisijevic <ivana.dunisijevic@mistral.ai>
Co-authored-by: Jean Burellier <sheplu@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Mert Unsal <mert.unsal@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul VEZIA <166131032+le-codeur-rapide@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: renovate-mistral[bot] <253709520+renovate-mistral[bot]@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-06-19 11:01:24 +02:00 committed by GitHub
parent 564a14365e
commit 6bedf271ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
223 changed files with 10533 additions and 6947 deletions

View file

@ -1,12 +0,0 @@
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

@ -22,6 +22,11 @@ def _build(
def _render(*args, **kwargs):
kwargs.setdefault("dark", True)
args = list(args)
# Tests pass a single start line as an int for readability; the renderer
# expects a list of occurrences.
if len(args) >= 4 and isinstance(args[3], int):
args[3] = [args[3]]
return render_edit_diff(*args, **kwargs)
@ -156,6 +161,32 @@ class TestRenderEditDiff:
assert any(_plain(w).rstrip().endswith("d") for w in removed)
assert any(_plain(w).rstrip().endswith("Z") for w in added)
def test_replace_all_renders_each_occurrence(self) -> None:
widgets = render_edit_diff(
"foo", "bar", "py", [3, 10, 25], ansi=False, dark=True
)
removed = [w for w in widgets if "diff-removed" in w.classes]
added = [w for w in widgets if "diff-added" in w.classes]
assert len(removed) == 3
assert len(added) == 3
def test_replace_all_uses_each_start_line(self) -> None:
widgets = render_edit_diff(
"foo", "bar", "py", [3, 10, 25], ansi=False, dark=True
)
joined = "\n".join(_plain(w) for w in widgets)
assert "3" in joined
assert "10" in joined
assert "25" in joined
def test_replace_all_separates_occurrences_with_gap(self) -> None:
widgets = render_edit_diff("foo", "bar", "py", [3, 10], ansi=False, dark=True)
assert sum("diff-gap" in w.classes for w in widgets) == 1
def test_single_occurrence_has_no_gap(self) -> None:
widgets = render_edit_diff("foo", "bar", "py", [3], ansi=False, dark=True)
assert all("diff-gap" not in w.classes for w in widgets)
class TestBorderColors:
def test_keys_index_into_widgets(self) -> None:

View file

@ -39,10 +39,12 @@ async def test_no_queue_header_when_empty(vibe_app: VibeApp) -> None:
async def test_bash_submitted_during_running_bash_is_queued(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!sleep 0.3"
chat_input.value = "!sleep 1"
await pilot.press("enter")
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=1.0)
assert await _wait_until(
pilot, lambda: vibe_app._bash_task is not None, timeout=2.0
)
chat_input.value = "!echo queued"
await pilot.press("enter")
@ -56,6 +58,15 @@ async def test_bash_submitted_during_running_bash_is_queued(vibe_app: VibeApp) -
queued_bashes = [w for w in vibe_app.query(BashOutputMessage) if w._queued]
assert len(queued_bashes) == 1
await pilot.press("ctrl+c")
assert await _wait_until(
pilot, lambda: len(vibe_app._input_queue) == 0, timeout=2.0
)
await pilot.press("escape")
assert await _wait_until(
pilot, lambda: vibe_app._bash_task is None, timeout=5.0
)
@pytest.mark.asyncio
async def test_slash_command_rejected_with_warning_when_busy(vibe_app: VibeApp) -> None:

View file

@ -1,12 +1,13 @@
from __future__ import annotations
import asyncio
import time
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, 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.app import VibeApp, _run_app_with_cleanup
from vibe.cli.textual_ui.quit_manager import QUIT_CONFIRM_DELAY, QuitManager
@ -182,3 +183,69 @@ class TestActionDeleteRightOrQuit:
mock_confirm.assert_called_once_with(
"Ctrl+D", "1 queued message will be discarded"
)
@pytest.mark.asyncio
async def test_shutdown_cleanup_cancels_in_flight_tasks(app: VibeApp) -> None:
async def _pending() -> None:
await asyncio.Event().wait()
agent_task = asyncio.create_task(_pending())
bash_task = asyncio.create_task(_pending())
app._agent_task = agent_task
app._bash_task = bash_task
await asyncio.wait_for(app.shutdown_cleanup(), timeout=1.0)
assert agent_task.cancelled()
assert bash_task.cancelled()
@pytest.mark.asyncio
async def test_shutdown_disables_future_queue_drains(app: VibeApp) -> None:
app._input_queue.append_prompt("queued")
await app._begin_shutdown()
with patch("vibe.cli.textual_ui.message_queue.asyncio.create_task") as create_task:
app._queue.start_drain_if_needed()
create_task.assert_not_called()
@pytest.mark.asyncio
async def test_begin_shutdown_stops_scheduled_loop_runner(app: VibeApp) -> None:
with (
patch.object(app._queue, "shutdown", new_callable=AsyncMock) as queue_shutdown,
patch.object(app._loop_runner, "stop", new_callable=AsyncMock) as loop_stop,
):
await app._begin_shutdown()
queue_shutdown.assert_awaited_once()
loop_stop.assert_awaited_once()
@pytest.mark.asyncio
async def test_shutdown_cleanup_flushes_telemetry(app: VibeApp) -> None:
with patch.object(
app.agent_loop.telemetry_client, "aclose", new_callable=AsyncMock
) as telemetry_aclose:
await app.shutdown_cleanup()
telemetry_aclose.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_app_with_cleanup_runs_cleanup_when_run_async_raises(
app: VibeApp,
) -> None:
with (
patch.object(
app, "run_async", new_callable=AsyncMock, side_effect=RuntimeError("boom")
),
patch.object(app, "shutdown_cleanup", new_callable=AsyncMock) as cleanup,
):
with pytest.raises(RuntimeError, match="boom"):
await _run_app_with_cleanup(app)
cleanup.assert_awaited_once()

View file

@ -1,286 +0,0 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from vibe.cli.textual_ui.remote.remote_session_manager import RemoteSessionManager
from vibe.core.tools.builtins.ask_user_question import AskUserQuestionArgs
from vibe.core.types import WaitingForInputEvent
@pytest.fixture
def manager() -> RemoteSessionManager:
return RemoteSessionManager()
class TestProperties:
def test_is_active_false_by_default(self, manager: RemoteSessionManager) -> None:
assert manager.is_active is False
def test_is_terminated_false_when_inactive(
self, manager: RemoteSessionManager
) -> None:
assert manager.is_terminated is False
def test_is_waiting_for_input_false_when_inactive(
self, manager: RemoteSessionManager
) -> None:
assert manager.is_waiting_for_input is False
def test_has_pending_input_false_by_default(
self, manager: RemoteSessionManager
) -> None:
assert manager.has_pending_input is False
def test_session_id_none_when_inactive(self, manager: RemoteSessionManager) -> None:
assert manager.session_id is None
class TestAttachDetach:
@pytest.mark.asyncio
async def test_attach_activates_manager(
self, manager: RemoteSessionManager
) -> None:
with patch(
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
) as MockSource:
mock_source = MagicMock()
mock_source.session_id = "test-session-id"
MockSource.return_value = mock_source
config = MagicMock()
await manager.attach(session_id="test-session-id", config=config)
assert manager.is_active is True
assert manager.session_id == "test-session-id"
@pytest.mark.asyncio
async def test_detach_cleans_up(self, manager: RemoteSessionManager) -> None:
with patch(
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
) as MockSource:
mock_source = AsyncMock()
mock_source.session_id = "test-id"
MockSource.return_value = mock_source
config = MagicMock()
await manager.attach(session_id="test-id", config=config)
await manager.detach()
assert manager.is_active is False
assert manager.session_id is None
assert manager.has_pending_input is False
mock_source.close.assert_called_once()
@pytest.mark.asyncio
async def test_attach_detaches_previous_session(
self, manager: RemoteSessionManager
) -> None:
with patch(
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
) as MockSource:
first_source = AsyncMock()
second_source = MagicMock()
second_source.session_id = "second-id"
MockSource.side_effect = [first_source, second_source]
config = MagicMock()
await manager.attach(session_id="first-id", config=config)
await manager.attach(session_id="second-id", config=config)
first_source.close.assert_called_once()
assert manager.session_id == "second-id"
class TestValidateInput:
@pytest.mark.asyncio
async def test_returns_none_when_waiting_for_input(
self, manager: RemoteSessionManager
) -> None:
with patch(
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
) as MockSource:
mock_source = MagicMock()
mock_source.is_terminated = False
mock_source.is_waiting_for_input = True
MockSource.return_value = mock_source
config = MagicMock()
await manager.attach(session_id="id", config=config)
assert manager.validate_input() is None
@pytest.mark.asyncio
async def test_returns_warning_when_terminated(
self, manager: RemoteSessionManager
) -> None:
with patch(
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
) as MockSource:
mock_source = MagicMock()
mock_source.is_terminated = True
MockSource.return_value = mock_source
config = MagicMock()
await manager.attach(session_id="id", config=config)
result = manager.validate_input()
assert result is not None
assert "ended" in result
@pytest.mark.asyncio
async def test_returns_warning_when_not_waiting_for_input(
self, manager: RemoteSessionManager
) -> None:
with patch(
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
) as MockSource:
mock_source = MagicMock()
mock_source.is_terminated = False
mock_source.is_waiting_for_input = False
MockSource.return_value = mock_source
config = MagicMock()
await manager.attach(session_id="id", config=config)
result = manager.validate_input()
assert result is not None
assert "not waiting" in result
class TestSendPrompt:
@pytest.mark.asyncio
async def test_raises_when_inactive_and_required(
self, manager: RemoteSessionManager
) -> None:
with pytest.raises(RuntimeError, match="No active remote session"):
await manager.send_prompt("hello")
@pytest.mark.asyncio
async def test_returns_silently_when_inactive_and_not_required(
self, manager: RemoteSessionManager
) -> None:
await manager.send_prompt("hello", require_source=False)
@pytest.mark.asyncio
async def test_restores_pending_on_error(
self, manager: RemoteSessionManager
) -> None:
with patch(
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
) as MockSource:
mock_source = AsyncMock()
mock_source.send_prompt.side_effect = Exception("connection error")
MockSource.return_value = mock_source
config = MagicMock()
await manager.attach(session_id="id", config=config)
event = WaitingForInputEvent(task_id="t1", label="test")
manager.set_pending_input(event)
with pytest.raises(Exception, match="connection error"):
await manager.send_prompt("hello")
assert manager.has_pending_input is True
class TestPendingInput:
def test_set_and_cancel_pending_input(self, manager: RemoteSessionManager) -> None:
event = WaitingForInputEvent(task_id="t1", label="test")
manager.set_pending_input(event)
assert manager.has_pending_input is True
manager.cancel_pending_input()
assert manager.has_pending_input is False
class TestBuildQuestionArgs:
def test_returns_none_with_no_predefined_answers(
self, manager: RemoteSessionManager
) -> None:
event = WaitingForInputEvent(task_id="t1", label="test")
assert manager.build_question_args(event) is None
def test_returns_none_with_one_predefined_answer(
self, manager: RemoteSessionManager
) -> None:
event = WaitingForInputEvent(
task_id="t1", label="test", predefined_answers=["only one"]
)
assert manager.build_question_args(event) is None
def test_returns_args_with_two_predefined_answers(
self, manager: RemoteSessionManager
) -> None:
event = WaitingForInputEvent(
task_id="t1", label="Pick one", predefined_answers=["yes", "no"]
)
result = manager.build_question_args(event)
assert result is not None
assert isinstance(result, AskUserQuestionArgs)
assert len(result.questions) == 1
assert result.questions[0].question == "Pick one"
assert len(result.questions[0].options) == 2
def test_caps_at_four_predefined_answers(
self, manager: RemoteSessionManager
) -> None:
event = WaitingForInputEvent(
task_id="t1",
label="Pick",
predefined_answers=["a", "b", "c", "d", "e", "f"],
)
result = manager.build_question_args(event)
assert result is not None
assert len(result.questions[0].options) == 4
def test_uses_default_question_when_no_label(
self, manager: RemoteSessionManager
) -> None:
event = WaitingForInputEvent(task_id="t1", predefined_answers=["a", "b"])
result = manager.build_question_args(event)
assert result is not None
assert result.questions[0].question == "Choose an answer"
class TestBuildTerminalMessage:
def test_completed_when_no_source(self, manager: RemoteSessionManager) -> None:
msg_type, text = manager.build_terminal_message()
assert msg_type == "info"
assert "completed" in text
@pytest.mark.asyncio
async def test_failed_state(self, manager: RemoteSessionManager) -> None:
with patch(
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
) as MockSource:
mock_source = MagicMock()
mock_source.is_failed = True
mock_source.is_canceled = False
MockSource.return_value = mock_source
config = MagicMock()
await manager.attach(session_id="id", config=config)
msg_type, text = manager.build_terminal_message()
assert msg_type == "error"
assert "failed" in text.lower()
@pytest.mark.asyncio
async def test_canceled_state(self, manager: RemoteSessionManager) -> None:
with patch(
"vibe.cli.textual_ui.remote.remote_session_manager.RemoteEventsSource"
) as MockSource:
mock_source = MagicMock()
mock_source.is_failed = False
mock_source.is_canceled = True
MockSource.return_value = mock_source
config = MagicMock()
await manager.attach(session_id="id", config=config)
msg_type, text = manager.build_terminal_message()
assert msg_type == "warning"
assert "canceled" in text.lower()

View file

@ -19,25 +19,21 @@ def sample_sessions() -> list[ResumeSessionInfo]:
return [
ResumeSessionInfo(
session_id="session-a",
source="local",
cwd="/test",
title="Session A",
end_time=(datetime.now(UTC) - timedelta(minutes=5)).isoformat(),
),
ResumeSessionInfo(
session_id="session-b",
source="local",
cwd="/test",
title="Session B",
end_time=(datetime.now(UTC) - timedelta(hours=1)).isoformat(),
),
ResumeSessionInfo(
session_id="session-c",
source="remote",
cwd="/test",
title="Session C",
end_time=(datetime.now(UTC) - timedelta(days=1)).isoformat(),
status="RUNNING",
),
]
@ -45,9 +41,9 @@ def sample_sessions() -> list[ResumeSessionInfo]:
@pytest.fixture
def sample_latest_messages() -> dict[str, str]:
return {
"local:session-a": "Help me fix this bug",
"local:session-b": "Refactor the authentication module",
"remote:session-c": "Add unit tests for the API",
"session-a": "Help me fix this bug",
"session-b": "Refactor the authentication module",
"session-c": "Add unit tests for the API",
}
@ -143,31 +139,19 @@ class TestSessionPickerAppInit:
class TestSessionPickerMessages:
def test_session_selected_stores_option_id(self) -> None:
msg = SessionPickerApp.SessionSelected(
"local:test-session-id", "local", "test-session-id"
)
assert msg.option_id == "local:test-session-id"
assert msg.source == "local"
msg = SessionPickerApp.SessionSelected("test-session-id", "test-session-id")
assert msg.option_id == "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"
option_id = f"remote:{session_id}"
msg = SessionPickerApp.SessionSelected(option_id, "remote", session_id)
assert msg.option_id == option_id
assert msg.source == "remote"
assert msg.session_id == session_id
def test_cancelled_can_be_instantiated(self) -> None:
msg = SessionPickerApp.Cancelled()
assert isinstance(msg, SessionPickerApp.Cancelled)
def test_session_delete_requested_stores_session_info(self) -> None:
msg = SessionPickerApp.SessionDeleteRequested(
"local:test-session-id", "local", "test-session-id"
"test-session-id", "test-session-id"
)
assert msg.option_id == "local:test-session-id"
assert msg.source == "local"
assert msg.option_id == "test-session-id"
assert msg.session_id == "test-session-id"
@ -199,15 +183,15 @@ class TestSessionPickerSessionRemoval:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
option_list = FakeOptionList(highlighted_option_id="local:session-a")
option_list = FakeOptionList(highlighted_option_id="session-a")
posted_messages: list[object] = []
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
monkeypatch.setattr(picker, "post_message", posted_messages.append)
picker.action_request_delete()
assert_delete_state(picker, kind="confirmation", option_id="local:session-a")
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
assert_delete_state(picker, kind="confirmation", option_id="session-a")
assert option_list.replaced_prompts[-1].option_id == "session-a"
assert (
"Press D again to delete" in option_list.replaced_prompts[-1].prompt.plain
)
@ -222,7 +206,7 @@ class TestSessionPickerSessionRemoval:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
option_list = FakeOptionList(highlighted_option_id="local:session-a")
option_list = FakeOptionList(highlighted_option_id="session-a")
posted_messages: list[object] = []
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
monkeypatch.setattr(picker, "post_message", posted_messages.append)
@ -230,14 +214,13 @@ class TestSessionPickerSessionRemoval:
picker.action_request_delete()
picker.action_request_delete()
assert_delete_state(picker, kind="pending", option_id="local:session-a")
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
assert_delete_state(picker, kind="pending", option_id="session-a")
assert option_list.replaced_prompts[-1].option_id == "session-a"
assert "Deleting..." in option_list.replaced_prompts[-1].prompt.plain
assert len(posted_messages) == 1
message = posted_messages[0]
assert isinstance(message, SessionPickerApp.SessionDeleteRequested)
assert message.option_id == "local:session-a"
assert message.source == "local"
assert message.option_id == "session-a"
assert message.session_id == "session-a"
def test_delete_confirmation_is_consumed_after_request(
@ -249,7 +232,7 @@ class TestSessionPickerSessionRemoval:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
option_list = FakeOptionList(highlighted_option_id="local:session-a")
option_list = FakeOptionList(highlighted_option_id="session-a")
posted_messages: list[object] = []
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
monkeypatch.setattr(picker, "post_message", posted_messages.append)
@ -259,34 +242,10 @@ class TestSessionPickerSessionRemoval:
picker.action_request_delete()
assert len(posted_messages) == 1
assert_delete_state(picker, kind="pending", option_id="local:session-a")
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
assert_delete_state(picker, kind="pending", option_id="session-a")
assert option_list.replaced_prompts[-1].option_id == "session-a"
assert "Deleting..." in option_list.replaced_prompts[-1].prompt.plain
def test_delete_request_shows_feedback_for_remote_sessions(
self,
sample_sessions: list[ResumeSessionInfo],
sample_latest_messages: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
option_list = FakeOptionList(highlighted_option_id="remote:session-c")
posted_messages: list[object] = []
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
monkeypatch.setattr(picker, "post_message", posted_messages.append)
picker.action_request_delete()
assert_delete_state(picker, kind="feedback", option_id="remote:session-c")
assert option_list.replaced_prompts[-1].option_id == "remote:session-c"
assert (
"Can't delete remote session"
in option_list.replaced_prompts[-1].prompt.plain
)
assert posted_messages == []
def test_delete_request_shows_feedback_for_current_session(
self,
sample_sessions: list[ResumeSessionInfo],
@ -298,15 +257,15 @@ class TestSessionPickerSessionRemoval:
latest_messages=sample_latest_messages,
current_session_id="session-a",
)
option_list = FakeOptionList(highlighted_option_id="local:session-a")
option_list = FakeOptionList(highlighted_option_id="session-a")
posted_messages: list[object] = []
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
monkeypatch.setattr(picker, "post_message", posted_messages.append)
picker.action_request_delete()
assert_delete_state(picker, kind="feedback", option_id="local:session-a")
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
assert_delete_state(picker, kind="feedback", option_id="session-a")
assert option_list.replaced_prompts[-1].option_id == "session-a"
assert (
"Can't delete current session"
in option_list.replaced_prompts[-1].prompt.plain
@ -315,7 +274,7 @@ class TestSessionPickerSessionRemoval:
picker.action_request_delete()
assert_delete_state(picker, kind="feedback", option_id="local:session-a")
assert_delete_state(picker, kind="feedback", option_id="session-a")
assert posted_messages == []
def test_pending_delete_blocks_resume_selection(
@ -327,7 +286,7 @@ class TestSessionPickerSessionRemoval:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
option_list = FakeOptionList(highlighted_option_id="local:session-a")
option_list = FakeOptionList(highlighted_option_id="session-a")
posted_messages: list[object] = []
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
monkeypatch.setattr(picker, "post_message", posted_messages.append)
@ -335,15 +294,15 @@ class TestSessionPickerSessionRemoval:
picker.action_request_delete()
picker.action_request_delete()
picker.on_option_list_option_selected(
cast(OptionList.OptionSelected, FakeOptionEvent("local:session-a"))
cast(OptionList.OptionSelected, FakeOptionEvent("session-a"))
)
picker.on_option_list_option_selected(
cast(OptionList.OptionSelected, FakeOptionEvent("local:session-b"))
cast(OptionList.OptionSelected, FakeOptionEvent("session-b"))
)
assert len(posted_messages) == 1
assert isinstance(posted_messages[0], SessionPickerApp.SessionDeleteRequested)
assert_delete_state(picker, kind="pending", option_id="local:session-a")
assert_delete_state(picker, kind="pending", option_id="session-a")
def test_clear_pending_delete_restores_session_option(
self,
@ -354,7 +313,7 @@ class TestSessionPickerSessionRemoval:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
option_list = FakeOptionList(highlighted_option_id="local:session-a")
option_list = FakeOptionList(highlighted_option_id="session-a")
posted_messages: list[object] = []
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
monkeypatch.setattr(picker, "post_message", posted_messages.append)
@ -362,9 +321,9 @@ class TestSessionPickerSessionRemoval:
picker.action_request_delete()
picker.action_request_delete()
assert picker.clear_pending_delete("local:session-a") is True
assert picker.clear_pending_delete("session-a") is True
assert picker._delete_state is None
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
assert option_list.replaced_prompts[-1].option_id == "session-a"
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
def test_highlighting_another_session_clears_confirmation(
@ -376,16 +335,16 @@ class TestSessionPickerSessionRemoval:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
option_list = FakeOptionList(highlighted_option_id="local:session-a")
option_list = FakeOptionList(highlighted_option_id="session-a")
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
picker.action_request_delete()
picker.on_option_list_option_highlighted(
cast(OptionList.OptionHighlighted, FakeOptionEvent("local:session-b"))
cast(OptionList.OptionHighlighted, FakeOptionEvent("session-b"))
)
assert picker._delete_state is None
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
assert option_list.replaced_prompts[-1].option_id == "session-a"
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
def test_highlighting_another_session_clears_delete_feedback(
@ -395,18 +354,20 @@ class TestSessionPickerSessionRemoval:
monkeypatch: pytest.MonkeyPatch,
) -> None:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
sessions=sample_sessions,
latest_messages=sample_latest_messages,
current_session_id="session-c",
)
option_list = FakeOptionList(highlighted_option_id="remote:session-c")
option_list = FakeOptionList(highlighted_option_id="session-c")
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
picker.action_request_delete()
picker.on_option_list_option_highlighted(
cast(OptionList.OptionHighlighted, FakeOptionEvent("local:session-b"))
cast(OptionList.OptionHighlighted, FakeOptionEvent("session-b"))
)
assert picker._delete_state is None
assert option_list.replaced_prompts[-1].option_id == "remote:session-c"
assert option_list.replaced_prompts[-1].option_id == "session-c"
assert (
"Add unit tests for the API"
in option_list.replaced_prompts[-1].prompt.plain
@ -421,7 +382,7 @@ class TestSessionPickerSessionRemoval:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
option_list = FakeOptionList(highlighted_option_id="local:session-a")
option_list = FakeOptionList(highlighted_option_id="session-a")
posted_messages: list[object] = []
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
monkeypatch.setattr(picker, "post_message", posted_messages.append)
@ -444,9 +405,11 @@ class TestSessionPickerSessionRemoval:
monkeypatch: pytest.MonkeyPatch,
) -> None:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
sessions=sample_sessions,
latest_messages=sample_latest_messages,
current_session_id="session-c",
)
option_list = FakeOptionList(highlighted_option_id="remote:session-c")
option_list = FakeOptionList(highlighted_option_id="session-c")
posted_messages: list[object] = []
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
monkeypatch.setattr(picker, "post_message", posted_messages.append)
@ -471,20 +434,20 @@ class TestSessionPickerSessionRemoval:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
option_list = FakeOptionList(highlighted_option_id="local:session-a")
option_list = FakeOptionList(highlighted_option_id="session-a")
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
picker.action_request_delete()
assert_delete_state(picker, kind="confirmation", option_id="local:session-a")
assert_delete_state(picker, kind="confirmation", option_id="session-a")
assert picker.remove_session("local:session-a") is True
assert picker.remove_session("session-a") is True
assert [session.option_id for session in picker._sessions] == [
"local:session-b",
"remote:session-c",
"session-b",
"session-c",
]
assert "local:session-a" not in picker._latest_messages
assert "session-a" not in picker._latest_messages
assert picker._delete_state is None
assert option_list.removed_option_ids == ["local:session-a"]
assert option_list.removed_option_ids == ["session-a"]
def test_remove_missing_session_returns_false(
self,
@ -498,7 +461,7 @@ class TestSessionPickerSessionRemoval:
option_list = FakeOptionList()
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
assert picker.remove_session("local:missing") is False
assert picker.remove_session("missing") is False
assert picker._sessions == sample_sessions
assert picker._latest_messages == sample_latest_messages

View file

@ -5,12 +5,14 @@ from weakref import WeakKeyDictionary
from vibe.cli.textual_ui.widgets.messages import UserMessage
from vibe.cli.textual_ui.windowing.history import build_history_widgets
from vibe.core.types import ImageAttachment, LLMMessage, Role
from vibe.core.types import FileImageSource, ImageAttachment, LLMMessage, Role
def _att(path: Path, alias: str) -> ImageAttachment:
path.write_bytes(b"\x89PNG")
return ImageAttachment(path=path, alias=alias, mime_type="image/png")
return ImageAttachment(
source=FileImageSource(path=path), alias=alias, mime_type="image/png"
)
def test_attachments_footer_singular(tmp_path: Path) -> None: