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: Nelson PROIA <144663685+Nelson-PROIA@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: Quentin <quentin.torroba@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: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Guillaume LE GOFF 2026-06-12 13:16:04 +02:00 committed by GitHub
parent 702d0f412e
commit cafb6d4147
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 12401 additions and 3029 deletions

View file

@ -268,8 +268,16 @@ def test_installed_bundle_launches(binary_dir: Path, binary_name: str) -> None:
env = _isolated_env(vibe_home)
env["PATH"] = f"{install_dir}{os.pathsep}{env.get('PATH', '')}"
command = binary_name
if platform.system() == "Windows":
# subprocess on Windows does not resolve executables through a PATH
# value supplied only via env, so resolve it against that PATH first.
if (resolved := shutil.which(binary_name, path=env["PATH"])) is None:
_fail(f"installed binary not found on PATH: {binary_name}")
command = resolved
result = subprocess.run(
[binary_name, "--version"],
[command, "--version"],
capture_output=True,
cwd=workdir,
env=env,

View file

@ -101,6 +101,7 @@ class TestCommandRegistry:
assert result is not None
_, cmd, _ = result
assert cmd.handler == "_show_session_picker"
assert cmd.description == "Browse, resume, or delete saved sessions"
def test_rename_command_registration(self) -> None:
registry = CommandRegistry()
@ -143,3 +144,47 @@ class TestCommandRegistry:
assert cmd_name == "loop"
assert cmd.handler == "_loop_command"
assert cmd_args == "30s ping"
def test_exit_command_accepts_bare_synonyms(self) -> None:
registry = CommandRegistry()
for alias in ["/exit", "exit", "quit", ":q", ":quit"]:
assert registry.get_command_name(alias) == "exit", alias
result = registry.parse_command(alias)
assert result is not None, alias
cmd_name, cmd, _ = result
assert cmd_name == "exit"
assert cmd.handler == "_exit_app"
assert cmd.exits is True
def test_bare_exit_synonym_with_trailing_text_is_not_a_command(self) -> None:
registry = CommandRegistry()
assert registry.parse_command("exit the function early") is None
assert registry.parse_command("quit your job") is None
def test_bare_exit_synonym_in_multiline_message_is_not_a_command(self) -> None:
registry = CommandRegistry()
assert registry.parse_command("exit\nplease refactor this module") is None
def test_slash_exit_still_parses_with_trailing_text(self) -> None:
registry = CommandRegistry()
result = registry.parse_command("/exit now")
assert result is not None
cmd_name, _, cmd_args = result
assert cmd_name == "exit"
assert cmd_args == "now"
def test_exit_command_synonyms_are_case_insensitive(self) -> None:
registry = CommandRegistry()
for alias in ["EXIT", "Quit", " exit ", ":Q"]:
assert registry.get_command_name(alias) == "exit", alias
def test_exit_synonyms_excluded_when_command_disabled(self) -> None:
registry = CommandRegistry(excluded_commands=["exit"])
for alias in ["/exit", "exit", "quit", ":q", ":quit"]:
assert registry.get_command_name(alias) is None, alias
def test_help_text_lists_exit_synonyms(self) -> None:
registry = CommandRegistry()
help_text = registry.get_help_text()
for alias in ["`/exit`", "`exit`", "`quit`", "`:q`", "`:quit`"]:
assert alias in help_text, alias

31
tests/cli/test_help.py Normal file
View file

@ -0,0 +1,31 @@
from __future__ import annotations
import pytest
from vibe.cli.entrypoint import parse_arguments
def test_help_shows_auto_approve_flag(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr("sys.argv", ["vibe", "--help"])
with pytest.raises(SystemExit) as exc_info:
parse_arguments()
assert exc_info.value.code == 0
output = capsys.readouterr().out
assert "--auto-approve" in output
assert "Shortcut for --agent auto-approve" in output
def test_auto_approve_conflicts_with_agent(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr("sys.argv", ["vibe", "--agent", "plan", "--auto-approve"])
with pytest.raises(SystemExit) as exc_info:
parse_arguments()
assert exc_info.value.code == 2
assert "not allowed with argument --agent" in capsys.readouterr().err

View file

@ -7,8 +7,10 @@ from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import VibeConfig
def _make_args(*, agent: str | None, prompt: str | None) -> argparse.Namespace:
return argparse.Namespace(agent=agent, prompt=prompt)
def _make_args(
*, agent: str | None, prompt: str | None, auto_approve: bool = False
) -> argparse.Namespace:
return argparse.Namespace(agent=agent, prompt=prompt, auto_approve=auto_approve)
def test_uses_args_agent_when_provided() -> None:
@ -51,3 +53,10 @@ def test_programmatic_mode_keeps_explicit_agent_arg() -> None:
args = _make_args(agent="accept-edits", prompt="hello")
assert get_initial_agent_name(args, config) == "accept-edits"
def test_auto_approve_flag_selects_auto_approve_agent() -> None:
config = VibeConfig.model_construct(default_agent=BuiltinAgentName.PLAN)
args = _make_args(agent=None, prompt="hello", auto_approve=True)
assert get_initial_agent_name(args, config) == BuiltinAgentName.AUTO_APPROVE

View file

@ -0,0 +1,230 @@
from __future__ import annotations
from pathlib import Path
import pytest
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
from vibe.core.config import SessionLoggingConfig
from vibe.core.session.resume_sessions import short_session_id
def _enabled_session_config(save_dir: Path) -> SessionLoggingConfig:
return SessionLoggingConfig(enabled=True, save_dir=str(save_dir))
class FakeSessionPicker:
def __init__(self, *, has_sessions_after_remove: bool = True) -> None:
self.has_sessions = True
self.removed_option_ids: list[str] = []
self.cleared_pending_option_ids: list[str] = []
self._has_sessions_after_remove = has_sessions_after_remove
def remove_session(self, option_id: str) -> bool:
self.removed_option_ids.append(option_id)
self.has_sessions = self._has_sessions_after_remove
return True
def clear_pending_delete(self, option_id: str) -> bool:
self.cleared_pending_option_ids.append(option_id)
return True
@pytest.mark.asyncio
async def test_session_delete_request_deletes_local_session(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = build_test_vibe_config(
session_logging=_enabled_session_config(tmp_path), enable_connectors=False
)
app = build_test_vibe_app(config=config)
picker = FakeSessionPicker()
mounted_widgets: list[object] = []
deleted_sessions: list[tuple[str, SessionLoggingConfig]] = []
async def delete_session(
session_id: str, session_config: SessionLoggingConfig
) -> None:
deleted_sessions.append((session_id, session_config))
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
mounted_widgets.append(widget)
monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session)
monkeypatch.setattr(app, "query_one", lambda _selector: picker)
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
await app.on_session_picker_app_session_delete_requested(
SessionPickerApp.SessionDeleteRequested(
"local:deleted-session", "local", "deleted-session"
)
)
assert deleted_sessions == [("deleted-session", config.session_logging)]
assert picker.removed_option_ids == ["local:deleted-session"]
assert any(
isinstance(widget, UserCommandMessage)
and widget._content
== f"Deleted session `{short_session_id('deleted-session')}`."
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_session_delete_request_keeps_picker_on_delete_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = build_test_vibe_config(
session_logging=_enabled_session_config(tmp_path), enable_connectors=False
)
app = build_test_vibe_app(config=config)
picker = FakeSessionPicker()
mounted_widgets: list[object] = []
async def delete_session(
session_id: str, session_config: SessionLoggingConfig
) -> None:
raise RuntimeError("disk said no")
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
mounted_widgets.append(widget)
monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session)
monkeypatch.setattr(app, "query_one", lambda _selector: picker)
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
await app.on_session_picker_app_session_delete_requested(
SessionPickerApp.SessionDeleteRequested(
"local:deleted-session", "local", "deleted-session"
)
)
assert picker.removed_option_ids == []
assert picker.cleared_pending_option_ids == ["local:deleted-session"]
assert any(
isinstance(widget, ErrorMessage)
and widget._error == "Failed to delete session: disk said no"
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_session_delete_request_returns_to_input_when_picker_becomes_empty(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = build_test_vibe_config(
session_logging=_enabled_session_config(tmp_path), enable_connectors=False
)
app = build_test_vibe_app(config=config)
picker = FakeSessionPicker(has_sessions_after_remove=False)
mounted_widgets: list[object] = []
switched_to_input = False
async def delete_session(
session_id: str, session_config: SessionLoggingConfig
) -> None:
pass
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
mounted_widgets.append(widget)
async def switch_to_input() -> None:
nonlocal switched_to_input
switched_to_input = True
monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session)
monkeypatch.setattr(app, "query_one", lambda _selector: picker)
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
monkeypatch.setattr(app, "_switch_to_input_app", switch_to_input)
await app.on_session_picker_app_session_delete_requested(
SessionPickerApp.SessionDeleteRequested(
"local:deleted-session", "local", "deleted-session"
)
)
assert switched_to_input is True
assert picker.removed_option_ids == ["local:deleted-session"]
assert [
widget._content
for widget in mounted_widgets
if isinstance(widget, UserCommandMessage)
] == [
f"Deleted session `{short_session_id('deleted-session')}`.",
"No saved sessions left for this directory.",
]
@pytest.mark.asyncio
async def test_session_delete_request_rejects_remote_sessions(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = build_test_vibe_config(
session_logging=_enabled_session_config(tmp_path), enable_connectors=False
)
app = build_test_vibe_app(config=config)
mounted_widgets: list[object] = []
async def delete_session(
session_id: str, session_config: SessionLoggingConfig
) -> None:
pytest.fail("remote sessions should not be deleted")
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
mounted_widgets.append(widget)
monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session)
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
await app.on_session_picker_app_session_delete_requested(
SessionPickerApp.SessionDeleteRequested(
"remote:remote-session", "remote", "remote-session"
)
)
assert any(
isinstance(widget, ErrorMessage)
and widget._error == "Deleting remote sessions is not supported."
for widget in mounted_widgets
)
@pytest.mark.asyncio
async def test_session_delete_request_rejects_current_session(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
config = build_test_vibe_config(
session_logging=_enabled_session_config(tmp_path), enable_connectors=False
)
app = build_test_vibe_app(config=config)
picker = FakeSessionPicker()
mounted_widgets: list[object] = []
deleted_sessions: list[str] = []
async def delete_session(
session_id: str, session_config: SessionLoggingConfig
) -> None:
deleted_sessions.append(session_id)
async def mount_and_scroll(widget: object, after: object | None = None) -> None:
mounted_widgets.append(widget)
monkeypatch.setattr("vibe.cli.textual_ui.app.delete_saved_session", delete_session)
monkeypatch.setattr(app, "query_one", lambda _selector: picker)
monkeypatch.setattr(app, "_mount_and_scroll", mount_and_scroll)
await app.on_session_picker_app_session_delete_requested(
SessionPickerApp.SessionDeleteRequested(
f"local:{app.agent_loop.session_id}", "local", app.agent_loop.session_id
)
)
assert deleted_sessions == []
assert picker.cleared_pending_option_ids == [f"local:{app.agent_loop.session_id}"]
assert any(
isinstance(widget, ErrorMessage)
and widget._error == "Deleting the current session is not supported."
for widget in mounted_widgets
)

View file

@ -145,6 +145,35 @@ async def test_ui_displays_multiple_user_assistant_turns(
assert assistant_messages[1]._content == "Second answer"
@pytest.mark.asyncio
async def test_ui_displays_messages_when_resuming_in_dangerous_directory(
monkeypatch: pytest.MonkeyPatch, vibe_config: VibeConfig
) -> None:
monkeypatch.setattr(
"vibe.cli.textual_ui.app.is_dangerous_directory",
lambda: (True, "You are in the home directory"),
)
agent_loop = build_test_agent_loop(config=vibe_config)
agent_loop.messages.extend([
LLMMessage(role=Role.user, content="Hello from a previous run"),
LLMMessage(role=Role.assistant, content="Welcome back!"),
])
app = build_test_vibe_app(agent_loop=agent_loop)
async with app.run_test() as pilot:
await pilot.pause(0.5)
user_messages = app.query(UserMessage)
assistant_messages = app.query(AssistantMessage)
assert len(user_messages) == 1
assert user_messages[0]._content == "Hello from a previous run"
assert len(assistant_messages) == 1
assert assistant_messages[0]._content == "Welcome back!"
@pytest.mark.asyncio
async def test_ui_rebuilds_history_when_whats_new_is_shown(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path

View file

@ -129,3 +129,30 @@ async def test_skill_without_args_does_not_prepend_invocation_line(
vibe_app_with_skills, pilot, "Do the thing."
)
assert "/my-skill" not in message._content
@pytest.mark.asyncio
async def test_popped_queued_skill_does_not_fire_telemetry(
vibe_app_with_skills: VibeApp, monkeypatch: pytest.MonkeyPatch
) -> None:
async with vibe_app_with_skills.run_test() as pilot:
events: list[tuple[str, str]] = []
monkeypatch.setattr(
vibe_app_with_skills.agent_loop.telemetry_client,
"send_slash_command_used",
lambda name, kind: events.append((name, kind)),
)
chat_input = vibe_app_with_skills.query_one(ChatInputContainer)
vibe_app_with_skills._agent_running = True
try:
chat_input.post_message(ChatInputContainer.Submitted("/my-skill"))
await pilot.pause(0.1)
assert len(vibe_app_with_skills._input_queue) == 1
await pilot.press("ctrl+c")
await pilot.pause(0.1)
assert len(vibe_app_with_skills._input_queue) == 0
assert events == []
finally:
vibe_app_with_skills._agent_running = False

View file

@ -4,38 +4,55 @@ from unittest.mock import AsyncMock
import pytest
from tests.stubs.fake_tool import FakeTool, FakeToolArgs
import vibe.cli.textual_ui.handlers.event_handler as event_handler_module
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.cli.textual_ui.widgets.messages import HookSystemMessageLine
from vibe.core.hooks.models import (
HookEndEvent,
HookMessageSeverity,
HookRunEndEvent,
HookRunStartEvent,
HookStartEvent,
HookType,
)
from vibe.core.types import ToolCallEvent, ToolResultEvent
class FakeHookRunContainer:
def __init__(self) -> None:
self.display = False
self.remove = AsyncMock()
self.messages: list[HookSystemMessageLine] = []
self.classes: set[str] = set()
async def add_message(self, _widget: object) -> None:
def add_class(self, cls: str) -> None:
self.classes.add(cls)
async def add_message(self, widget: HookSystemMessageLine) -> None:
self.display = True
self.messages.append(widget)
@pytest.fixture
def hook_container_factory(
monkeypatch: pytest.MonkeyPatch,
) -> list[FakeHookRunContainer]:
created: list[FakeHookRunContainer] = []
def make_container() -> FakeHookRunContainer:
container = FakeHookRunContainer()
created.append(container)
return container
monkeypatch.setattr(event_handler_module, "HookRunContainer", make_container)
return created
@pytest.mark.asyncio
async def test_hook_run_end_removes_empty_container(
monkeypatch: pytest.MonkeyPatch,
hook_container_factory: list[FakeHookRunContainer],
) -> 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
@ -44,30 +61,22 @@ async def test_hook_run_end_removes_empty_container(
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
assert len(hook_container_factory) == 1
hook_container_factory[0].remove.assert_awaited_once()
assert "agent_turn" not in handler._hook_containers
@pytest.mark.asyncio
async def test_hook_run_end_keeps_container_with_messages(
monkeypatch: pytest.MonkeyPatch,
hook_container_factory: list[FakeHookRunContainer],
) -> 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(HookStartEvent(hook_name="post-turn"))
await handler.handle_event(
HookEndEvent(
hook_name="post-turn", status=HookMessageSeverity.OK, content="Hook output"
@ -75,7 +84,279 @@ async def test_hook_run_end_keeps_container_with_messages(
)
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
assert len(hook_container_factory) == 1
hook_container_factory[0].remove.assert_not_awaited()
assert hook_container_factory[0].display is True
assert "agent_turn" not in handler._hook_containers
def _tool_call_event(call_id: str) -> ToolCallEvent:
return ToolCallEvent(
tool_name="stub_tool",
tool_class=FakeTool,
args=FakeToolArgs(),
tool_call_id=call_id,
)
def _tool_result_event(call_id: str) -> ToolResultEvent:
return ToolResultEvent(
tool_name="stub_tool",
tool_class=FakeTool,
result=None,
cancelled=False,
tool_call_id=call_id,
)
@pytest.mark.asyncio
async def test_before_tool_container_scoped_to_tool_call_id(
hook_container_factory: list[FakeHookRunContainer],
) -> None:
"""Two concurrent tool calls each get their own before_tool container."""
mount_callback = AsyncMock()
handler = EventHandler(
mount_callback=mount_callback, get_tools_collapsed=lambda: False
)
# Two tool calls arrive
await handler.handle_event(_tool_call_event("call_A"))
await handler.handle_event(_tool_call_event("call_B"))
# before_tool starts for both, interleaved
await handler.handle_event(
HookRunStartEvent(
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_A"
)
)
await handler.handle_event(
HookRunStartEvent(
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_B"
)
)
# Two distinct containers were created.
assert len(hook_container_factory) == 2
assert "before_tool:call_A" in handler._hook_containers
assert "before_tool:call_B" in handler._hook_containers
assert (
handler._hook_containers["before_tool:call_A"]
is not handler._hook_containers["before_tool:call_B"]
)
# End them — both are empty, both should be removed.
await handler.handle_event(
HookRunEndEvent(scope=HookType.BEFORE_TOOL, tool_call_id="call_A")
)
await handler.handle_event(
HookRunEndEvent(scope=HookType.BEFORE_TOOL, tool_call_id="call_B")
)
assert "before_tool:call_A" not in handler._hook_containers
assert "before_tool:call_B" not in handler._hook_containers
@pytest.mark.asyncio
async def test_after_tool_container_anchors_after_tool_result(
hook_container_factory: list[FakeHookRunContainer],
) -> None:
mount_callback = AsyncMock()
handler = EventHandler(
mount_callback=mount_callback, get_tools_collapsed=lambda: False
)
await handler.handle_event(_tool_call_event("call_X"))
await handler.handle_event(_tool_result_event("call_X"))
# After-tool start mounts after the tool result, which is the current
# anchor for this tool_call_id.
await handler.handle_event(
HookRunStartEvent(
scope=HookType.AFTER_TOOL, tool_name="stub_tool", tool_call_id="call_X"
)
)
assert "after_tool:call_X" in handler._hook_containers
# mount_callback was called with after=<tool_result_widget> for the after_tool
# container. Inspect the last call's kwargs.
last_call = mount_callback.call_args_list[-1]
assert last_call.kwargs.get("after") is not None
@pytest.mark.asyncio
async def test_before_tool_container_for_unknown_tool_call_id(
hook_container_factory: list[FakeHookRunContainer],
) -> None:
mount_callback = AsyncMock()
handler = EventHandler(
mount_callback=mount_callback, get_tools_collapsed=lambda: False
)
await handler.handle_event(
HookRunStartEvent(
scope=HookType.BEFORE_TOOL, tool_name="bash", tool_call_id="unknown"
)
)
last_call = mount_callback.call_args_list[-1]
assert last_call.kwargs.get("after") is None
assert last_call.kwargs.get("before") is None
@pytest.mark.asyncio
async def test_before_tool_container_mounts_before_tool_call_widget(
hook_container_factory: list[FakeHookRunContainer],
) -> None:
mount_callback = AsyncMock()
handler = EventHandler(
mount_callback=mount_callback, get_tools_collapsed=lambda: False
)
await handler.handle_event(_tool_call_event("call_Y"))
tool_call_widget = handler._tool_call_anchors["call_Y"]
await handler.handle_event(
HookRunStartEvent(
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_Y"
)
)
last_call = mount_callback.call_args_list[-1]
assert last_call.kwargs.get("before") is tool_call_widget
assert last_call.kwargs.get("after") is None
@pytest.mark.asyncio
async def test_before_tool_run_end_keeps_tool_call_widget_as_anchor(
hook_container_factory: list[FakeHookRunContainer],
) -> None:
mount_callback = AsyncMock()
handler = EventHandler(
mount_callback=mount_callback, get_tools_collapsed=lambda: False
)
await handler.handle_event(_tool_call_event("call_Z"))
tool_call_widget = handler._tool_call_anchors["call_Z"]
await handler.handle_event(
HookRunStartEvent(
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_Z"
)
)
await handler.handle_event(HookStartEvent(hook_name="before"))
await handler.handle_event(
HookEndEvent(hook_name="before", status=HookMessageSeverity.OK, content="ok")
)
await handler.handle_event(
HookRunEndEvent(scope=HookType.BEFORE_TOOL, tool_call_id="call_Z")
)
# Even though the before_tool container has content and stays in the DOM,
# the anchor for the next widget (the tool result) must remain the call
# widget — the container lives *above* the call, not below it.
assert handler._tool_call_anchors["call_Z"] is tool_call_widget
@pytest.mark.asyncio
async def test_hook_end_routes_to_matching_container_when_chains_interleave(
hook_container_factory: list[FakeHookRunContainer],
) -> None:
mount_callback = AsyncMock()
handler = EventHandler(
mount_callback=mount_callback, get_tools_collapsed=lambda: False
)
await handler.handle_event(_tool_call_event("call_A"))
await handler.handle_event(_tool_call_event("call_B"))
await handler.handle_event(
HookRunStartEvent(
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_A"
)
)
assert len(hook_container_factory) == 1
container_a = hook_container_factory[-1]
await handler.handle_event(HookStartEvent(hook_name="hook_a"))
await handler.handle_event(
HookRunStartEvent(
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_B"
)
)
assert len(hook_container_factory) == 2
container_b = hook_container_factory[-1]
assert container_a is not container_b
await handler.handle_event(
HookEndEvent(
hook_name="hook_a",
status=HookMessageSeverity.OK,
content="from A",
scope=HookType.BEFORE_TOOL,
tool_call_id="call_A",
)
)
await handler.handle_event(HookStartEvent(hook_name="hook_b"))
await handler.handle_event(
HookEndEvent(
hook_name="hook_b",
status=HookMessageSeverity.OK,
content="from B",
scope=HookType.BEFORE_TOOL,
tool_call_id="call_B",
)
)
assert len(container_a.messages) == 1
assert len(container_b.messages) == 1
assert container_a.messages[0]._content == "from A"
assert container_b.messages[0]._content == "from B"
@pytest.mark.asyncio
async def test_hook_end_after_other_chain_ended_still_routes_correctly(
hook_container_factory: list[FakeHookRunContainer],
) -> None:
mount_callback = AsyncMock()
handler = EventHandler(
mount_callback=mount_callback, get_tools_collapsed=lambda: False
)
await handler.handle_event(_tool_call_event("call_A"))
await handler.handle_event(_tool_call_event("call_B"))
await handler.handle_event(
HookRunStartEvent(
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_A"
)
)
container_a = hook_container_factory[-1]
await handler.handle_event(HookStartEvent(hook_name="hook_a"))
await handler.handle_event(
HookRunStartEvent(
scope=HookType.BEFORE_TOOL, tool_name="stub_tool", tool_call_id="call_B"
)
)
await handler.handle_event(HookStartEvent(hook_name="hook_b"))
await handler.handle_event(
HookEndEvent(
hook_name="hook_b",
status=HookMessageSeverity.OK,
content="from B",
scope=HookType.BEFORE_TOOL,
tool_call_id="call_B",
)
)
await handler.handle_event(
HookRunEndEvent(scope=HookType.BEFORE_TOOL, tool_call_id="call_B")
)
await handler.handle_event(
HookEndEvent(
hook_name="hook_a",
status=HookMessageSeverity.OK,
content="from A",
scope=HookType.BEFORE_TOOL,
tool_call_id="call_A",
)
)
assert len(container_a.messages) == 1
assert container_a.messages[0]._content == "from A"

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import pytest
from vibe.cli.textual_ui.message_queue import MessageQueue, QueuedItemKind
from vibe.cli.textual_ui.message_queue import MessageQueue, QueuedItem, QueuedItemKind
def test_empty_queue_is_falsy() -> None:
@ -38,6 +38,17 @@ def test_pop_last_returns_newest() -> None:
assert [item.content for item in queue.items] == ["a", "b"]
def test_pop_last_resumes_when_queue_becomes_empty() -> None:
queue = MessageQueue()
queue.append_prompt("a")
queue.pause()
queue.pop_last()
assert not queue
assert not queue.paused
def test_pop_first_returns_oldest() -> None:
queue = MessageQueue()
queue.append_prompt("a")
@ -74,11 +85,9 @@ def test_pause_and_resume() -> None:
def test_pause_is_idempotent() -> None:
queue = MessageQueue()
calls = []
queue.set_change_listener(lambda: calls.append(None))
queue.pause()
queue.pause()
assert len(calls) == 1
assert queue.paused
def test_clear_resets_state() -> None:
@ -90,34 +99,30 @@ def test_clear_resets_state() -> None:
assert not queue.paused
def test_change_listener_fires_on_mutations() -> None:
def test_prepend_prompts_inserts_at_head_preserving_order() -> None:
queue = MessageQueue()
calls: list[None] = []
queue.set_change_listener(lambda: calls.append(None))
queue.append_prompt("a")
assert len(calls) == 1
queue.append_bash("ls")
assert len(calls) == 2
queue.pop_last()
assert len(calls) == 3
queue.pause()
assert len(calls) == 4
queue.resume()
assert len(calls) == 5
queue.append_prompt("x")
queue.append_prompt("y")
queue.prepend_prompts([
QueuedItem(QueuedItemKind.PROMPT, "a"),
QueuedItem(QueuedItemKind.PROMPT, "b"),
])
assert [item.content for item in queue.items] == ["a", "b", "x", "y"]
def test_change_listener_can_be_cleared() -> None:
def test_prepend_prompts_empty_is_noop() -> None:
queue = MessageQueue()
calls: list[None] = []
queue.set_change_listener(lambda: calls.append(None))
queue.set_change_listener(None)
queue.append_prompt("a")
assert calls == []
queue.append_prompt("x")
queue.prepend_prompts([])
assert [item.content for item in queue.items] == ["x"]
def test_append_prompt_with_skill_name() -> None:
queue = MessageQueue()
queue.append_prompt("expanded prompt", skill_name="my-skill")
item = queue.items[0]
assert item.skill_name == "my-skill"
assert item.content == "expanded prompt"
def test_items_returns_copy() -> None:

View file

@ -0,0 +1,200 @@
from __future__ import annotations
import time
import pytest
from tests.conftest import build_test_vibe_app
from vibe.cli.textual_ui.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import (
BashOutputMessage,
QueueHeaderMessage,
WarningMessage,
)
@pytest.fixture
def vibe_app() -> VibeApp:
return build_test_vibe_app()
async def _wait_until(pilot, predicate, timeout: float = 2.0) -> bool:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return True
await pilot.pause(0.05)
return False
@pytest.mark.asyncio
async def test_no_queue_header_when_empty(vibe_app: VibeApp) -> None:
async with vibe_app.run_test():
headers = list(vibe_app.query(QueueHeaderMessage))
assert headers == []
@pytest.mark.asyncio
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"
await pilot.press("enter")
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=1.0)
chat_input.value = "!echo queued"
await pilot.press("enter")
assert len(vibe_app._input_queue) == 1
assert vibe_app._input_queue.items[0].content == "echo queued"
headers = list(vibe_app.query(QueueHeaderMessage))
assert len(headers) == 1
queued_bashes = [w for w in vibe_app.query(BashOutputMessage) if w._queued]
assert len(queued_bashes) == 1
@pytest.mark.asyncio
async def test_slash_command_rejected_with_warning_when_busy(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"
await pilot.press("enter")
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=1.0)
chat_input.value = "/help"
await pilot.press("enter")
assert not list(vibe_app.query(WarningMessage))
assert any(
"Slash commands cannot be queued" in notification.message
for notification in vibe_app._notifications
)
assert len(vibe_app._input_queue) == 0
assert chat_input.value.startswith("/help")
@pytest.mark.asyncio
async def test_ctrl_c_pops_last_queued_item_lifo(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!sleep 2"
await pilot.press("enter")
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=2.0)
chat_input.value = "!echo first"
await pilot.press("enter")
chat_input.value = "!echo second"
await pilot.press("enter")
assert len(vibe_app._input_queue) == 2
await pilot.press("ctrl+c")
assert len(vibe_app._input_queue) == 1
assert vibe_app._input_queue.items[0].content == "echo first"
await pilot.press("escape")
await _wait_until(pilot, lambda: vibe_app._bash_task is None, timeout=5.0)
@pytest.mark.asyncio
async def test_escape_pauses_queue_when_job_running(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!sleep 2"
await pilot.press("enter")
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=2.0)
chat_input.value = "!echo queued"
await pilot.press("enter")
assert len(vibe_app._input_queue) == 1
await pilot.press("escape")
assert vibe_app._input_queue.paused
assert len(vibe_app._input_queue) == 1
await _wait_until(pilot, lambda: vibe_app._bash_task is None, timeout=5.0)
@pytest.mark.asyncio
async def test_drain_runs_queued_bashes_in_fifo_order(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!sleep 0.2"
await pilot.press("enter")
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=1.0)
chat_input.value = "!echo first"
await pilot.press("enter")
chat_input.value = "!echo second"
await pilot.press("enter")
await _wait_until(
pilot,
lambda: (
len(list(vibe_app.query(BashOutputMessage))) == 3
and all(not m._pending for m in vibe_app.query(BashOutputMessage))
),
timeout=5.0,
)
msgs = list(vibe_app.query(BashOutputMessage))
assert len(msgs) == 3
assert vibe_app._input_queue.paused is False
assert len(vibe_app._input_queue) == 0
@pytest.mark.asyncio
async def test_enter_on_empty_input_flushes_paused_queue(vibe_app: VibeApp) -> None:
async with vibe_app.run_test() as pilot:
chat_input = vibe_app.query_one(ChatInputContainer)
chat_input.value = "!sleep 2"
await pilot.press("enter")
await _wait_until(pilot, lambda: vibe_app._bash_task is not None, timeout=2.0)
chat_input.value = "!echo queued"
await pilot.press("enter")
assert len(vibe_app._input_queue) == 1
await pilot.press("escape")
assert vibe_app._input_queue.paused
await _wait_until(pilot, lambda: vibe_app._bash_task is None, timeout=10.0)
chat_input.value = ""
await pilot.press("enter")
await _wait_until(
pilot,
lambda: (
not vibe_app._input_queue.paused and len(vibe_app._input_queue) == 0
),
timeout=10.0,
)
assert not vibe_app._input_queue.paused
assert len(vibe_app._input_queue) == 0
@pytest.mark.asyncio
async def test_quit_warning_shows_queue_count(vibe_app: VibeApp) -> None:
async with vibe_app.run_test():
vibe_app._input_queue.append_prompt("a")
vibe_app._input_queue.append_prompt("b")
warning = vibe_app._queue.quit_warning_extra()
assert warning == "2 queued messages will be discarded"
vibe_app._input_queue.pop_last()
warning = vibe_app._queue.quit_warning_extra()
assert warning == "1 queued message will be discarded"
vibe_app._input_queue.pop_last()
assert vibe_app._queue.quit_warning_extra() == ""

View file

@ -92,11 +92,12 @@ class TestActionInterruptOrQuit:
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, "_try_interrupt_no_job_steps", return_value=False),
patch.object(app, "_try_interrupt_running_job", 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")
mock_confirm.assert_called_once_with("Ctrl+C", "")
def test_quits_on_confirmed(self, app: VibeApp) -> None:
app._quit_manager._confirm_time = time.monotonic()
@ -111,7 +112,9 @@ class TestActionInterruptOrQuit:
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, "_try_interrupt_no_job_steps", return_value=True
) as mock_interrupt,
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
):
app.action_interrupt_or_quit()
@ -123,11 +126,12 @@ class TestActionInterruptOrQuit:
) -> None:
with (
patch.object(app, "_get_chat_input", return_value=None),
patch.object(app, "_try_interrupt", return_value=False),
patch.object(app, "_try_interrupt_no_job_steps", return_value=False),
patch.object(app, "_try_interrupt_running_job", 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")
mock_confirm.assert_called_once_with("Ctrl+C", "")
class TestActionDeleteRightOrQuit:
@ -148,7 +152,7 @@ class TestActionDeleteRightOrQuit:
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
):
app.action_delete_right_or_quit()
mock_confirm.assert_called_once_with("Ctrl+D")
mock_confirm.assert_called_once_with("Ctrl+D", "")
def test_quits_on_confirmed(self, app: VibeApp) -> None:
app._quit_manager._confirm_time = time.monotonic()
@ -166,4 +170,15 @@ class TestActionDeleteRightOrQuit:
patch.object(app._quit_manager, "request_confirmation") as mock_confirm,
):
app.action_delete_right_or_quit()
mock_confirm.assert_called_once_with("Ctrl+D")
mock_confirm.assert_called_once_with("Ctrl+D", "")
def test_shows_queue_warning_when_queue_non_empty(self, app: VibeApp) -> None:
app._input_queue.append_prompt("queued")
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", "1 queued message will be discarded"
)

View file

@ -1,6 +1,14 @@
from __future__ import annotations
from vibe.cli.textual_ui.widgets.tool_widgets import _strip_line_numbers
from pydantic import BaseModel
from vibe.cli.textual_ui.widgets.collapsible import CollapsibleSection
from vibe.cli.textual_ui.widgets.tool_widgets import (
ToolResultWidget,
_fenced_code_block,
_strip_line_numbers,
get_result_widget,
)
def test_strips_numbered_prefixes() -> None:
@ -16,3 +24,57 @@ def test_leaves_warning_lines_untouched() -> None:
def test_preserves_arrows_inside_content() -> None:
content = " 1→a → b → c"
assert _strip_line_numbers(content) == "a → b → c"
def test_fence_uses_three_backticks_for_plain_content() -> None:
block = _fenced_code_block("hello\nworld", "py")
assert block == "```py\nhello\nworld\n```"
def test_fence_outgrows_embedded_triple_backticks() -> None:
content = "before\n```\n[click me](http://evil)\n```\nafter"
block = _fenced_code_block(content, "md")
fence = "````"
assert block == f"{fence}md\n{content}\n{fence}"
assert block.startswith(fence)
assert block.endswith(fence)
def test_fence_outgrows_longest_backtick_run() -> None:
content = "a ```` b ``` c"
block = _fenced_code_block(content, "")
assert block.startswith("`````")
assert block.endswith("`````")
def test_fence_strips_newlines_from_ext() -> None:
block = _fenced_code_block("safe", "x\n[click](http://evil)")
assert block == "```xclickhttpevil\nsafe\n```"
assert "\n[click]" not in block
def test_fence_strips_backticks_from_ext() -> None:
block = _fenced_code_block("safe", "py`\n```md")
first_line = block.split("\n", 1)[0]
assert first_line == "```pymd"
def test_fence_caps_ext_length() -> None:
block = _fenced_code_block("safe", "a" * 500)
first_line = block.split("\n", 1)[0]
assert first_line == "```" + "a" * 32
def test_unknown_tool_uses_default_widget() -> None:
widget = get_result_widget("unknown_tool", None, True, "done")
assert type(widget) is ToolResultWidget
def test_default_widget_renders_fields_collapsibly() -> None:
class _Result(BaseModel):
server: str
text: str
widget = ToolResultWidget(_Result(server="s", text="hello"), True, "ok")
children = list(widget.compose())
assert any(isinstance(child, CollapsibleSection) for child in children)

View file

@ -1,8 +1,11 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import cast
import pytest
from rich.text import Text
from textual.widgets import OptionList
from vibe.cli.textual_ui.widgets.session_picker import (
SessionPickerApp,
@ -48,6 +51,12 @@ def sample_latest_messages() -> dict[str, str]:
}
def assert_delete_state(picker: SessionPickerApp, *, kind: str, option_id: str) -> None:
assert picker._delete_state is not None
assert picker._delete_state.kind == kind
assert picker._delete_state.option_id == option_id
class TestFormatRelativeTime:
def test_just_now(self) -> None:
now = datetime.now(UTC).isoformat()
@ -99,6 +108,7 @@ class TestSessionPickerAppInit:
)
assert picker._sessions == sample_sessions
assert picker._latest_messages == sample_latest_messages
assert picker._current_session_id is None
def test_id_is_sessionpicker_app(self) -> None:
picker = SessionPickerApp(sessions=[], latest_messages={})
@ -107,6 +117,29 @@ class TestSessionPickerAppInit:
def test_can_focus_children_is_true(self) -> None:
assert SessionPickerApp.can_focus_children is True
def test_delete_confirmation_state_starts_empty(
self,
sample_sessions: list[ResumeSessionInfo],
sample_latest_messages: dict[str, str],
) -> None:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
assert picker._delete_state is None
def test_has_sessions_tracks_session_list(
self,
sample_sessions: list[ResumeSessionInfo],
sample_latest_messages: dict[str, str],
) -> None:
picker = SessionPickerApp(
sessions=sample_sessions, latest_messages=sample_latest_messages
)
assert picker.has_sessions is True
empty_picker = SessionPickerApp(sessions=[], latest_messages={})
assert empty_picker.has_sessions is False
class TestSessionPickerMessages:
def test_session_selected_stores_option_id(self) -> None:
@ -129,16 +162,377 @@ class TestSessionPickerMessages:
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"
)
assert msg.option_id == "local:test-session-id"
assert msg.source == "local"
assert msg.session_id == "test-session-id"
class TestSessionPickerAppBindings:
def _get_binding_keys(self) -> list[str]:
keys = []
for binding in SessionPickerApp.BINDINGS:
if isinstance(binding, tuple) and len(binding) >= 1:
keys.append(binding[0])
keys.extend(binding[0].split(","))
else:
keys.append(binding.key)
keys.extend(binding.key.split(","))
return keys
def test_has_escape_binding(self) -> None:
assert "escape" in self._get_binding_keys()
def test_has_delete_binding(self) -> None:
assert "d" in self._get_binding_keys()
assert "D" in self._get_binding_keys()
class TestSessionPickerSessionRemoval:
def test_first_delete_request_enters_confirmation(
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="local: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 (
"Press D again to delete" in option_list.replaced_prompts[-1].prompt.plain
)
assert posted_messages == []
def test_second_delete_request_posts_delete_message(
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="local: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()
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 "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.session_id == "session-a"
def test_delete_confirmation_is_consumed_after_request(
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="local: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()
picker.action_request_delete()
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 "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],
sample_latest_messages: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
picker = SessionPickerApp(
sessions=sample_sessions,
latest_messages=sample_latest_messages,
current_session_id="session-a",
)
option_list = FakeOptionList(highlighted_option_id="local: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 (
"Can't delete current session"
in option_list.replaced_prompts[-1].prompt.plain
)
assert posted_messages == []
picker.action_request_delete()
assert_delete_state(picker, kind="feedback", option_id="local:session-a")
assert posted_messages == []
def test_pending_delete_blocks_resume_selection(
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="local: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()
picker.action_request_delete()
picker.on_option_list_option_selected(
cast(OptionList.OptionSelected, FakeOptionEvent("local:session-a"))
)
picker.on_option_list_option_selected(
cast(OptionList.OptionSelected, FakeOptionEvent("local: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")
def test_clear_pending_delete_restores_session_option(
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="local: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()
picker.action_request_delete()
assert picker.clear_pending_delete("local:session-a") is True
assert picker._delete_state is None
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
def test_highlighting_another_session_clears_confirmation(
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="local: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"))
)
assert picker._delete_state is None
assert option_list.replaced_prompts[-1].option_id == "local:session-a"
assert "Help me fix this bug" in option_list.replaced_prompts[-1].prompt.plain
def test_highlighting_another_session_clears_delete_feedback(
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")
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"))
)
assert picker._delete_state is None
assert option_list.replaced_prompts[-1].option_id == "remote:session-c"
assert (
"Add unit tests for the API"
in option_list.replaced_prompts[-1].prompt.plain
)
def test_escape_clears_confirmation_before_cancelling(
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="local: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()
picker.action_cancel()
assert picker._delete_state is None
assert posted_messages == []
picker.action_cancel()
assert len(posted_messages) == 1
assert isinstance(posted_messages[0], SessionPickerApp.Cancelled)
def test_escape_clears_delete_feedback_before_cancelling(
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()
picker.action_cancel()
assert picker._delete_state is None
assert posted_messages == []
picker.action_cancel()
assert len(posted_messages) == 1
assert isinstance(posted_messages[0], SessionPickerApp.Cancelled)
def test_remove_session_updates_picker_state(
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="local: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 picker.remove_session("local:session-a") is True
assert [session.option_id for session in picker._sessions] == [
"local:session-b",
"remote:session-c",
]
assert "local:session-a" not in picker._latest_messages
assert picker._delete_state is None
assert option_list.removed_option_ids == ["local:session-a"]
def test_remove_missing_session_returns_false(
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()
monkeypatch.setattr(picker, "query_one", lambda _selector: option_list)
assert picker.remove_session("local:missing") is False
assert picker._sessions == sample_sessions
assert picker._latest_messages == sample_latest_messages
assert option_list.removed_option_ids == []
class FakeOption:
def __init__(self, option_id: str) -> None:
self.id = option_id
class FakeOptionEvent:
def __init__(self, option_id: str) -> None:
self.option = FakeOption(option_id)
class ReplacedPrompt:
def __init__(self, option_id: str, prompt: Text) -> None:
self.option_id = option_id
self.prompt = prompt
class FakeOptionList:
def __init__(self, highlighted_option_id: str | None = None) -> None:
self.highlighted_option = (
FakeOption(highlighted_option_id)
if highlighted_option_id is not None
else None
)
self.removed_option_ids: list[str] = []
self.replaced_prompts: list[ReplacedPrompt] = []
def remove_option(self, option_id: str) -> None:
self.removed_option_ids.append(option_id)
def replace_option_prompt(self, option_id: str, prompt: Text) -> None:
self.replaced_prompts.append(ReplacedPrompt(option_id, prompt))

View file

@ -0,0 +1,75 @@
from __future__ import annotations
import pytest
from textual import events
from vibe.cli.textual_ui.terminal_input_filter import (
FilteringXTermParser,
patch_driver_parser,
strip_malformed_mouse,
)
NOISE = [
"\x1b[<32;NaN;NaNM", # malformed SGR mouse (extended button on focus change)
"\x1b[<35;NaN;NaNm",
]
PRESERVED = [
"\x1b[A", # arrow up
"\x1b[15~", # F5
"\x1b[97u", # plain kitty key 'a'
"\x1b[<0;5;5M", # valid mouse down
"\x1b[<0;5;5m", # valid mouse up
"\x1b[<128;10;10M", # valid extended-button mouse
"\x1b[<0;-1;-1M", # negative SGR-Pixels coords (Textual handles these)
"\x1b[24;80R", # cursor position report
"\x1b[I", # focus in
]
@pytest.mark.parametrize("seq", NOISE)
def test_strip_removes_malformed_mouse(seq: str) -> None:
assert strip_malformed_mouse(seq) == ""
@pytest.mark.parametrize("seq", PRESERVED)
def test_strip_preserves_real_input(seq: str) -> None:
assert strip_malformed_mouse(seq) == seq
def test_strip_keeps_real_key_after_noise() -> None:
assert strip_malformed_mouse("\x1b[<32;NaN;NaNM\x1b[A") == "\x1b[A"
@pytest.mark.parametrize("seq", NOISE)
def test_parser_emits_no_keys_for_noise(seq: str) -> None:
tokens = list(FilteringXTermParser().feed(seq))
assert [t for t in tokens if isinstance(t, events.Key) and t.character] == []
def test_parser_still_emits_key_for_arrow() -> None:
tokens = list(FilteringXTermParser().feed("\x1b[A"))
assert any(isinstance(t, events.Key) and t.key == "up" for t in tokens)
def test_all_noise_chunk_does_not_trip_eof() -> None:
parser = FilteringXTermParser()
assert list(parser.feed("\x1b[<32;NaN;NaNM")) == []
# The parser must still be alive for the next real chunk.
assert any(
isinstance(t, events.Key) and t.key == "up" for t in parser.feed("\x1b[A")
)
def test_patch_driver_parser_rebinds_module_global() -> None:
import sys
from textual.drivers.linux_driver import LinuxDriver
namespace = sys.modules[LinuxDriver.__module__].__dict__
original = namespace["XTermParser"]
try:
patch_driver_parser(LinuxDriver)
assert namespace["XTermParser"] is FilteringXTermParser
finally:
namespace["XTermParser"] = original

View file

@ -52,7 +52,6 @@ def test_resumed_user_message_with_images_renders_footer(tmp_path: Path) -> None
[msg],
tool_call_map={},
start_index=0,
tools_collapsed=False,
history_widget_indices=WeakKeyDictionary(),
)
@ -70,7 +69,6 @@ def test_resumed_user_message_with_images_only_still_mounts(tmp_path: Path) -> N
[msg],
tool_call_map={},
start_index=0,
tools_collapsed=False,
history_widget_indices=WeakKeyDictionary(),
)