diff --git a/.vscode/launch.json b/.vscode/launch.json index 4be5748..369c564 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "2.2.0", + "version": "2.2.1", "configurations": [ { "name": "ACP Server", diff --git a/CHANGELOG.md b/CHANGELOG.md index bd6722b..9866ac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.2.1] - 2026-02-18 + +### Added + +- Multiple clipboard copy strategies: OSC52 first, then pyperclip fallback when system clipboard is available (e.g. local GUI, SSH without OSC52) +- Ctrl+Z to put Vibe in background + +### Changed + +- Improve performance around streaming and scrolling +- File watcher is now opt-out by default; opt-in via config +- Bump Textual version in dependencies +- Inline code styling: yellow bold with transparent background for better readability + +### Fixed + +- Banner: sync skills count after initial app mount (fixes wrong count in some cases) +- Collapsed tool results: strip newlines in truncation to remove extra blank line +- Context token widget: preserve stats listeners across `/clear` so token percentage updates correctly +- Vertex AI: cache credentials to avoid blocking the event loop on every LLM request +- Bash tool: remove `NO_COLOR` from subprocess env to fix snapshot tests and colored output + + ## [2.2.0] - 2026-02-17 ### Added diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 658dfcd..8553bb8 100644 --- a/distribution/zed/extension.toml +++ b/distribution/zed/extension.toml @@ -1,7 +1,7 @@ id = "mistral-vibe" name = "Mistral Vibe" description = "Mistral's open-source coding assistant" -version = "2.2.0" +version = "2.2.1" schema_version = 1 authors = ["Mistral AI"] repository = "https://github.com/mistralai/mistral-vibe" @@ -11,25 +11,25 @@ name = "Mistral Vibe" icon = "./icons/mistral_vibe.svg" [agent_servers.mistral-vibe.targets.darwin-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-darwin-aarch64-2.2.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-aarch64-2.2.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-darwin-x86_64-2.2.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-darwin-x86_64-2.2.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-linux-aarch64-2.2.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-aarch64-2.2.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-linux-x86_64-2.2.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-linux-x86_64-2.2.1.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-windows-aarch64-2.2.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-aarch64-2.2.1.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.0/vibe-acp-windows-x86_64-2.2.0.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.2.1/vibe-acp-windows-x86_64-2.2.1.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index 1d61e8f..606a353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.2.0" +version = "2.2.1" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" @@ -46,7 +46,7 @@ dependencies = [ "pyyaml>=6.0.0", "requests>=2.20.0", "rich>=14.0.0", - "textual>=1.0.0", + "textual>=7.4.0", "textual-speedups>=0.2.1", "tomli-w>=1.2.0", "tree-sitter>=0.25.2", diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 5f62f6e..9137730 100644 --- a/tests/acp/test_initialize.py +++ b/tests/acp/test_initialize.py @@ -28,7 +28,7 @@ class TestACPInitialize: session_capabilities=SessionCapabilities(list=SessionListCapabilities()), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.1" ) assert response.auth_methods == [] @@ -52,7 +52,7 @@ class TestACPInitialize: session_capabilities=SessionCapabilities(list=SessionListCapabilities()), ) assert response.agent_info == Implementation( - name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.0" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.2.1" ) assert response.auth_methods is not None diff --git a/tests/autocompletion/test_file_indexer.py b/tests/autocompletion/test_file_indexer.py index dbd3b10..7ddb792 100644 --- a/tests/autocompletion/test_file_indexer.py +++ b/tests/autocompletion/test_file_indexer.py @@ -16,7 +16,7 @@ from vibe.core.autocompletion.file_indexer import FileIndexer @pytest.fixture def file_indexer() -> Generator[FileIndexer]: - indexer = FileIndexer() + indexer = FileIndexer(should_enable_watcher=lambda: True) yield indexer indexer.shutdown() @@ -30,6 +30,20 @@ def _wait_for(condition: Callable[[], bool], timeout=3.0) -> bool: return False +def _assert_index_state_stable( + file_indexer: FileIndexer, + expected_entries: set[str], + expected_incremental_updates: int, + duration: float = 1.0, +) -> None: + deadline = time.monotonic() + duration + while time.monotonic() < deadline: + current_entries = {entry.rel for entry in file_indexer.get_index(Path("."))} + assert current_entries == expected_entries + assert file_indexer.stats.incremental_updates == expected_incremental_updates + time.sleep(0.1) + + def test_updates_index_on_file_creation( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, file_indexer: FileIndexer ) -> None: @@ -143,7 +157,9 @@ def test_rebuilds_index_when_mass_change_threshold_is_exceeded( # detected by the watcher number_of_files = mass_change_threshold * 3 monkeypatch.chdir(tmp_path) - indexer = FileIndexer(mass_change_threshold=mass_change_threshold) + indexer = FileIndexer( + mass_change_threshold=mass_change_threshold, should_enable_watcher=lambda: True + ) try: indexer.get_index(Path(".")) rebuilds_before = indexer.stats.rebuilds @@ -229,3 +245,101 @@ def test_shutdown_cleans_up_resources( file_indexer.shutdown() assert file_indexer.get_index(Path(".")) == [] + + +def test_watcher_is_disabled_by_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + file_indexer = FileIndexer() + try: + baseline_entries = {entry.rel for entry in file_indexer.get_index(Path("."))} + incremental_before = file_indexer.stats.incremental_updates + (tmp_path / "file.py").write_text("", encoding="utf-8") + + _assert_index_state_stable( + file_indexer=file_indexer, + expected_entries=baseline_entries, + expected_incremental_updates=incremental_before, + ) + finally: + file_indexer.shutdown() + + +def test_disabling_watcher_stops_runtime_updates( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + watcher_enabled = True + file_indexer = FileIndexer(should_enable_watcher=lambda: watcher_enabled) + try: + tracked = tmp_path / "tracked.py" + tracked.write_text("", encoding="utf-8") + file_indexer.get_index(Path(".")) + assert any( + entry.rel == "tracked.py" for entry in file_indexer.get_index(Path(".")) + ) + + watcher_enabled = False + file_indexer.get_index(Path(".")) + + expected_entries = {entry.rel for entry in file_indexer.get_index(Path("."))} + incremental_before = file_indexer.stats.incremental_updates + tracked.unlink() + + _assert_index_state_stable( + file_indexer=file_indexer, + expected_entries=expected_entries, + expected_incremental_updates=incremental_before, + ) + finally: + file_indexer.shutdown() + + +def _current_entries(file_indexer: FileIndexer) -> set[str]: + return {entry.rel for entry in file_indexer.get_index(Path("."))} + + +def _assert_created_file_is_not_indexed( + file_indexer: FileIndexer, tmp_path: Path, filename: str +) -> None: + expected_entries = _current_entries(file_indexer) + expected_incremental_updates = file_indexer.stats.incremental_updates + (tmp_path / filename).write_text("", encoding="utf-8") + + _assert_index_state_stable( + file_indexer=file_indexer, + expected_entries=expected_entries, + expected_incremental_updates=expected_incremental_updates, + ) + + +def _assert_created_file_is_indexed( + file_indexer: FileIndexer, tmp_path: Path, filename: str +) -> None: + incremental_before = file_indexer.stats.incremental_updates + (tmp_path / filename).write_text("", encoding="utf-8") + + assert _wait_for(lambda: filename in _current_entries(file_indexer)) + assert file_indexer.stats.incremental_updates >= incremental_before + 1 + + +def test_watcher_toggle_flow_off_on_off( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + watcher_enabled = False + file_indexer = FileIndexer(should_enable_watcher=lambda: watcher_enabled) + try: + file_indexer.get_index(Path(".")) + _assert_created_file_is_not_indexed(file_indexer, tmp_path, "off_before.py") + + watcher_enabled = True + file_indexer.get_index(Path(".")) + _assert_created_file_is_indexed(file_indexer, tmp_path, "on_file.py") + + watcher_enabled = False + file_indexer.get_index(Path(".")) + _assert_created_file_is_not_indexed(file_indexer, tmp_path, "off_after.py") + finally: + file_indexer.shutdown() diff --git a/tests/backend/test_backend.py b/tests/backend/test_backend.py index b508b5a..8cc4e02 100644 --- a/tests/backend/test_backend.py +++ b/tests/backend/test_backend.py @@ -13,8 +13,10 @@ the tests will be. Always prefer real API data over manually constructed example from __future__ import annotations import json +from unittest.mock import MagicMock, patch import httpx +from mistralai.utils.retries import BackoffStrategy, RetryConfig import pytest import respx @@ -42,6 +44,16 @@ from vibe.core.utils import get_user_agent class TestBackend: + @staticmethod + def _build_fast_retry_config() -> RetryConfig: + return RetryConfig( + strategy="backoff", + backoff=BackoffStrategy( + initial_interval=1, max_interval=1, exponent=1, max_elapsed_time=1 + ), + retry_connection_errors=False, + ) + @pytest.mark.asyncio @pytest.mark.parametrize( "base_url,json_response,result_data", @@ -230,6 +242,8 @@ class TestBackend: api_key_env_var="API_KEY", ) backend = backend_class(provider=provider) + if isinstance(backend, MistralBackend): + backend._retry_config = self._build_fast_retry_config() model = ModelConfig( name="model_name", provider="provider_name", alias="model_alias" ) @@ -396,3 +410,28 @@ class TestBackend: pass assert mock_api.calls.last.request.headers["user-agent"] == user_agent + + +class TestMistralRetry: + @staticmethod + def _create_test_backend() -> MistralBackend: + provider = ProviderConfig( + name="test_provider", + api_base="https://api.mistral.ai/v1", + api_key_env_var="API_KEY", + ) + return MistralBackend(provider=provider) + + @pytest.mark.asyncio + async def test_client_creation_includes_timeout_and_retry_config(self): + backend = self._create_test_backend() + + with patch("mistralai.Mistral") as mock_mistral_class: + mock_mistral_class.return_value = MagicMock() + backend._get_client() + mock_mistral_class.assert_called_once_with( + api_key=backend._api_key, + server_url=backend._server_url, + timeout_ms=720000, + retry_config=backend._retry_config, + ) diff --git a/tests/backend/test_vertex_anthropic_adapter.py b/tests/backend/test_vertex_anthropic_adapter.py index 846dfdd..77b905c 100644 --- a/tests/backend/test_vertex_anthropic_adapter.py +++ b/tests/backend/test_vertex_anthropic_adapter.py @@ -1,13 +1,14 @@ from __future__ import annotations import json -from unittest.mock import patch +from unittest.mock import MagicMock, PropertyMock, patch import pytest from vibe.core.config import ProviderConfig from vibe.core.llm.backend.vertex import ( VertexAnthropicAdapter, + VertexCredentials, build_vertex_base_url, build_vertex_endpoint, ) @@ -16,7 +17,14 @@ from vibe.core.types import AvailableFunction, AvailableTool, LLMMessage, Role @pytest.fixture def adapter(): - return VertexAnthropicAdapter() + adapter = VertexAnthropicAdapter() + with patch.object( + VertexCredentials, + "access_token", + new_callable=PropertyMock, + return_value="fake-token", + ): + yield adapter @pytest.fixture @@ -66,11 +74,7 @@ class TestBuildVertexEndpoint: class TestPrepareRequest: - @patch( - "vibe.core.llm.backend.vertex.get_vertex_access_token", - return_value="fake-token", - ) - def test_basic_request(self, mock_token, adapter, provider): + def test_basic_request(self, adapter, provider): messages = [LLMMessage(role=Role.user, content="Hello")] req = adapter.prepare_request( model_name="claude-3-5-sonnet", @@ -94,11 +98,7 @@ class TestPrepareRequest: assert "streamRawPredict" not in req.endpoint assert req.base_url == "https://us-central1-aiplatform.googleapis.com" - @patch( - "vibe.core.llm.backend.vertex.get_vertex_access_token", - return_value="fake-token", - ) - def test_streaming_request(self, mock_token, adapter, provider): + def test_streaming_request(self, adapter, provider): messages = [LLMMessage(role=Role.user, content="Hello")] req = adapter.prepare_request( model_name="claude-3-5-sonnet", @@ -115,11 +115,7 @@ class TestPrepareRequest: assert payload.get("stream") is True assert "streamRawPredict" in req.endpoint - @patch( - "vibe.core.llm.backend.vertex.get_vertex_access_token", - return_value="fake-token", - ) - def test_no_beta_features_for_vertex(self, mock_token, adapter, provider): + def test_no_beta_features_for_vertex(self, adapter, provider): """Vertex AI doesn't support the same beta features as direct Anthropic API.""" messages = [LLMMessage(role=Role.user, content="Hello")] req = adapter.prepare_request( @@ -136,11 +132,7 @@ class TestPrepareRequest: # Vertex AI doesn't support prompt-caching or other beta features assert req.headers.get("anthropic-beta", "") == "" - @patch( - "vibe.core.llm.backend.vertex.get_vertex_access_token", - return_value="fake-token", - ) - def test_with_extended_thinking(self, mock_token, adapter, provider): + def test_with_extended_thinking(self, adapter, provider): messages = [LLMMessage(role=Role.user, content="Hello")] req = adapter.prepare_request( model_name="claude-3-5-sonnet", @@ -159,11 +151,7 @@ class TestPrepareRequest: assert payload["max_tokens"] == 1024 assert payload["temperature"] == 1 - @patch( - "vibe.core.llm.backend.vertex.get_vertex_access_token", - return_value="fake-token", - ) - def test_with_tools(self, mock_token, adapter, provider): + def test_with_tools(self, adapter, provider): messages = [LLMMessage(role=Role.user, content="Hello")] tools = [ AvailableTool( @@ -227,11 +215,7 @@ class TestPrepareRequest: provider=provider, ) - @patch( - "vibe.core.llm.backend.vertex.get_vertex_access_token", - return_value="fake-token", - ) - def test_default_max_tokens(self, mock_token, adapter, provider): + def test_default_max_tokens(self, adapter, provider): messages = [LLMMessage(role=Role.user, content="Hello")] req = adapter.prepare_request( model_name="claude-3-5-sonnet", @@ -589,3 +573,65 @@ class TestHelperMethods: messages: list[dict] = [] adapter._add_cache_control_to_last_user_message(messages) assert messages == [] + + +class TestVertexCredentials: + def _make_creds( + self, *, valid: bool = True, token: str | None = "tok" + ) -> MagicMock: + creds = MagicMock() + creds.valid = valid + creds.token = token + return creds + + @patch("vibe.core.llm.backend.vertex.google.auth.default") + def test_initializes_credentials_on_first_access(self, mock_default: MagicMock): + creds = self._make_creds() + mock_default.return_value = (creds, "project") + + vc = VertexCredentials() + token = vc.access_token + + assert token == "tok" + mock_default.assert_called_once() + + @patch("vibe.core.llm.backend.vertex.google.auth.default") + def test_caches_credentials_across_calls(self, mock_default: MagicMock): + creds = self._make_creds() + mock_default.return_value = (creds, "project") + + vc = VertexCredentials() + _ = vc.access_token + _ = vc.access_token + _ = vc.access_token + + mock_default.assert_called_once() + + @patch("vibe.core.llm.backend.vertex.google.auth.default") + def test_refreshes_when_token_invalid(self, mock_default: MagicMock): + creds = self._make_creds(valid=False) + mock_default.return_value = (creds, "project") + + vc = VertexCredentials() + _ = vc.access_token + + creds.refresh.assert_called_once() + + @patch("vibe.core.llm.backend.vertex.google.auth.default") + def test_skips_refresh_when_token_valid(self, mock_default: MagicMock): + creds = self._make_creds(valid=True) + mock_default.return_value = (creds, "project") + + vc = VertexCredentials() + _ = vc.access_token + + creds.refresh.assert_not_called() + + @patch("vibe.core.llm.backend.vertex.google.auth.default") + def test_raises_when_token_is_none(self, mock_default: MagicMock): + creds = self._make_creds(valid=True, token=None) + mock_default.return_value = (creds, "project") + + vc = VertexCredentials() + with pytest.raises(RuntimeError, match="did not produce a token"): + _ = vc.access_token diff --git a/tests/cli/test_clipboard.py b/tests/cli/test_clipboard.py index 066b59d..aca63d2 100644 --- a/tests/cli/test_clipboard.py +++ b/tests/cli/test_clipboard.py @@ -8,7 +8,15 @@ from unittest.mock import MagicMock, mock_open, patch import pytest from textual.app import App -from vibe.cli.clipboard import _copy_osc52, copy_selection_to_clipboard +from vibe.cli.clipboard import ( + _copy_osc52, + _copy_pbcopy, + _copy_to_clipboard, + _copy_wl_copy, + _copy_xclip, + _read_clipboard, + copy_selection_to_clipboard, +) class MockWidget: @@ -79,9 +87,9 @@ def test_copy_selection_to_clipboard_no_notification( mock_app.notify.assert_not_called() -@patch("vibe.cli.clipboard._copy_osc52") +@patch("vibe.cli.clipboard._copy_to_clipboard") def test_copy_selection_to_clipboard_success( - mock_copy_osc52: MagicMock, mock_app: MagicMock + mock_copy_to_clipboard: MagicMock, mock_app: MagicMock ) -> None: widget = MockWidget( text_selection=SimpleNamespace(), get_selection_result=("selected text", None) @@ -91,7 +99,7 @@ def test_copy_selection_to_clipboard_success( result = copy_selection_to_clipboard(mock_app) assert result == "selected text" - mock_copy_osc52.assert_called_once_with("selected text") + mock_copy_to_clipboard.assert_called_once_with("selected text") mock_app.notify.assert_called_once_with( '"selected text" copied to clipboard', severity="information", @@ -100,21 +108,21 @@ def test_copy_selection_to_clipboard_success( ) -@patch("vibe.cli.clipboard._copy_osc52") -def test_copy_selection_to_clipboard_failure( - mock_copy_osc52: MagicMock, mock_app: MagicMock +@patch("vibe.cli.clipboard._copy_to_clipboard") +def test_copy_selection_to_clipboard_shows_failure_when_all_strategies_raise( + mock_copy_to_clipboard: MagicMock, mock_app: MagicMock ) -> None: + """When _copy_to_clipboard raises (all strategies failed), user sees 'Failed to copy' toast.""" widget = MockWidget( text_selection=SimpleNamespace(), get_selection_result=("selected text", None) ) mock_app.query.return_value = [widget] - - mock_copy_osc52.side_effect = Exception("OSC52 failed") + mock_copy_to_clipboard.side_effect = RuntimeError("All clipboard strategies failed") result = copy_selection_to_clipboard(mock_app) assert result is None - mock_copy_osc52.assert_called_once_with("selected text") + mock_copy_to_clipboard.assert_called_once_with("selected text") mock_app.notify.assert_called_once_with( "Failed to copy - clipboard not available", severity="warning", timeout=3 ) @@ -131,11 +139,13 @@ def test_copy_selection_to_clipboard_multiple_widgets(mock_app: MagicMock) -> No widget3 = MockWidget(text_selection=None) mock_app.query.return_value = [widget1, widget2, widget3] - with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52: + with patch("vibe.cli.clipboard._copy_to_clipboard") as mock_copy_to_clipboard: result = copy_selection_to_clipboard(mock_app) assert result == "first selection\nsecond selection" - mock_copy_osc52.assert_called_once_with("first selection\nsecond selection") + mock_copy_to_clipboard.assert_called_once_with( + "first selection\nsecond selection" + ) mock_app.notify.assert_called_once_with( '"first selection\u23cesecond selection" copied to clipboard', severity="information", @@ -151,11 +161,11 @@ def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) -> ) mock_app.query.return_value = [widget] - with patch("vibe.cli.clipboard._copy_osc52") as mock_copy_osc52: + with patch("vibe.cli.clipboard._copy_to_clipboard") as mock_copy_to_clipboard: result = copy_selection_to_clipboard(mock_app) - assert result == long_text - mock_copy_osc52.assert_called_once_with(long_text) + + mock_copy_to_clipboard.assert_called_once_with(long_text) notification_call = mock_app.notify.call_args assert notification_call is not None assert '"' in notification_call[0][0] @@ -163,6 +173,111 @@ def test_copy_selection_to_clipboard_preview_shortening(mock_app: MagicMock) -> assert len(notification_call[0][0]) < len(long_text) + 30 +def test_copy_to_clipboard_stops_after_verified_copy() -> None: + """Stops iterating once _read_clipboard confirms the text landed.""" + mock_first = MagicMock() + mock_second = MagicMock() + + with ( + patch("vibe.cli.clipboard._COPY_METHODS", [mock_first, mock_second]), + patch("vibe.cli.clipboard._read_clipboard", return_value="hello"), + ): + _copy_to_clipboard("hello") + + mock_first.assert_called_once_with("hello") + mock_second.assert_not_called() + + +def test_copy_to_clipboard_tries_all_when_verify_fails() -> None: + """Tries all strategies when _read_clipboard never confirms.""" + mock_first = MagicMock() + mock_second = MagicMock() + + with ( + patch("vibe.cli.clipboard._COPY_METHODS", [mock_first, mock_second]), + patch("vibe.cli.clipboard._read_clipboard", return_value=None), + ): + _copy_to_clipboard("hello") + + mock_first.assert_called_once_with("hello") + mock_second.assert_called_once_with("hello") + + +def test_copy_to_clipboard_raises_when_all_strategies_raise() -> None: + """RuntimeError is raised when every strategy fails.""" + mock_osc52 = MagicMock(side_effect=OSError("no tty")) + mock_pyperclip = MagicMock(side_effect=RuntimeError("pyperclip unavailable")) + + with ( + patch("vibe.cli.clipboard._COPY_METHODS", [mock_osc52, mock_pyperclip]), + pytest.raises(RuntimeError, match="All clipboard strategies failed"), + ): + _copy_to_clipboard("anything") + + +def test_read_clipboard_returns_first_successful_reader() -> None: + mock_reader = MagicMock(return_value="hello") + mock_reader2 = MagicMock(side_effect=RuntimeError("no clipboard")) + with patch( + "vibe.cli.clipboard._READ_CLIPBOARD_METHODS", [mock_reader, mock_reader2] + ): + assert _read_clipboard() == "hello" + mock_reader.assert_called_once() + mock_reader2.assert_not_called() + + +def test_read_clipboard_falls_through_on_failure() -> None: + failing = MagicMock(side_effect=RuntimeError("no clipboard")) + with patch("vibe.cli.clipboard._READ_CLIPBOARD_METHODS", [failing]): + assert _read_clipboard() is None + + +def test_read_clipboard_skips_failing_reader() -> None: + failing = MagicMock(side_effect=RuntimeError("broken")) + working = MagicMock(return_value="hello") + with patch("vibe.cli.clipboard._READ_CLIPBOARD_METHODS", [failing, working]): + assert _read_clipboard() == "hello" + working.assert_called_once() + + +@patch("subprocess.run") +def test_copy_pbcopy(mock_run: MagicMock) -> None: + _copy_pbcopy("hello") + mock_run.assert_called_once_with(["pbcopy"], input=b"hello", check=True) + + +@patch("subprocess.run") +def test_copy_xclip(mock_run: MagicMock) -> None: + _copy_xclip("hello") + mock_run.assert_called_once_with( + ["xclip", "-selection", "clipboard"], input=b"hello", check=True + ) + + +@patch("subprocess.run") +def test_copy_wl_copy(mock_run: MagicMock) -> None: + _copy_wl_copy("hello") + mock_run.assert_called_once_with(["wl-copy"], input=b"hello", check=True) + + +def test_copy_methods_includes_available_commands() -> None: + """_COPY_METHODS is built at import time using _has_cmd; re-import with mocked shutil.which.""" + import importlib + + import vibe.cli.clipboard as mod + + with patch( + "shutil.which", + side_effect=lambda cmd: "/usr/bin/xclip" if cmd == "xclip" else None, + ): + importlib.reload(mod) + assert mod._copy_xclip in mod._COPY_METHODS + assert mod._copy_pbcopy not in mod._COPY_METHODS + assert mod._copy_wl_copy not in mod._COPY_METHODS + + importlib.reload(mod) + + @patch("builtins.open", new_callable=mock_open) def test_copy_osc52_writes_correct_sequence( mock_file: MagicMock, monkeypatch: pytest.MonkeyPatch diff --git a/tests/cli/test_ui_clipboard_notifications.py b/tests/cli/test_ui_clipboard_notifications.py index b81edd6..b944394 100644 --- a/tests/cli/test_ui_clipboard_notifications.py +++ b/tests/cli/test_ui_clipboard_notifications.py @@ -29,7 +29,7 @@ async def test_ui_clipboard_notification_does_not_crash_on_markup_text( ) -> None: async with vibe_app.run_test(notifications=True) as pilot: await vibe_app.mount(ClipboardSelectionWidget("[/]")) - with patch("vibe.cli.clipboard._copy_osc52"): + with patch("vibe.cli.clipboard._copy_to_clipboard"): copy_selection_to_clipboard(vibe_app) await pilot.pause(0.1) diff --git a/tests/cli/test_ui_session_resume.py b/tests/cli/test_ui_session_resume.py index 3fc2064..54c5352 100644 --- a/tests/cli/test_ui_session_resume.py +++ b/tests/cli/test_ui_session_resume.py @@ -193,5 +193,5 @@ async def test_ui_rebuilds_history_when_whats_new_is_shown( assert message._content == "Hello from the previous session." assert whats_new_message is not None - assert "after-history" in whats_new_message.classes - assert children == [message, assistant_message, whats_new_message] + assert whats_new_message.parent is not messages_area + assert children == [message, assistant_message] diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg index 091ff60..fad9066 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_collapsed.svg @@ -111,17 +111,17 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg index fac8b0e..748db48 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_ask_user_question/test_snapshot_ask_user_question_expanded.svg @@ -141,28 +141,28 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -What programming language are you currently working with? -Rust -What type of project are you building? -Web Application -What editor or IDE do you prefer? -(Other) VS Code + + + + + + - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - - - - - +What programming language are you currently working with? +Rust +What type of project are you building? +Web Application +What editor or IDE do you prefer? +(Other) VS Code ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg index cd36f74..36a845a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_basic_conversation/test_snapshot_shows_basic_conversation.svg @@ -160,14 +160,14 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -Hello there, who are you? + -I'm the Vibe agent and I'm ready to help. + @@ -180,14 +180,14 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - +Hello there, who are you? - +I'm the Vibe agent and I'm ready to help. ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg index 6ab81c7..e1912c6 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_code_block_horizontal_scrolling/test_snapshot_allows_horizontal_scrolling_for_long_code_blocks.svg @@ -40,7 +40,8 @@ .terminal-r6 { fill: #d2d2d2 } .terminal-r7 { fill: #292929 } .terminal-r8 { fill: #4b4e55 } -.terminal-r9 { fill: #9a9b99 } +.terminal-r9 { fill: #d0b344;font-weight: bold } +.terminal-r10 { fill: #9a9b99 } @@ -162,44 +163,44 @@ - + -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -Here's a very long print instruction: + -(): -ery long line (Lorem Ipsum) -m ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna al -▋             + + + + -The print statement includes a very long line of Lorem Ipsum text to demonstrate a lengthy output. + - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - +Here's a very long print instruction: - - - - +(): +ery long line (Lorem Ipsum) +m ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna al +▋             - +The print statement includes a very long line of Lorem Ipsum text to demonstrate a lengthy output. -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ -> - - -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg index c5a10d5..c0d6339 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_accept_edits_mode.svg @@ -160,9 +160,9 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + @@ -184,9 +184,9 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg index b423b09..cd57914 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_auto_approve_mode.svg @@ -160,9 +160,9 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + @@ -184,9 +184,9 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg index ab8d7a7..d9eb6ab 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_to_plan_mode.svg @@ -160,9 +160,9 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + @@ -184,9 +184,9 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg index 0e30749..63dafad 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_cycle_wraps_to_default.svg @@ -159,9 +159,9 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + @@ -183,9 +183,9 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg index 0e30749..63dafad 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_modes/test_snapshot_default_mode.svg @@ -159,9 +159,9 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + @@ -183,9 +183,9 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg index f68abd5..5ce32e0 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_buffered_reasoning_yields_before_content.svg @@ -162,16 +162,16 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -Give me an answer + -Thought + -Here is my carefully considered answer. I hope this helps! + @@ -180,16 +180,16 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - +Give me an answer - +Thought - +Here is my carefully considered answer. I hope this helps! ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg index ab9fb7b..49da0b4 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_interleaved_reasoning.svg @@ -162,34 +162,34 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -Explain this to me + -Thought + -Here's the first part of the answer. + -Thought + -And here's the conclusion! - - - + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - +Explain this to me - +Thought - +Here's the first part of the answer. - +Thought - +And here's the conclusion! ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg index ce6ba06..57a8d06 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content.svg @@ -162,16 +162,16 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -What is the answer? + -Thought + -The answer to your question is 42. This is the ultimate answer. + @@ -180,16 +180,16 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - +What is the answer? - +Thought - +The answer to your question is 42. This is the ultimate answer. ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg index c54a163..87f3f29 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_reasoning_content/test_snapshot_shows_reasoning_content_expanded.svg @@ -162,34 +162,34 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -What is the answer? + -Thought -Let me think about this step by step... First, I need to understand the question. Then I can formulate a response. + + -The answer to your question is 42. This is the ultimate answer. + - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - +What is the answer? - - +Thought +Let me think about this step by step... First, I need to understand the question. Then I can formulate a response. - +The answer to your question is 42. This is the ultimate answer. ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg index a2d534b..466c987 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg @@ -163,13 +163,13 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -What's New -• Feature 1 -• Feature 2 + + + @@ -183,13 +183,13 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -Update available +What's New +• Feature 1 +• Feature 2Update available 1.0.4 => 1000.2.0 Please update mistral-vibe with your package manager diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg index a2d1c85..a7f4f3f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_session_resume/test_snapshot_shows_resumed_session_messages.svg @@ -161,16 +161,16 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -Hello, how are you? + -I'm doing well, thank you! Let me read that file for you. + -read_file + @@ -179,16 +179,16 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - +Hello, how are you? - +I'm doing well, thank you! Let me read that file for you. - +read_file ┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg index 258f92f..8714a39 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_no_plan_message.svg @@ -161,14 +161,14 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -What's New -• Feature 1 -• Feature 2 -• Feature 3 + + + + @@ -180,14 +180,14 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - - - +What's New +• Feature 1 +• Feature 2 +• Feature 3 diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg index a804262..895e69b 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_switch_message.svg @@ -162,33 +162,33 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -What's New - -• Feature 1 -• Feature 2 -• Feature 3 - -Switch to your Le Chat Pro API key + + + + + + + - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - - - - - - +What's New + +• Feature 1 +• Feature 2 +• Feature 3 + +Switch to your Le Chat Pro API key diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg index 2ddeacb..e3dd69f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_upgrade_message.svg @@ -162,33 +162,33 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -What's New - -• Feature 1 -• Feature 2 -• Feature 3 - -Unlock more with Vibe - Upgrade to Le Chat Pro + + + + + + + - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - - - - - - +What's New + +• Feature 1 +• Feature 2 +• Feature 3 + +Unlock more with Vibe - Upgrade to Le Chat Pro diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg index 3b5bd58..57a1d1e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_whats_new/test_snapshot_shows_whats_new_message.svg @@ -161,14 +161,14 @@ -   ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + + -What's New -• Feature 1 -• Feature 2 -• Feature 3 + + + + @@ -180,14 +180,14 @@ - - - +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - - - +What's New +• Feature 1 +• Feature 2 +• Feature 3 diff --git a/tests/test_agent_observer_streaming.py b/tests/test_agent_observer_streaming.py index 7f3bed6..b08f2d1 100644 --- a/tests/test_agent_observer_streaming.py +++ b/tests/test_agent_observer_streaming.py @@ -149,7 +149,7 @@ async def test_act_emits_user_and_assistant_msgs(observer_capture) -> None: @pytest.mark.asyncio -async def test_act_streams_batched_chunks_in_order() -> None: +async def test_act_streams_chunks_in_order() -> None: backend = FakeBackend([ mock_llm_chunk(content="Hello"), mock_llm_chunk(content=" from"), @@ -166,10 +166,15 @@ async def test_act_streams_batched_chunks_in_order() -> None: events = [event async for event in agent.act("Stream, please.")] assistant_events = [e for e in events if isinstance(e, AssistantEvent)] - assert len(assistant_events) == 2 + assert len(assistant_events) == 7 assert [event.content for event in assistant_events] == [ - "Hello from Vibe! More", - " and end", + "Hello", + " from", + " Vibe", + "! ", + "More", + " and", + " end", ] assert agent.messages[-1].role == Role.assistant assert agent.messages[-1].content == "Hello from Vibe! More and end" @@ -248,12 +253,18 @@ async def test_act_handles_tool_call_chunk_with_content() -> None: assert [type(event) for event in events] == [ UserMessageEvent, AssistantEvent, + AssistantEvent, + AssistantEvent, ToolCallEvent, ToolResultEvent, ] assert isinstance(events[0], UserMessageEvent) assert isinstance(events[1], AssistantEvent) - assert events[1].content == "Preparing todo request complete" + assert isinstance(events[2], AssistantEvent) + assert isinstance(events[3], AssistantEvent) + assert events[1].content == "Preparing " + assert events[2].content == "todo request" + assert events[3].content == " complete" assert any( m.role == Role.assistant and m.content == "Preparing todo request complete" for m in agent.messages @@ -360,6 +371,7 @@ async def test_act_handles_user_cancellation_during_streaming() -> None: assert [type(event) for event in events] == [ UserMessageEvent, AssistantEvent, + AssistantEvent, ToolCallEvent, ToolResultEvent, ] @@ -430,7 +442,7 @@ def _snapshot_events(events: list) -> list[tuple[str, str]]: @pytest.mark.asyncio -async def test_reasoning_buffer_yields_before_content_on_transition() -> None: +async def test_reasoning_yields_before_content_on_transition() -> None: backend = FakeBackend([ mock_llm_chunk(content="", reasoning_content="Let me think"), mock_llm_chunk(content="", reasoning_content=" about this"), @@ -444,19 +456,21 @@ async def test_reasoning_buffer_yields_before_content_on_transition() -> None: events = [event async for event in agent.act("What's the answer?")] assert _snapshot_events(events) == [ - ("ReasoningEvent", "Let me think about this problem..."), + ("ReasoningEvent", "Let me think"), + ("ReasoningEvent", " about this"), + ("ReasoningEvent", " problem..."), ("AssistantEvent", "The answer is 42."), ] @pytest.mark.asyncio -async def test_reasoning_buffer_yields_before_content_with_batching() -> None: +async def test_reasoning_yields_per_chunk() -> None: backend = FakeBackend([ mock_llm_chunk(content="", reasoning_content="Step 1"), mock_llm_chunk(content="", reasoning_content=", Step 2"), mock_llm_chunk(content="", reasoning_content=", Step 3"), mock_llm_chunk(content="", reasoning_content=", Step 4"), - mock_llm_chunk(content="", reasoning_content=", Step 5"), # Triggers batch + mock_llm_chunk(content="", reasoning_content=", Step 5"), mock_llm_chunk(content="", reasoning_content=", Step 6"), mock_llm_chunk(content="", reasoning_content=", Final"), mock_llm_chunk(content="Done thinking!"), @@ -468,15 +482,20 @@ async def test_reasoning_buffer_yields_before_content_with_batching() -> None: events = [event async for event in agent.act("Think step by step")] assert _snapshot_events(events) == [ - ("ReasoningEvent", "Step 1, Step 2, Step 3, Step 4, Step 5"), - ("ReasoningEvent", ", Step 6, Final"), + ("ReasoningEvent", "Step 1"), + ("ReasoningEvent", ", Step 2"), + ("ReasoningEvent", ", Step 3"), + ("ReasoningEvent", ", Step 4"), + ("ReasoningEvent", ", Step 5"), + ("ReasoningEvent", ", Step 6"), + ("ReasoningEvent", ", Final"), ("AssistantEvent", "Done thinking!"), ] @pytest.mark.asyncio -async def test_content_buffer_yields_before_reasoning_on_transition() -> None: - """When content is buffered and reasoning arrives, content yields first.""" +async def test_content_yields_before_reasoning_on_transition() -> None: + """When content chunks arrive and reasoning arrives, content yields first.""" backend = FakeBackend([ mock_llm_chunk(content="Starting the response"), mock_llm_chunk(content=" here..."), @@ -491,8 +510,10 @@ async def test_content_buffer_yields_before_reasoning_on_transition() -> None: events = [event async for event in agent.act("Give me an answer")] assert _snapshot_events(events) == [ - ("AssistantEvent", "Starting the response here..."), - ("ReasoningEvent", "Wait, let me reconsider this approach..."), + ("AssistantEvent", "Starting the response"), + ("AssistantEvent", " here..."), + ("ReasoningEvent", "Wait, let me reconsider"), + ("ReasoningEvent", " this approach..."), ("AssistantEvent", "Actually, the final answer."), ] @@ -540,7 +561,8 @@ async def test_only_reasoning_chunks_yields_reasoning_event() -> None: events = [event async for event in agent.act("Silent thinking")] assert _snapshot_events(events) == [ - ("ReasoningEvent", "Just thinking... nothing to say yet.") + ("ReasoningEvent", "Just thinking..."), + ("ReasoningEvent", " nothing to say yet."), ] @@ -566,7 +588,7 @@ async def test_final_buffers_flush_in_correct_order() -> None: async def test_empty_content_chunks_do_not_trigger_false_yields() -> None: backend = FakeBackend([ mock_llm_chunk(content="", reasoning_content="Reasoning here"), - mock_llm_chunk(content=""), # Empty content shouldn't flush reasoning + mock_llm_chunk(content=""), # Empty content shouldn't yield mock_llm_chunk(content="", reasoning_content=" more reasoning"), mock_llm_chunk(content="Actual content"), ]) @@ -577,6 +599,7 @@ async def test_empty_content_chunks_do_not_trigger_false_yields() -> None: events = [event async for event in agent.act("Empty content test")] assert _snapshot_events(events) == [ - ("ReasoningEvent", "Reasoning here more reasoning"), + ("ReasoningEvent", "Reasoning here"), + ("ReasoningEvent", " more reasoning"), ("AssistantEvent", "Actual content"), ] diff --git a/tests/test_agent_stats.py b/tests/test_agent_stats.py index 60b24a6..d77388d 100644 --- a/tests/test_agent_stats.py +++ b/tests/test_agent_stats.py @@ -535,6 +535,27 @@ class TestAutoCompactIntegration: class TestClearHistoryFullReset: + @pytest.mark.asyncio + async def test_clear_history_preserves_listeners(self) -> None: + backend = FakeBackend(mock_llm_chunk(content="Response")) + agent = build_test_agent_loop(config=make_config(), backend=backend) + + listener_calls: list[int] = [] + agent.stats.add_listener( + "context_tokens", lambda s: listener_calls.append(s.context_tokens) + ) + + async for _ in agent.act("Hello"): + pass + + assert agent.stats.context_tokens > 0 + listener_calls.clear() + + await agent.clear_history() + + assert agent.stats.context_tokens == 0 + assert any(v == 0 for v in listener_calls) + @pytest.mark.asyncio async def test_clear_history_fully_resets_stats(self) -> None: backend = FakeBackend(mock_llm_chunk(content="Response")) diff --git a/tests/test_reasoning_content.py b/tests/test_reasoning_content.py index 0fa4a08..aaec490 100644 --- a/tests/test_reasoning_content.py +++ b/tests/test_reasoning_content.py @@ -323,7 +323,7 @@ class TestAgentLoopStreamingReasoningEvents: assert len(reasoning_events) == 0 assistant_events = [e for e in events if isinstance(e, AssistantEvent)] - assert len(assistant_events) == 1 + assert len(assistant_events) == 2 assistant_msg = next(m for m in agent.messages if m.role == Role.assistant) assert assistant_msg.reasoning_content is None diff --git a/uv.lock b/uv.lock index 81f1c30..c27ffe9 100644 --- a/uv.lock +++ b/uv.lock @@ -724,7 +724,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.2.0" +version = "2.2.1" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, @@ -796,7 +796,7 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.0" }, { name = "requests", specifier = ">=2.20.0" }, { name = "rich", specifier = ">=14.0.0" }, - { name = "textual", specifier = ">=1.0.0" }, + { name = "textual", specifier = ">=7.4.0" }, { name = "textual-speedups", specifier = ">=0.2.1" }, { name = "tomli-w", specifier = ">=1.2.0" }, { name = "tree-sitter", specifier = ">=0.25.2" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index 0276a2b..c32a834 100644 --- a/vibe/__init__.py +++ b/vibe/__init__.py @@ -3,4 +3,4 @@ from __future__ import annotations from pathlib import Path VIBE_ROOT = Path(__file__).parent -__version__ = "2.2.0" +__version__ = "2.2.1" diff --git a/vibe/cli/clipboard.py b/vibe/cli/clipboard.py index ad73b44..3526f5e 100644 --- a/vibe/cli/clipboard.py +++ b/vibe/cli/clipboard.py @@ -1,8 +1,12 @@ from __future__ import annotations import base64 +from collections.abc import Callable import os +import shutil +import subprocess +import pyperclip from textual.app import App _PREVIEW_MAX_LENGTH = 40 @@ -19,6 +23,100 @@ def _copy_osc52(text: str) -> None: tty.flush() +def _copy_pyperclip(text: str) -> None: + pyperclip.copy(text) + + +def _has_cmd(cmd: str) -> bool: + return shutil.which(cmd) is not None + + +def _copy_pbcopy(text: str) -> None: + subprocess.run(["pbcopy"], input=text.encode("utf-8"), check=True) + + +def _copy_xclip(text: str) -> None: + subprocess.run( + ["xclip", "-selection", "clipboard"], input=text.encode("utf-8"), check=True + ) + + +def _copy_wl_copy(text: str) -> None: + subprocess.run(["wl-copy"], input=text.encode("utf-8"), check=True) + + +_CMD_STRATEGIES: list[tuple[str, Callable[[str], None]]] = [ + ("pbcopy", _copy_pbcopy), + ("xclip", _copy_xclip), + ("wl-copy", _copy_wl_copy), +] + +_COPY_METHODS: list[Callable[[str], None]] = [ + _copy_osc52, + _copy_pyperclip, + *[fn for cmd, fn in _CMD_STRATEGIES if _has_cmd(cmd)], +] + + +def _paste_pyperclip() -> str: + return pyperclip.paste() + + +def _paste_pbpaste() -> str: + return subprocess.run(["pbpaste"], capture_output=True, check=True).stdout.decode( + "utf-8" + ) + + +def _paste_xclip() -> str: + return subprocess.run( + ["xclip", "-selection", "clipboard", "-o"], capture_output=True, check=True + ).stdout.decode("utf-8") + + +def _paste_wl_paste() -> str: + return subprocess.run(["wl-paste"], capture_output=True, check=True).stdout.decode( + "utf-8" + ) + + +_PASTE_CMD_STRATEGIES: list[tuple[str, Callable[[], str]]] = [ + ("pbpaste", _paste_pbpaste), + ("xclip", _paste_xclip), + ("wl-paste", _paste_wl_paste), +] + +_READ_CLIPBOARD_METHODS: list[Callable[[], str]] = [ + _paste_pyperclip, + *[fn for cmd, fn in _PASTE_CMD_STRATEGIES if _has_cmd(cmd)], +] + + +def _read_clipboard() -> str | None: + for reader in _READ_CLIPBOARD_METHODS: + try: + return reader() + except Exception: + pass + return None + + +def _copy_to_clipboard(text: str) -> None: + all_strategies_failed = True + for to_clipboard in _COPY_METHODS: + try: + to_clipboard(text) + except Exception: + pass + else: + all_strategies_failed = False + if _read_clipboard() == text: + return + + if all_strategies_failed: + raise RuntimeError("All clipboard strategies failed") + + def _shorten_preview(texts: list[str]) -> str: dense_text = "⏎".join(texts).replace("\n", "⏎") if len(dense_text) > _PREVIEW_MAX_LENGTH: @@ -26,7 +124,7 @@ def _shorten_preview(texts: list[str]) -> str: return dense_text -def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None: +def _get_selected_texts(app: App) -> list[str]: selected_texts = [] for widget in app.query("*"): @@ -47,13 +145,17 @@ def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None if selected_text.strip(): selected_texts.append(selected_text) + return selected_texts + + +def copy_selection_to_clipboard(app: App, show_toast: bool = True) -> str | None: + selected_texts = _get_selected_texts(app) if not selected_texts: return None combined_text = "\n".join(selected_texts) - try: - _copy_osc52(combined_text) + _copy_to_clipboard(combined_text) if show_toast: app.notify( f'"{_shorten_preview(selected_texts)}" copied to clipboard', diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 666ccf2..c1f21f3 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -2,16 +2,20 @@ from __future__ import annotations import asyncio from enum import StrEnum, auto +import os from pathlib import Path +import signal import subprocess import time from typing import Any, ClassVar, assert_never, cast from weakref import WeakKeyDictionary from pydantic import BaseModel -from textual.app import App, ComposeResult +from rich import print as rprint +from textual.app import WINDOWS, App, ComposeResult from textual.binding import Binding, BindingType from textual.containers import Horizontal, VerticalGroup, VerticalScroll +from textual.driver import Driver from textual.events import AppBlur, AppFocus, MouseUp from textual.widget import Widget from textual.widgets import Static @@ -38,11 +42,9 @@ from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenS from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer from vibe.cli.textual_ui.widgets.messages import ( - AssistantMessage, BashOutputMessage, ErrorMessage, InterruptMessage, - ReasoningMessage, StreamingMessageBase, UserCommandMessage, UserMessage, @@ -54,7 +56,7 @@ from vibe.cli.textual_ui.widgets.path_display import PathDisplay from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp from vibe.cli.textual_ui.widgets.question_app import QuestionApp from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage -from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage +from vibe.cli.textual_ui.widgets.tools import ToolResultMessage from vibe.cli.textual_ui.windowing import ( HISTORY_RESUME_TAIL_MESSAGES, LOAD_MORE_BATCH_SIZE, @@ -180,6 +182,7 @@ class VibeApp(App): # noqa: PLR0904 BINDINGS: ClassVar[list[BindingType]] = [ Binding("ctrl+c", "clear_quit", "Quit", show=False), Binding("ctrl+d", "force_quit", "Quit", show=False, priority=True), + Binding("ctrl+z", "suspend_with_message", "Suspend", show=False, priority=True), Binding("escape", "interrupt", "Interrupt", show=False, priority=True), Binding("ctrl+o", "toggle_tool", "Toggle Tool", show=False), Binding("ctrl+y", "copy_selection", "Copy", show=False, priority=True), @@ -225,8 +228,6 @@ class VibeApp(App): # noqa: PLR0904 self.history_file = HISTORY_FILE.path self._tools_collapsed = True - self._current_streaming_message: AssistantMessage | None = None - self._current_streaming_reasoning: ReasoningMessage | None = None self._windowing = SessionWindowing(load_more_batch_size=LOAD_MORE_BATCH_SIZE) self._load_more = HistoryLoadMoreManager() self._tool_call_map: dict[str, str] | None = None @@ -239,9 +240,9 @@ class VibeApp(App): # noqa: PLR0904 self._plan_offer_gateway = plan_offer_gateway self._initial_prompt = initial_prompt self._teleport_on_start = teleport_on_start and self.config.nuage_enabled - self._auto_scroll = True self._last_escape_time: float | None = None self._banner: Banner | None = None + self._whats_new_message: WhatsNewMessage | None = None self._cached_messages_area: Widget | None = None self._cached_chat: ChatScroll | None = None self._cached_loading_area: Widget | None = None @@ -267,6 +268,7 @@ class VibeApp(App): # noqa: PLR0904 safety=self.agent_loop.agent_profile.safety, agent_name=self.agent_loop.agent_profile.display_name.lower(), skill_entries_getter=self._get_skill_entries, + file_watcher_for_autocomplete_getter=self._is_file_watcher_enabled, nuage_enabled=self.config.nuage_enabled, ) @@ -284,7 +286,6 @@ class VibeApp(App): # noqa: PLR0904 self.event_handler = EventHandler( mount_callback=self._mount_and_scroll, - scroll_callback=self._scroll_to_bottom_deferred, get_tools_collapsed=lambda: self._tools_collapsed, ) @@ -312,6 +313,8 @@ class VibeApp(App): # noqa: PLR0904 self._schedule_update_notification() self.agent_loop.emit_new_session_telemetry("cli") + self.call_after_refresh(self._refresh_banner) + if self._initial_prompt or self._teleport_on_start: self.call_after_refresh(self._process_initial_prompt) @@ -325,12 +328,19 @@ class VibeApp(App): # noqa: PLR0904 self._handle_user_message(self._initial_prompt), exclusive=False ) + def _is_file_watcher_enabled(self) -> bool: + return self.config.file_watcher_for_autocomplete + async def on_chat_input_container_submitted( self, event: ChatInputContainer.Submitted ) -> None: if self._banner: self._banner.freeze_animation() + if self._whats_new_message: + await self._whats_new_message.remove() + self._whats_new_message = None + value = event.value.strip() if not value: return @@ -582,9 +592,7 @@ class VibeApp(App): # noqa: PLR0904 plan.tool_call_map, start_index=plan.tail_start_index, ) - self.call_after_refresh( - lambda: self._align_chat_after_history_rebuild(plan.has_backfill) - ) + self.call_after_refresh(self._scroll_chat_to_end) self._tool_call_map = plan.tool_call_map self._windowing.set_backfill(plan.backfill_messages) await self._load_more.set_visible( @@ -703,7 +711,8 @@ class VibeApp(App): # noqa: PLR0904 if self._loading_widget: await self._loading_widget.remove() self._loading_widget = None - await self._finalize_current_streaming_message() + if self.event_handler: + await self.event_handler.finalize_streaming() await self._refresh_windowing_from_history() async def _teleport_command(self) -> None: @@ -814,6 +823,7 @@ class VibeApp(App): # noqa: PLR0904 if self.event_handler: self.event_handler.stop_current_tool_call(success=False) self.event_handler.stop_current_compact() + await self.event_handler.finalize_streaming() self._agent_running = False loading_area = self._cached_loading_area or self.query_one( @@ -822,7 +832,6 @@ class VibeApp(App): # noqa: PLR0904 await loading_area.remove_children() self._loading_widget = None - await self._finalize_current_streaming_message() await self._mount_and_scroll(InterruptMessage()) self._interrupt_requested = False @@ -881,7 +890,8 @@ class VibeApp(App): # noqa: PLR0904 self._tool_call_map = None self._history_widget_indices = WeakKeyDictionary() await self.agent_loop.clear_history() - await self._finalize_current_streaming_message() + if self.event_handler: + await self.event_handler.finalize_streaming() messages_area = self._cached_messages_area or self.query_one("#messages") await messages_area.remove_children() @@ -1019,7 +1029,7 @@ class VibeApp(App): # noqa: PLR0904 self.call_after_refresh(widget.focus) if scroll: - self.call_after_refresh(self._scroll_to_bottom) + self.call_after_refresh(self._scroll_chat_to_end) async def _switch_to_config_app(self) -> None: if self._current_bottom_app == BottomApp.Config: @@ -1059,7 +1069,7 @@ class VibeApp(App): # noqa: PLR0904 self._chat_input_container.display = True self._current_bottom_app = BottomApp.Input self.call_after_refresh(self._chat_input_container.focus_input) - self.call_after_refresh(self._scroll_to_bottom) + self.call_after_refresh(self._scroll_chat_to_end) def _focus_current_bottom_app(self) -> None: try: @@ -1153,7 +1163,7 @@ class VibeApp(App): # noqa: PLR0904 self._handle_agent_running_escape() self._last_escape_time = current_time - self._scroll_to_bottom() + self.call_after_refresh(self._scroll_chat_to_end) self._focus_current_bottom_app() async def on_history_load_more_requested(self, _: HistoryLoadMoreRequested) -> None: @@ -1211,6 +1221,10 @@ class VibeApp(App): # noqa: PLR0904 def _refresh_profile_widgets(self) -> None: self._update_profile_widgets(self.agent_loop.agent_profile) + def _refresh_banner(self) -> None: + if self._banner: + self._banner.set_state(self.config, self.agent_loop.skill_manager) + def _update_profile_widgets(self, profile: AgentProfile) -> None: if self._chat_input_container: self._chat_input_container.set_safety(profile.safety) @@ -1245,7 +1259,6 @@ class VibeApp(App): # noqa: PLR0904 try: chat = self._cached_chat or self.query_one("#chat", ChatScroll) chat.scroll_relative(y=-5, animate=False) - self._auto_scroll = False except Exception: pass @@ -1253,8 +1266,6 @@ class VibeApp(App): # noqa: PLR0904 try: chat = self._cached_chat or self.query_one("#chat", ChatScroll) chat.scroll_relative(y=5, animate=False) - if self._is_scrolled_to_bottom(chat): - self._auto_scroll = True except Exception: pass @@ -1283,7 +1294,11 @@ class VibeApp(App): # noqa: PLR0904 whats_new_message = WhatsNewMessage(f"{content}\n\n{plan_offer}") if self._history_widget_indices: whats_new_message.add_class("after-history") - await self._mount_and_scroll(whats_new_message) + messages_area = self._cached_messages_area or self.query_one("#messages") + chat = self._cached_chat or self.query_one("#chat", ChatScroll) + await chat.mount(whats_new_message, after=messages_area) + self._whats_new_message = whats_new_message + chat.anchor() await mark_version_as_seen(self._current_version, self._update_cache_repository) async def _plan_offer_cta(self) -> str | None: @@ -1309,105 +1324,29 @@ class VibeApp(App): # noqa: PLR0904 ) return - async def _finalize_current_streaming_message(self) -> None: - if self._current_streaming_reasoning is not None: - self._current_streaming_reasoning.stop_spinning() - await self._current_streaming_reasoning.stop_stream() - self._current_streaming_reasoning = None - - if self._current_streaming_message is None: - return - - await self._current_streaming_message.stop_stream() - self._current_streaming_message = None - - async def _handle_streaming_widget[T: StreamingMessageBase]( - self, - widget: T, - current_stream: T | None, - other_stream: StreamingMessageBase | None, - messages_area: Widget, - ) -> T | None: - if other_stream is not None: - await other_stream.stop_stream() - - if current_stream is not None: - if widget._content: - await current_stream.append_content(widget._content) - return None - - await messages_area.mount(widget) - await widget.write_initial_content() - return widget + def _scroll_chat_to_end(self) -> None: + chat = self._cached_chat or self.query_one("#chat", ChatScroll) + chat.scroll_end(animate=False) async def _mount_and_scroll(self, widget: Widget) -> None: messages_area = self._cached_messages_area or self.query_one("#messages") chat = self._cached_chat or self.query_one("#chat", ChatScroll) - was_at_bottom = self._is_scrolled_to_bottom(chat) - result: Widget | None = None - if was_at_bottom: - self._auto_scroll = True + await messages_area.mount(widget) + if isinstance(widget, StreamingMessageBase): + await widget.write_initial_content() - if isinstance(widget, ReasoningMessage): - result = await self._handle_streaming_widget( - widget, - self._current_streaming_reasoning, - self._current_streaming_message, - messages_area, - ) - if result is not None: - self._current_streaming_reasoning = result - self._current_streaming_message = None - elif isinstance(widget, AssistantMessage): - if self._current_streaming_reasoning is not None: - self._current_streaming_reasoning.stop_spinning() - result = await self._handle_streaming_widget( - widget, - self._current_streaming_message, - self._current_streaming_reasoning, - messages_area, - ) - if result is not None: - self._current_streaming_message = result - self._current_streaming_reasoning = None - else: - await self._finalize_current_streaming_message() - await messages_area.mount(widget) - result = widget - - is_tool_message = isinstance(widget, (ToolCallMessage, ToolResultMessage)) - - if not is_tool_message: - self.call_after_refresh(self._scroll_to_bottom) - - if result is not None: - self.call_after_refresh(self._try_prune) - if was_at_bottom: - self.call_after_refresh(self._anchor_if_scrollable) - - def _is_scrolled_to_bottom(self, scroll_view: VerticalScroll) -> bool: - try: - threshold = 3 - return scroll_view.scroll_y >= (scroll_view.max_scroll_y - threshold) - except Exception: - return True - - def _scroll_to_bottom(self) -> None: - try: - chat = self._cached_chat or self.query_one("#chat", ChatScroll) - chat.scroll_end(animate=False) - except Exception: - pass - - def _scroll_to_bottom_deferred(self) -> None: - self.call_after_refresh(self._scroll_to_bottom) + self.call_after_refresh(self._try_prune) + chat.anchor() async def _try_prune(self) -> None: messages_area = self._cached_messages_area or self.query_one("#messages") - await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK) + pruned = await prune_by_height(messages_area, PRUNE_LOW_MARK, PRUNE_HIGH_MARK) if self._load_more.widget and not self._load_more.widget.parent: self._load_more.widget = None + if pruned: + chat = self._cached_chat or self.query_one("#chat", ChatScroll) + self.call_later(chat.anchor) async def _refresh_windowing_from_history(self) -> None: if self._load_more.widget is None: @@ -1424,29 +1363,6 @@ class VibeApp(App): # noqa: PLR0904 messages_area, visible=has_backfill, remaining=self._windowing.remaining ) - def _anchor_if_scrollable(self) -> None: - if not self._auto_scroll: - return - try: - chat = self._cached_chat or self.query_one("#chat", ChatScroll) - if chat.max_scroll_y == 0: - return - chat.anchor() - except Exception: - pass - - def _align_chat_after_history_rebuild(self, has_backfill: bool) -> None: - try: - chat = self._cached_chat or self.query_one("#chat", ChatScroll) - if has_backfill and chat.max_scroll_y > 0: - chat.anchor(True) - chat.scroll_end(animate=False) - chat.anchor(False) - return - chat.scroll_end(animate=False) - except Exception: - pass - def _schedule_update_notification(self) -> None: if self._update_notifier is None or not self.config.enable_update_checks: return @@ -1516,6 +1432,20 @@ class VibeApp(App): # noqa: PLR0904 if self._chat_input_container and self._chat_input_container.input_widget: self._chat_input_container.input_widget.set_app_focus(True) + def action_suspend_with_message(self) -> None: + if WINDOWS or self._driver is None or not self._driver.can_suspend: + return + with self.suspend(): + rprint( + "Mistral Vibe has been suspended. Run [bold cyan]fg[/bold cyan] to bring Mistral Vibe back." + ) + os.kill(os.getpid(), signal.SIGTSTP) + + def _on_driver_signal_resume(self, event: Driver.SignalResume) -> None: + # Textual doesn't repaint after resuming from Ctrl+Z (SIGTSTP); + # force a full layout refresh so the UI isn't garbled. + self.refresh(layout=True) + def _print_session_resume_message(session_id: str | None) -> None: if not session_id: diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss index 40d5f51..c688b89 100644 --- a/vibe/cli/textual_ui/app.tcss +++ b/vibe/cli/textual_ui/app.tcss @@ -29,6 +29,7 @@ TextArea > .text-area--cursor { width: 100%; background: transparent; padding: 0; + align-vertical: bottom; } #loading-area { @@ -147,8 +148,9 @@ Markdown { color: ansi_default; .code_inline { - color: ansi_default; - background: ansi_bright_black; + color: ansi_yellow; + background: transparent; + text-style: bold; } MarkdownFence { diff --git a/vibe/cli/textual_ui/handlers/event_handler.py b/vibe/cli/textual_ui/handlers/event_handler.py index 6c55270..45eba69 100644 --- a/vibe/cli/textual_ui/handlers/event_handler.py +++ b/vibe/cli/textual_ui/handlers/event_handler.py @@ -27,16 +27,14 @@ if TYPE_CHECKING: class EventHandler: def __init__( - self, - mount_callback: Callable, - scroll_callback: Callable, - get_tools_collapsed: Callable[[], bool], + self, mount_callback: Callable, get_tools_collapsed: Callable[[], bool] ) -> None: self.mount_callback = mount_callback - self.scroll_callback = scroll_callback self.get_tools_collapsed = get_tools_collapsed self.current_tool_call: ToolCallMessage | None = None self.current_compact: CompactMessage | None = None + self.current_streaming_message: AssistantMessage | None = None + self.current_streaming_reasoning: ReasoningMessage | None = None async def handle_event( self, @@ -45,24 +43,29 @@ class EventHandler: loading_widget: LoadingWidget | None = None, ) -> ToolCallMessage | None: match event: - case ToolCallEvent(): - return await self._handle_tool_call(event, loading_widget) - case ToolResultEvent(): - sanitized_event = self._sanitize_event(event) - await self._handle_tool_result(sanitized_event) - case ToolStreamEvent(): - await self._handle_tool_stream(event) case ReasoningEvent(): await self._handle_reasoning_message(event) case AssistantEvent(): await self._handle_assistant_message(event) + case ToolCallEvent(): + await self.finalize_streaming() + return await self._handle_tool_call(event, loading_widget) + case ToolResultEvent(): + await self.finalize_streaming() + sanitized_event = self._sanitize_event(event) + await self._handle_tool_result(sanitized_event) + case ToolStreamEvent(): + await self._handle_tool_stream(event) case CompactStartEvent(): + await self.finalize_streaming() await self._handle_compact_start() case CompactEndEvent(): + await self.finalize_streaming() await self._handle_compact_end(event) case UserMessageEvent(): - pass + await self.finalize_streaming() case _: + await self.finalize_streaming() await self._handle_unknown_event(event) return None @@ -113,13 +116,30 @@ class EventHandler: self.current_tool_call.set_stream_message(event.message) async def _handle_assistant_message(self, event: AssistantEvent) -> None: - await self.mount_callback(AssistantMessage(event.content)) + if self.current_streaming_reasoning is not None: + self.current_streaming_reasoning.stop_spinning() + await self.current_streaming_reasoning.stop_stream() + self.current_streaming_reasoning = None + + if self.current_streaming_message is None: + msg = AssistantMessage(event.content) + self.current_streaming_message = msg + await self.mount_callback(msg) + else: + await self.current_streaming_message.append_content(event.content) async def _handle_reasoning_message(self, event: ReasoningEvent) -> None: - tools_collapsed = self.get_tools_collapsed() - await self.mount_callback( - ReasoningMessage(event.content, collapsed=tools_collapsed) - ) + if self.current_streaming_message is not None: + await self.current_streaming_message.stop_stream() + self.current_streaming_message = None + + if self.current_streaming_reasoning is None: + tools_collapsed = self.get_tools_collapsed() + msg = ReasoningMessage(event.content, collapsed=tools_collapsed) + self.current_streaming_reasoning = msg + await self.mount_callback(msg) + else: + await self.current_streaming_reasoning.append_content(event.content) async def _handle_compact_start(self) -> None: compact_msg = CompactMessage() @@ -136,6 +156,15 @@ class EventHandler: async def _handle_unknown_event(self, event: BaseEvent) -> None: await self.mount_callback(NoMarkupStatic(str(event), classes="unknown-event")) + async def finalize_streaming(self) -> None: + if self.current_streaming_reasoning is not None: + self.current_streaming_reasoning.stop_spinning() + await self.current_streaming_reasoning.stop_stream() + self.current_streaming_reasoning = None + if self.current_streaming_message is not None: + await self.current_streaming_message.stop_stream() + self.current_streaming_message = None + def stop_current_tool_call(self, success: bool = True) -> None: if self.current_tool_call: self.current_tool_call.stop_spinning(success=success) diff --git a/vibe/cli/textual_ui/widgets/chat_input/container.py b/vibe/cli/textual_ui/widgets/chat_input/container.py index bfc7a02..43857b3 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/container.py +++ b/vibe/cli/textual_ui/widgets/chat_input/container.py @@ -42,6 +42,7 @@ class ChatInputContainer(Vertical): safety: AgentSafety = AgentSafety.NEUTRAL, agent_name: str = "", skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None, + file_watcher_for_autocomplete_getter: Callable[[], bool] | None = None, nuage_enabled: bool = False, **kwargs: Any, ) -> None: @@ -51,11 +52,19 @@ class ChatInputContainer(Vertical): self._safety = safety self._agent_name = agent_name self._skill_entries_getter = skill_entries_getter + self._file_watcher_for_autocomplete_getter = ( + file_watcher_for_autocomplete_getter + ) self._nuage_enabled = nuage_enabled self._completion_manager = MultiCompletionManager([ SlashCommandController(CommandCompleter(self._get_slash_entries), self), - PathCompletionController(PathCompleter(), self), + PathCompletionController( + PathCompleter( + watcher_enabled_getter=self._file_watcher_for_autocomplete_getter + ), + self, + ), ]) self._completion_popup: CompletionPopup | None = None self._body: ChatInputBody | None = None diff --git a/vibe/cli/textual_ui/widgets/config_app.py b/vibe/cli/textual_ui/widgets/config_app.py index 838844c..c92427e 100644 --- a/vibe/cli/textual_ui/widgets/config_app.py +++ b/vibe/cli/textual_ui/widgets/config_app.py @@ -63,6 +63,12 @@ class ConfigApp(Container): "type": "cycle", "options": ["On", "Off"], }, + { + "key": "file_watcher_for_autocomplete", + "label": "Autocomplete watcher (may delay first autocompletion)", + "type": "cycle", + "options": ["On", "Off"], + }, ] self.title_widget: Static | None = None diff --git a/vibe/cli/textual_ui/widgets/tool_widgets.py b/vibe/cli/textual_ui/widgets/tool_widgets.py index 64bcaf4..d7cd34a 100644 --- a/vibe/cli/textual_ui/widgets/tool_widgets.py +++ b/vibe/cli/textual_ui/widgets/tool_widgets.py @@ -25,9 +25,9 @@ from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult def _truncate_lines(content: str, max_lines: int) -> tuple[str, str | None]: """Truncate content to max_lines, returning (content, truncation_info).""" - lines = content.split("\n") + lines = content.strip("\n").split("\n") if len(lines) <= max_lines: - return content, None + return "\n".join(lines), None remaining = len(lines) - max_lines return "\n".join(lines[:max_lines]), f"… ({remaining} more lines)" diff --git a/vibe/cli/textual_ui/windowing/history_windowing.py b/vibe/cli/textual_ui/windowing/history_windowing.py index 59c49ec..2969c41 100644 --- a/vibe/cli/textual_ui/windowing/history_windowing.py +++ b/vibe/cli/textual_ui/windowing/history_windowing.py @@ -5,7 +5,6 @@ from weakref import WeakKeyDictionary from textual.widget import Widget -from vibe.cli.textual_ui.widgets.messages import WhatsNewMessage from vibe.cli.textual_ui.windowing.history import ( build_tool_call_map, split_history_tail, @@ -29,10 +28,7 @@ class HistoryResumePlan: def should_resume_history(messages_children: list[Widget]) -> bool: - allowed_pre_existing_types = (WhatsNewMessage,) - return all( - isinstance(child, allowed_pre_existing_types) for child in messages_children - ) + return len(messages_children) == 0 def create_resume_plan( diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 8b30ef5..f2cb1eb 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -474,54 +474,21 @@ class AgentLoop: async def _stream_assistant_events( self, ) -> AsyncGenerator[AssistantEvent | ReasoningEvent]: - content_buffer = "" - reasoning_buffer = "" - chunks_with_content = 0 - chunks_with_reasoning = 0 message_id: str | None = None - BATCH_SIZE = 5 async for chunk in self._chat_streaming(): if message_id is None: message_id = chunk.message.message_id if chunk.message.reasoning_content: - if content_buffer: - yield AssistantEvent(content=content_buffer, message_id=message_id) - content_buffer = "" - chunks_with_content = 0 - - reasoning_buffer += chunk.message.reasoning_content - chunks_with_reasoning += 1 - - if chunks_with_reasoning >= BATCH_SIZE: - yield ReasoningEvent( - content=reasoning_buffer, message_id=message_id - ) - reasoning_buffer = "" - chunks_with_reasoning = 0 + yield ReasoningEvent( + content=chunk.message.reasoning_content, message_id=message_id + ) if chunk.message.content: - if reasoning_buffer: - yield ReasoningEvent( - content=reasoning_buffer, message_id=message_id - ) - reasoning_buffer = "" - chunks_with_reasoning = 0 - - content_buffer += chunk.message.content - chunks_with_content += 1 - - if chunks_with_content >= BATCH_SIZE: - yield AssistantEvent(content=content_buffer, message_id=message_id) - content_buffer = "" - chunks_with_content = 0 - - if reasoning_buffer: - yield ReasoningEvent(content=reasoning_buffer, message_id=message_id) - - if content_buffer: - yield AssistantEvent(content=content_buffer, message_id=message_id) + yield AssistantEvent( + content=chunk.message.content, message_id=message_id + ) async def _get_assistant_event(self) -> AssistantEvent: llm_result = await self._chat() @@ -933,7 +900,7 @@ class AgentLoop: ) self.messages = self.messages[:1] - self.stats = AgentStats() + self.stats = AgentStats.create_fresh(self.stats) self.stats.trigger_listeners() try: diff --git a/vibe/core/autocompletion/completers.py b/vibe/core/autocompletion/completers.py index 387e84a..827b1b5 100644 --- a/vibe/core/autocompletion/completers.py +++ b/vibe/core/autocompletion/completers.py @@ -71,8 +71,9 @@ class PathCompleter(Completer): self, max_entries_to_process: int = DEFAULT_MAX_ENTRIES_TO_PROCESS, target_matches: int = DEFAULT_TARGET_MATCHES, + watcher_enabled_getter: Callable[[], bool] | None = None, ) -> None: - self._indexer = FileIndexer() + self._indexer = FileIndexer(should_enable_watcher=watcher_enabled_getter) self._max_entries_to_process = max_entries_to_process self._target_matches = target_matches diff --git a/vibe/core/autocompletion/file_indexer/indexer.py b/vibe/core/autocompletion/file_indexer/indexer.py index 3500423..91329b3 100644 --- a/vibe/core/autocompletion/file_indexer/indexer.py +++ b/vibe/core/autocompletion/file_indexer/indexer.py @@ -1,7 +1,7 @@ from __future__ import annotations import atexit -from collections.abc import Iterable +from collections.abc import Callable, Iterable from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path @@ -23,7 +23,11 @@ class _RebuildTask: class FileIndexer: - def __init__(self, mass_change_threshold: int = 200) -> None: + def __init__( + self, + mass_change_threshold: int = 200, + should_enable_watcher: Callable[[], bool] | None = None, + ) -> None: self._lock = RLock() # guards _store snapshot access and watcher callbacks. self._stats = FileIndexStats() self._ignore_rules = IgnoreRules() @@ -40,6 +44,7 @@ class FileIndexer: ) # coordinates updates to _active_rebuilds and _target_root. self._target_root: Path | None = None self._shutdown = False + self._should_enable_watcher = should_enable_watcher or (lambda: False) atexit.register(self.shutdown) @@ -74,7 +79,10 @@ class FileIndexer: self._start_background_rebuild(resolved_root) self._wait_for_rebuild(resolved_root) - self._watcher.start(resolved_root) + if self._should_enable_watcher(): + self._watcher.start(resolved_root) + else: + self._watcher.stop() with self._lock: # ensure root reference is fresh before snapshotting return self._store.snapshot() diff --git a/vibe/core/config.py b/vibe/core/config.py index b04f03f..349d6ea 100644 --- a/vibe/core/config.py +++ b/vibe/core/config.py @@ -311,6 +311,7 @@ class VibeConfig(BaseSettings): vim_keybindings: bool = False disable_welcome_banner_animation: bool = False autocopy_to_clipboard: bool = True + file_watcher_for_autocomplete: bool = False displayed_workdir: str = "" auto_compact_threshold: int = 200_000 context_warnings: bool = False diff --git a/vibe/core/llm/backend/mistral.py b/vibe/core/llm/backend/mistral.py index d3bee2f..9522b6f 100644 --- a/vibe/core/llm/backend/mistral.py +++ b/vibe/core/llm/backend/mistral.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, NamedTuple, cast import httpx import mistralai +from mistralai.utils.retries import BackoffStrategy, RetryConfig from vibe.core.llm.exceptions import BackendErrorBuilder from vibe.core.llm.message_utils import merge_consecutive_user_messages @@ -178,13 +179,22 @@ class MistralBackend: ) self._server_url = match.group(1) self._timeout = timeout + self._retry_config = self._build_retry_config() + + def _build_retry_config(self) -> RetryConfig: + return RetryConfig( + strategy="backoff", + backoff=BackoffStrategy( + initial_interval=500, + max_interval=30000, + exponent=1.5, + max_elapsed_time=300000, + ), + retry_connection_errors=True, + ) async def __aenter__(self) -> MistralBackend: - self._client = mistralai.Mistral( - api_key=self._api_key, - server_url=self._server_url, - timeout_ms=int(self._timeout * 1000), - ) + self._client = self._create_mistral_client() await self._client.__aenter__() return self @@ -199,11 +209,17 @@ class MistralBackend: exc_type=exc_type, exc_val=exc_val, exc_tb=exc_tb ) + def _create_mistral_client(self) -> mistralai.Mistral: + return mistralai.Mistral( + api_key=self._api_key, + server_url=self._server_url, + timeout_ms=int(self._timeout * 1000), + retry_config=self._retry_config, + ) + def _get_client(self) -> mistralai.Mistral: if self._client is None: - self._client = mistralai.Mistral( - api_key=self._api_key, server_url=self._server_url - ) + self._client = self._create_mistral_client() return self._client async def complete( diff --git a/vibe/core/llm/backend/vertex.py b/vibe/core/llm/backend/vertex.py index 7b14834..504c3ac 100644 --- a/vibe/core/llm/backend/vertex.py +++ b/vibe/core/llm/backend/vertex.py @@ -1,9 +1,11 @@ from __future__ import annotations import json +import threading from typing import Any, ClassVar import google.auth +import google.auth.credentials from google.auth.transport.requests import Request from vibe.core.config import ProviderConfig @@ -12,15 +14,6 @@ from vibe.core.llm.backend.base import PreparedRequest from vibe.core.types import AvailableTool, LLMMessage, StrToolChoice -def get_vertex_access_token() -> str: - - credentials, _ = google.auth.default( - scopes=["https://www.googleapis.com/auth/cloud-platform"] - ) - credentials.refresh(Request()) - return credentials.token - - def build_vertex_base_url(region: str) -> str: if region == "global": return "https://aiplatform.googleapis.com" @@ -37,13 +30,39 @@ def build_vertex_endpoint( ) +class VertexCredentials: + def __init__(self) -> None: + self._credentials: google.auth.credentials.Credentials | None = None + self._lock = threading.Lock() + + @property + def access_token(self) -> str: + with self._lock: + creds = self._credentials + if creds is None: + creds, _ = google.auth.default( + scopes=["https://www.googleapis.com/auth/cloud-platform"] + ) + self._credentials = creds + if not creds.valid: + creds.refresh(Request()) + if creds.token is None: + raise RuntimeError( + "Vertex AI credential refresh did not produce a token" + ) + return creds.token + + class VertexAnthropicAdapter(AnthropicAdapter): """Vertex AI adapter — inherits all streaming/parsing from AnthropicAdapter.""" endpoint: ClassVar[str] = "" - # Vertex AI doesn't support beta features BETA_FEATURES: ClassVar[str] = "" + def __init__(self) -> None: + super().__init__() + self.credentials = VertexCredentials() + def prepare_request( # noqa: PLR0913 self, *, @@ -70,7 +89,6 @@ class VertexAnthropicAdapter(AnthropicAdapter): converted_tools = self._mapper.prepare_tools(tools) converted_tool_choice = self._mapper.prepare_tool_choice(tool_choice) - # Build vertex-specific payload (no "model" key, uses anthropic_version) payload: dict[str, Any] = { "anthropic_version": "vertex-2023-10-16", "messages": converted_messages, @@ -98,11 +116,9 @@ class VertexAnthropicAdapter(AnthropicAdapter): self._add_cache_control_to_last_user_message(converted_messages) - access_token = get_vertex_access_token() - headers = { "Content-Type": "application/json", - "Authorization": f"Bearer {access_token}", + "Authorization": f"Bearer {self.credentials.access_token}", "anthropic-beta": self.BETA_FEATURES, } diff --git a/vibe/core/tools/builtins/bash.py b/vibe/core/tools/builtins/bash.py index 7e0732c..bc1e850 100644 --- a/vibe/core/tools/builtins/bash.py +++ b/vibe/core/tools/builtins/bash.py @@ -72,13 +72,7 @@ def _get_shell_executable() -> str | None: def _get_base_env() -> dict[str, str]: - base_env = { - **os.environ, - "CI": "true", - "NONINTERACTIVE": "1", - "NO_TTY": "1", - "NO_COLOR": "1", - } + base_env = {**os.environ, "CI": "true", "NONINTERACTIVE": "1", "NO_TTY": "1"} if is_windows(): base_env["GIT_PAGER"] = "more" diff --git a/vibe/core/types.py b/vibe/core/types.py index 47decf4..d9904c8 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -61,6 +61,12 @@ class AgentStats(BaseModel): ) -> None: self._listeners[attr_name] = listener + @staticmethod + def create_fresh(previous: AgentStats) -> AgentStats: + fresh = AgentStats() + fresh._listeners = previous._listeners.copy() + return fresh + @computed_field @property def session_total_llm_tokens(self) -> int: diff --git a/vibe/whats_new.md b/vibe/whats_new.md index 5edd388..811eeee 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,5 +1,4 @@ -# What's New in 2.2.0 +# What's new -- **Agent Skills standard** — Vibe now discovers skills from `.agents/skills/` (agentskills.io) as well as `.vibe/skills/`. - -Optional: usage and tool events are sent to our datalake to improve the product if you have a valid Mistral API key; set `disable_telemetry = true` in config to opt out. +- **Performance**: general improvements (streaming, scrolling). +- **Fix**: autocopying now behaves correctly in cases where it previously failed.