from __future__ import annotations import pytest from tests.conftest import build_test_agent_loop, build_test_vibe_app from tests.mock.utils import mock_llm_chunk from tests.stubs.fake_backend import FakeBackend from vibe.cli.textual_ui.app import BottomApp, VibeApp from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer from vibe.cli.textual_ui.widgets.messages import UserMessage def _make_app(num_responses: int = 3) -> VibeApp: backend = FakeBackend([ mock_llm_chunk(content=f"Response {i + 1}") for i in range(num_responses) ]) agent_loop = build_test_agent_loop(backend=backend) return build_test_vibe_app(agent_loop=agent_loop) async def _send_messages(pilot, messages: list[str]) -> None: for msg in messages: await pilot.press(*msg) await pilot.press("enter") await pilot.pause(0.4) @pytest.mark.asyncio async def test_rewind_mode_activates_on_alt_up() -> None: app = _make_app() async with app.run_test() as pilot: await _send_messages(pilot, ["hello", "world"]) await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_mode is True assert app._current_bottom_app == BottomApp.Rewind @pytest.mark.asyncio async def test_rewind_highlights_last_user_message() -> None: app = _make_app() async with app.run_test() as pilot: await _send_messages(pilot, ["hello", "world"]) await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None assert app._rewind_highlighted_widget.get_content() == "world" @pytest.mark.asyncio async def test_rewind_navigates_to_previous_message() -> None: app = _make_app() async with app.run_test() as pilot: await _send_messages(pilot, ["hello", "world"]) await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None assert app._rewind_highlighted_widget.get_content() == "hello" @pytest.mark.asyncio async def test_rewind_navigates_down() -> None: app = _make_app() async with app.run_test() as pilot: await _send_messages(pilot, ["hello", "world"]) # Go up twice, then down once await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) await pilot.press("alt+down") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None assert app._rewind_highlighted_widget.get_content() == "world" @pytest.mark.asyncio async def test_rewind_escape_exits_mode() -> None: app = _make_app() async with app.run_test() as pilot: await _send_messages(pilot, ["hello", "world"]) await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) await pilot.press("escape") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_mode is False assert app._rewind_highlighted_widget is None assert app._current_bottom_app == BottomApp.Input @pytest.mark.asyncio async def test_rewind_ctrl_p_n_alternate_bindings() -> None: app = _make_app() async with app.run_test() as pilot: await _send_messages(pilot, ["hello", "world"]) # ctrl+p should enter rewind mode await pilot.press("ctrl+p") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_mode is True assert app._rewind_highlighted_widget is not None assert app._rewind_highlighted_widget.get_content() == "world" # ctrl+p again goes to previous await pilot.press("ctrl+p") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None assert app._rewind_highlighted_widget.get_content() == "hello" # ctrl+n goes back await pilot.press("ctrl+n") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None assert app._rewind_highlighted_widget.get_content() == "world" @pytest.mark.asyncio async def test_rewind_confirm_edits_message_and_prefills_input() -> None: app = _make_app() async with app.run_test() as pilot: await _send_messages(pilot, ["hello", "world"]) await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) # Confirm with enter (selects "Edit message from here") await pilot.press("enter") await pilot.app.workers.wait_for_complete() await pilot.pause(0.2) assert app._rewind_mode is False assert app._current_bottom_app == BottomApp.Input # Input should be pre-filled with the rewound message chat_input = app.query_one(ChatInputContainer) assert chat_input.value == "world" @pytest.mark.asyncio async def test_rewind_removes_messages_after_selected() -> None: app = _make_app() async with app.run_test() as pilot: await _send_messages(pilot, ["first", "second", "third"]) # Navigate to "second" await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None assert app._rewind_highlighted_widget.get_content() == "second" # Confirm await pilot.press("enter") await pilot.app.workers.wait_for_complete() await pilot.pause(0.2) # Only "first" should remain as a UserMessage messages_area = app.query_one("#messages") user_widgets = [ child for child in messages_area.children if isinstance(child, UserMessage) ] assert len(user_widgets) == 1 assert user_widgets[0].get_content() == "first" @pytest.mark.asyncio async def test_rewind_skips_command_messages() -> None: """Slash-command echo messages (message_index=None) are not rewind-selectable.""" app = _make_app() async with app.run_test() as pilot: await _send_messages(pilot, ["hello"]) # Simulate a slash command inserting a UserMessage without message_index await app._mount_and_scroll(UserMessage("/model")) await pilot.pause(0.1) await _send_messages(pilot, ["world"]) # First alt+up should land on "world", not the command message await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None assert app._rewind_highlighted_widget.get_content() == "world" # Second alt+up should land on "hello", skipping the command message await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None assert app._rewind_highlighted_widget.get_content() == "hello" @pytest.mark.asyncio async def test_rewind_edits_uncommitted_user_message() -> None: app = _make_app() async with app.run_test() as pilot: await app._mount_and_scroll(UserMessage("first", message_index=1)) await pilot.pause(0.1) await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) await pilot.press("enter") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) chat_input = app.query_one(ChatInputContainer) assert app._rewind_mode is False assert chat_input.value == "first" @pytest.mark.asyncio async def test_rewind_does_not_activate_while_agent_running() -> None: app = _make_app() async with app.run_test() as pilot: await _send_messages(pilot, ["hello"]) app._agent_running = True await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) assert app._rewind_mode is False @pytest.mark.asyncio async def test_rewind_option_selection_with_number_keys() -> None: app = _make_app() async with app.run_test() as pilot: await _send_messages(pilot, ["hello"]) await pilot.press("alt+up") await pilot.app.workers.wait_for_complete() await pilot.pause(0.1) # Press "1" to select first option directly await pilot.press("1") await pilot.app.workers.wait_for_complete() await pilot.pause(0.2) assert app._rewind_mode is False assert app._current_bottom_app == BottomApp.Input