diff --git a/.vscode/launch.json b/.vscode/launch.json index 51586af..2b76c8c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "2.7.6", + "version": "0.2.0", "configurations": [ { "name": "ACP Server", diff --git a/AGENTS.md b/AGENTS.md index 245028f..3e73f88 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,6 +155,22 @@ guidelines: - Or ask Cursor: select the undefined symbol, then Cmd+K and request "Add the missing import for ". - Or copy the import from an existing file in the repo (e.g. acp.schema, acp.helpers, vibe.core.*). + - title: "Keep Builtin Vibe Skill Up-to-Date" + description: > + The file `vibe/core/skills/builtins/vibe.py` is the builtin self-awareness skill. + It documents the CLI's features for the model: config.toml fields, CLI parameters, slash + commands, agents, skills, tools, VIBE_HOME structure, and environment variables. + When you change any of the following, update `vibe/core/skills/builtins/vibe.py` + to reflect the new behavior: + - CLI arguments or flags (vibe/cli/entrypoint.py) + - config.toml fields or defaults (vibe/core/config/_settings.py) + - Slash commands (vibe/cli/commands.py) + - Built-in agents (vibe/core/agents/) + - VIBE_HOME directory layout or paths (vibe/core/paths/) + - Skill, tool, or agent discovery logic + - Environment variables + If in doubt, read the skill file and check whether your change makes any section stale. + - title: "No Docstrings in Tests" description: > Do not add docstrings to test functions, test methods, or test classes. diff --git a/CHANGELOG.md b/CHANGELOG.md index 16fd452..76a259b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ 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.8.0] - 2026-04-21 + +### Added + +- Builtin skills system with self-awareness skill +- `/copy` slash command to copy messages to clipboard +- Merge field info support for configuration +- Glob tool for file pattern matching +- `exit` (without slash) to exit the application +- Current git branch and GitHub PR display in bottom bar +- Diff view for `write_file` overwrites in approval and result widgets +- Windowed virtualization for chat scroll with scroll-based auto-load + +### Changed + +- Deferred heavy initialization in subagents/ACP to prevent UI freeze +- Removed `/terminal-setup` command +- Strengthened user-level AGENTS.md instruction injection +- Allowed safe `find` commands by default with forced approval for execution predicates + +### Fixed + +- UI freeze on sub-agent initialization +- Race condition in banner initialization +- Scroll ghost artifacts in chat +- MergeKeyError key extraction for KeyError +- Task subagent deferred initialization signaling +- Word-drag state on mouse up to stop extending selection +- Full-widget selection flash on double-click +- Tab character handling in Textual selection +- Double-click word selection to match screen coordinates + ## [2.7.6] - 2026-04-16 ### Added @@ -29,7 +61,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Alt+Left / Alt+Right key bindings from chat input - ## [2.7.5] - 2026-04-14 ### Changed @@ -45,7 +76,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Encoding detection fallback in `read_safe` for non-UTF-8 files - Config saving logic cleanup - ## [2.7.4] - 2026-04-09 ### Added @@ -75,14 +105,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use skill in recent commands via the up-arrow navigation - Fixed loading order issues in vibe initialization - ## [2.7.3] - 2026-04-03 ### Added - `/data-retention` slash command to view Mistral AI's data retention notice and privacy settings - ## [2.7.2] - 2026-04-01 ### Added @@ -100,7 +128,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved bash denylist matching and error messages - Command messages are now skipped during rewind navigation - ## [2.7.1] - 2026-03-31 ### Added @@ -121,7 +148,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Text selection errors when copying from unmounting components - Excluded "injected" field from user messages in generic backend - ## [2.7.0] - 2026-03-24 ### Added @@ -133,7 +159,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Preserve message_id when aggregating streaming LLM chunks - Improved error handling for SDK response errors - ## [2.6.2] - 2026-03-23 ### Changed @@ -144,14 +169,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Context usage updates via ACP - ## [2.6.1] - 2026-03-23 ### Changed - Loosened agent-client-protocol version constraint from pinned to minimum bound - ## [2.6.0] - 2026-03-23 ### Added @@ -188,7 +211,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Context usage updates sent via ACP - Include `exit_plan_mode` tool only in plan mode - ## [2.5.0] - 2026-03-16 ### Added @@ -215,7 +237,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved scrolling performance - Web search tool now infers server URL from provider config - ## [2.4.2] - 2026-03-12 ### Added @@ -229,7 +250,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Update notification toast no longer times out, ensuring the user sees the restart prompt - Removed `file_content_before` from Vibe Code, reducing payload size - ## [2.4.1] - 2026-03-10 ### Added @@ -246,7 +266,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Space key fix extended to all `Input` widgets (question prompts, proxy setup) in VS Code terminal - Ruff isort/formatter config conflict resolved (`split-on-trailing-comma` set to `false`) - ## [2.4.0] - 2026-03-09 ### Added @@ -268,7 +287,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - UTF-8 encoding enforced when reading metadata files - Update notifier no longer crashes on unexpected response fields - ## [2.3.0] - 2026-02-27 ### Added @@ -306,7 +324,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - ## [2.2.1] - 2026-02-18 ### Added @@ -329,7 +346,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 @@ -358,7 +374,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Revert cryptography 46.0.5 bump for compatibility - Pin banner version in UI snapshot tests for stability - ## [2.1.0] - 2026-02-11 ### Added @@ -388,7 +403,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Legacy terminal theme module and agent indicator widget - Standalone onboarding theme selection screen (replaced by redesign) - ## [2.0.2] - 2026-01-30 ### Added @@ -409,14 +423,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix global agent prompt not being loaded correctly - Do not propose to "resume" when there is nothing to resume - ## [2.0.1] - 2026-01-28 ### Fixed - Fix encoding issues in Windows - ## [2.0.0] - 2026-01-27 ### Added @@ -462,7 +474,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - instructions.md support - workdir setting in config file - ## [1.3.5] - 2026-01-12 ### Fixed diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 71ad511..dc081a4 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.7.6" +version = "2.8.0" 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.7.6/vibe-acp-darwin-aarch64-2.7.6.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.0/vibe-acp-darwin-aarch64-2.8.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.6/vibe-acp-darwin-x86_64-2.7.6.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.0/vibe-acp-darwin-x86_64-2.8.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.6/vibe-acp-linux-aarch64-2.7.6.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.0/vibe-acp-linux-aarch64-2.8.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.6/vibe-acp-linux-x86_64-2.7.6.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.0/vibe-acp-linux-x86_64-2.8.0.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.6/vibe-acp-windows-aarch64-2.7.6.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.0/vibe-acp-windows-aarch64-2.8.0.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.6/vibe-acp-windows-x86_64-2.7.6.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.8.0/vibe-acp-windows-x86_64-2.8.0.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index 3eefaee..defd5d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.7.6" +version = "2.8.0" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" diff --git a/scripts/bump_version.py b/scripts/bump_version.py index 8d3bdd4..305371f 100755 --- a/scripts/bump_version.py +++ b/scripts/bump_version.py @@ -201,11 +201,6 @@ Examples: (f"-{current_version}.zip", f"-{new_version}.zip"), ], ) - # Update .vscode/launch.json - update_hard_values_files( - ".vscode/launch.json", - [(f'"version": "{current_version}"', f'"version": "{new_version}"')], - ) # Update vibe/core/__init__.py update_hard_values_files( "vibe/__init__.py", diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index 7e4944a..e687aac 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.7.6" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.8.0" ) 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.7.6" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.8.0" ) assert response.auth_methods is not None diff --git a/tests/banner/__init__.py b/tests/banner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/banner/test_banner_initial_state.py b/tests/banner/test_banner_initial_state.py new file mode 100644 index 0000000..455b2ad --- /dev/null +++ b/tests/banner/test_banner_initial_state.py @@ -0,0 +1,167 @@ +"""Tests for the Banner widget initial state with connectors/MCP.""" + +from __future__ import annotations + +from unittest.mock import Mock, patch + +from vibe.cli.textual_ui.widgets.banner.banner import ( + Banner, + BannerState, + _connector_count, + _pluralize, +) +from vibe.core.config import VibeConfig +from vibe.core.skills.manager import SkillManager +from vibe.core.tools.connectors.connector_registry import ConnectorRegistry +from vibe.core.tools.mcp.registry import MCPRegistry + + +class TestBannerInitialState: + """Test that Banner properly displays initial state including connectors/MCP.""" + + def test_pluralize(self) -> None: + """Test pluralization helper.""" + assert _pluralize(0, "model") == "0 models" + assert _pluralize(1, "model") == "1 model" + assert _pluralize(2, "model") == "2 models" + assert _pluralize(0, "MCP server") == "0 MCP servers" + assert _pluralize(1, "MCP server") == "1 MCP server" + assert _pluralize(2, "connector") == "2 connectors" + + def test_connector_count_with_registry(self) -> None: + """Test _connector_count with a populated registry.""" + registry = Mock(spec=ConnectorRegistry) + registry.connector_count = 3 + assert _connector_count(registry) == 3 + + def test_connector_count_without_registry(self) -> None: + """Test _connector_count with None registry.""" + assert _connector_count(None) == 0 + + def test_banner_initial_state_includes_connectors(self) -> None: + """Test that Banner._initial_state includes connector count.""" + config = Mock(spec=VibeConfig) + config.active_model = "test-model" + config.models = ["test-model"] + config.mcp_servers = [] + config.disable_welcome_banner_animation = False + + skill_manager = Mock(spec=SkillManager) + skill_manager.custom_skills_count = 0 + + mcp_registry = Mock(spec=MCPRegistry) + mcp_registry.count_loaded.return_value = 0 + + connector_registry = Mock(spec=ConnectorRegistry) + connector_registry.connector_count = 5 + + banner = Banner( + config=config, + skill_manager=skill_manager, + mcp_registry=mcp_registry, + connector_registry=connector_registry, + ) + + assert banner._initial_state.active_model == "test-model" + assert banner._initial_state.models_count == 1 + assert banner._initial_state.mcp_servers_count == 0 + assert banner._initial_state.connectors_count == 5 + assert banner._initial_state.skills_count == 0 + + def test_banner_initial_state_with_none_connector_registry(self) -> None: + """Test that Banner._initial_state handles None connector registry.""" + config = Mock(spec=VibeConfig) + config.active_model = "test-model" + config.models = ["test-model"] + config.mcp_servers = [] + config.disable_welcome_banner_animation = False + + skill_manager = Mock(spec=SkillManager) + skill_manager.custom_skills_count = 0 + + mcp_registry = Mock(spec=MCPRegistry) + mcp_registry.count_loaded.return_value = 0 + + banner = Banner( + config=config, + skill_manager=skill_manager, + mcp_registry=mcp_registry, + connector_registry=None, + ) + + assert banner._initial_state.connectors_count == 0 + + def test_format_meta_counts_includes_connectors(self) -> None: + """Test that _format_meta_counts includes connector count when > 0.""" + # Test _format_meta_counts by directly calling it on a Banner instance + config = Mock(spec=VibeConfig) + config.active_model = "test-model" + config.models = [] # Must be a list for len() to work + config.mcp_servers = [] + config.disable_welcome_banner_animation = False + + skill_manager = Mock(spec=SkillManager) + skill_manager.custom_skills_count = 0 + + mcp_registry = Mock(spec=MCPRegistry) + mcp_registry.count_loaded.return_value = 0 + + banner = Banner( + config=config, + skill_manager=skill_manager, + mcp_registry=mcp_registry, + connector_registry=None, + ) + + # Now test _format_meta_counts by setting state + banner.state = BannerState( + models_count=2, mcp_servers_count=1, connectors_count=3, skills_count=5 + ) + result = banner._format_meta_counts() + assert "2 models" in result + assert "3 connectors" in result + assert "1 MCP server" in result + assert "5 skills" in result + + # Test without connectors + banner.state = BannerState( + models_count=2, mcp_servers_count=1, connectors_count=0, skills_count=5 + ) + result = banner._format_meta_counts() + assert "2 models" in result + assert "connectors" not in result # Should not appear when 0 + assert "1 MCP server" in result + assert "5 skills" in result + + +class TestBannerWithEnabledConnectors: + """Integration tests for Banner with EXPERIMENTAL_ENABLE_CONNECTORS=1.""" + + @patch("vibe.core.tools.connectors.connectors_enabled") + def test_connectors_enabled_flag(self, mock_enabled: Mock) -> None: + """Test that connector count is read when enabled.""" + mock_enabled.return_value = True + + config = Mock(spec=VibeConfig) + config.active_model = "test-model" + config.models = ["test-model"] + config.mcp_servers = [] + config.disable_welcome_banner_animation = False + + skill_manager = Mock(spec=SkillManager) + skill_manager.custom_skills_count = 0 + + mcp_registry = Mock(spec=MCPRegistry) + mcp_registry.count_loaded.return_value = 0 + + connector_registry = Mock(spec=ConnectorRegistry) + connector_registry.connector_count = 5 + + banner = Banner( + config=config, + skill_manager=skill_manager, + mcp_registry=mcp_registry, + connector_registry=connector_registry, + ) + + assert banner._initial_state.connectors_count == 5 diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 829c923..f7bd65d 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -9,6 +9,7 @@ class TestCommandRegistry: assert registry.get_command_name("/help") == "help" assert registry.get_command_name("/config") == "config" assert registry.get_command_name("/model") == "model" + assert registry.get_command_name("/connectors") == "mcp" assert registry.get_command_name("/clear") == "clear" assert registry.get_command_name("/exit") == "exit" assert registry.get_command_name("/data-retention") == "data-retention" @@ -77,6 +78,11 @@ class TestCommandRegistry: result = registry.parse_command("/mcp filesystem") assert result == ("mcp", registry.commands["mcp"], "filesystem") + def test_parse_command_maps_connector_alias_to_mcp(self) -> None: + registry = CommandRegistry() + result = registry.parse_command("/connectors filesystem") + assert result == ("mcp", registry.commands["mcp"], "filesystem") + def test_data_retention_command_registration(self) -> None: registry = CommandRegistry() result = registry.parse_command("/data-retention") diff --git a/tests/cli/test_mcp_app.py b/tests/cli/test_mcp_app.py index 13d5cf3..f00237b 100644 --- a/tests/cli/test_mcp_app.py +++ b/tests/cli/test_mcp_app.py @@ -2,11 +2,19 @@ from __future__ import annotations from collections.abc import AsyncGenerator from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock -from vibe.cli.textual_ui.widgets.mcp_app import MCPApp, collect_mcp_tool_index +import pytest + +from tests.stubs.fake_connector_registry import FakeConnectorRegistry +from vibe.cli.textual_ui.widgets.mcp_app import ( + MCPApp, + _sort_connector_names_for_menu, + collect_mcp_tool_index, +) from vibe.core.config import MCPStdio from vibe.core.tools.base import InvokeContext +from vibe.core.tools.connectors.connector_registry import RemoteTool from vibe.core.tools.mcp.tools import MCPTool, MCPToolResult, _OpenArgs from vibe.core.types import ToolStreamEvent @@ -158,3 +166,103 @@ class TestMCPAppInit: ) app.action_back() assert render_calls == [] + + @pytest.mark.asyncio + async def test_action_refresh_dispatches_worker(self) -> None: + servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] + mgr = _make_tool_manager({}) + refresh_callback = AsyncMock(return_value="Refreshed.") + app = MCPApp( + mcp_servers=servers, tool_manager=mgr, refresh_callback=refresh_callback + ) + app._viewing_server = "srv" + render_calls: list[tuple[str | None, str | None]] = [] + app._refresh_view = lambda server_name, *, kind=None: render_calls.append(( + server_name, + kind, + )) + app.run_worker = MagicMock() + + await app.action_refresh() + + assert app._status_message == "Refreshing..." + assert render_calls == [("srv", None)] + app.run_worker.assert_called_once() + + def test_on_worker_state_changed_updates_after_refresh(self) -> None: + from textual.worker import Worker + + servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] + mgr = _make_tool_manager({}) + app = MCPApp(mcp_servers=servers, tool_manager=mgr) + app.refresh_index = MagicMock() + + worker = MagicMock(spec=Worker) + worker.group = "refresh" + worker.is_finished = True + worker.result = "Refreshed." + event = MagicMock(spec=Worker.StateChanged) + event.worker = worker + + app.on_worker_state_changed(event) + + assert app._status_message == "Refreshed." + assert app._refreshing is False + app.refresh_index.assert_called_once() + + def test_close_blocked_while_refreshing(self) -> None: + mgr = _make_tool_manager({}) + app = MCPApp(mcp_servers=[], tool_manager=mgr) + app._refreshing = True + app.post_message = MagicMock() + + app.action_close() + + app.post_message.assert_not_called() + + def test_back_blocked_while_refreshing(self) -> None: + servers = [MCPStdio(name="srv", transport="stdio", command="cmd")] + mgr = _make_tool_manager({}) + app = MCPApp(mcp_servers=servers, tool_manager=mgr) + app._viewing_server = "srv" + app._refreshing = True + render_calls: list[str | None] = [] + app._refresh_view = lambda server_name, *, kind=None: render_calls.append( + server_name + ) + + app.action_back() + + assert render_calls == [] + + +class TestConnectorMenuOrdering: + def test_connectors_are_sorted_by_connected_state_then_name(self) -> None: + registry = FakeConnectorRegistry( + connectors={ + "zeta": [], + "alpha": [RemoteTool(name="lookup", description="Lookup")], + "beta": [], + } + ) + + ordered = _sort_connector_names_for_menu( + registry.get_connector_names(), registry + ) + + assert ordered == ["alpha", "beta", "zeta"] + + def test_sorting_is_case_insensitive(self) -> None: + registry = FakeConnectorRegistry( + connectors={ + "Zeta": [], + "alpha": [RemoteTool(name="lookup", description="Lookup")], + "Beta": [], + } + ) + + ordered = _sort_connector_names_for_menu( + registry.get_connector_names(), registry + ) + + assert ordered == ["alpha", "Beta", "Zeta"] diff --git a/tests/cli/test_ui_skill_dispatch.py b/tests/cli/test_ui_skill_dispatch.py index 025e12d..53e4ea9 100644 --- a/tests/cli/test_ui_skill_dispatch.py +++ b/tests/cli/test_ui_skill_dispatch.py @@ -129,24 +129,3 @@ async def test_skill_without_args_does_not_prepend_invocation_line( vibe_app_with_skills, pilot, "Do the thing." ) assert "/my-skill" not in message._content - - -@pytest.mark.asyncio -async def test_skill_with_missing_file_shows_error(tmp_path: Path) -> None: - skills_dir = tmp_path / "skills" - skills_dir.mkdir() - skill_dir = create_skill(skills_dir, "my-skill", body=SKILL_BODY) - vibe_app = build_test_vibe_app( - config=build_test_vibe_config(skill_paths=[skills_dir]) - ) - (skill_dir / "SKILL.md").unlink() - - async with vibe_app.run_test() as pilot: - await pilot.pause(0.1) - chat_input = vibe_app.query_one(ChatInputContainer) - chat_input.post_message(ChatInputContainer.Submitted("/my-skill")) - - error = await _wait_for_error_message_containing( - vibe_app, pilot, "Failed to read skill file" - ) - assert "Failed to read skill file" in error._error diff --git a/tests/core/test_config_field.py b/tests/core/test_config_field.py new file mode 100644 index 0000000..57d25ba --- /dev/null +++ b/tests/core/test_config_field.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from typing import Annotated + +from pydantic import BaseModel, Field +import pytest + +from vibe.core.config.schema import ( + DuplicateMergeMetadataError, + MergeFieldMetadata, + WithConcatMerge, + WithConflictMerge, + WithReplaceMerge, + WithShallowMerge, + WithUnionMerge, +) +from vibe.core.utils.merge import MergeConflictError, MergeKeyError, MergeStrategy + + +class TestMergeFieldMetadata: + def test_frozen(self) -> None: + info = WithReplaceMerge() + with pytest.raises(FrozenInstanceError): + info.merge_strategy = MergeStrategy.CONCAT # type: ignore[misc] + + def test_equality(self) -> None: + assert WithReplaceMerge() == WithReplaceMerge() + + def test_inequality(self) -> None: + assert WithReplaceMerge() != WithConcatMerge() + + def test_from_field_returns_correct_subclass(self) -> None: + class _M(BaseModel): + x: Annotated[str, WithReplaceMerge()] = "a" + + info = MergeFieldMetadata.from_field(_M.model_fields["x"]) + assert isinstance(info, WithReplaceMerge) + assert info.merge_strategy is MergeStrategy.REPLACE + + def test_from_field_rejects_duplicate_markers(self) -> None: + class _M(BaseModel): + x: Annotated[str, WithReplaceMerge(), WithConflictMerge()] = "a" + + with pytest.raises(DuplicateMergeMetadataError): + MergeFieldMetadata.from_field(_M.model_fields["x"]) + + def test_from_field_returns_none_for_plain_field(self) -> None: + class _M(BaseModel): + x: str = "a" + + assert MergeFieldMetadata.from_field(_M.model_fields["x"]) is None + + +class TestWithUnionMerge: + def test_merge_key_required(self) -> None: + with pytest.raises(TypeError, match="requires merge_key"): + WithUnionMerge() + + def test_merge_key_stored(self) -> None: + marker = WithUnionMerge(merge_key="alias") + assert marker.merge_key == "alias" + assert marker.merge_strategy is MergeStrategy.UNION + + +class _ToolConfig(BaseModel): + enabled: bool = True + timeout: int = 30 + + +class _ModelConfig(BaseModel): + alias: str + provider: str + + +class _SampleConfig(BaseModel): + active_model: Annotated[str, WithReplaceMerge()] = "devstral-2" + disabled_tools: Annotated[list[str], WithConcatMerge()] = Field( + default_factory=list + ) + models: Annotated[list[_ModelConfig], WithUnionMerge(merge_key="alias")] = Field( + default_factory=list + ) + tools: Annotated[dict[str, _ToolConfig], WithShallowMerge()] = Field( + default_factory=dict + ) + allowed_hosts: Annotated[list[str] | None, WithConflictMerge()] = None + + +class TestAnnotatedFieldInModel: + def test_model_instantiates_with_defaults(self) -> None: + cfg = _SampleConfig() + assert cfg.active_model == "devstral-2" + assert cfg.disabled_tools == [] + assert cfg.models == [] + assert cfg.tools == {} + assert cfg.allowed_hosts is None + + def test_all_fields_have_merge_info(self) -> None: + for name, field_info in _SampleConfig.model_fields.items(): + info = MergeFieldMetadata.from_field(field_info) + assert info is not None, f"Field '{name}' missing MergeFieldMetadata" + + def test_merge_replace_via_field(self) -> None: + info = MergeFieldMetadata.from_field(_SampleConfig.model_fields["active_model"]) + assert info is not None + assert ( + info.merge_strategy.apply("devstral-2", "mistral-large") == "mistral-large" + ) + + def test_merge_concat_via_field(self) -> None: + info = MergeFieldMetadata.from_field( + _SampleConfig.model_fields["disabled_tools"] + ) + assert info is not None + result = info.merge_strategy.apply(["tool_a"], ["tool_b", "tool_c"]) + assert result == ["tool_a", "tool_b", "tool_c"] + + def test_merge_union_via_field(self) -> None: + info = MergeFieldMetadata.from_field(_SampleConfig.model_fields["models"]) + assert info is not None + assert info.merge_key == "alias" + + base = [{"alias": "m1", "provider": "p1"}, {"alias": "m2", "provider": "p2"}] + override = [{"alias": "m2", "provider": "p2-override"}] + key_fn = lambda item: item[info.merge_key] + result = info.merge_strategy.apply(base, override, key_fn=key_fn) + + assert len(result) == 2 + assert result[0] == {"alias": "m1", "provider": "p1"} + assert result[1] == {"alias": "m2", "provider": "p2-override"} + + def test_merge_union_missing_key_raises(self) -> None: + base = [{"alias": "m1", "provider": "p1"}] + override = [{"provider": "p2"}] # missing "alias" + key_fn = lambda item: item["alias"] + with pytest.raises(MergeKeyError): + MergeStrategy.UNION.apply(base, override, key_fn=key_fn) + + def test_merge_merge_via_field(self) -> None: + info = MergeFieldMetadata.from_field(_SampleConfig.model_fields["tools"]) + assert info is not None + + base = { + "search": _ToolConfig(enabled=True, timeout=30), + "browser": _ToolConfig(timeout=60), + } + override = { + "browser": _ToolConfig(enabled=False, timeout=120), + "code": _ToolConfig(), + } + result = info.merge_strategy.apply(base, override) + assert result == { + "search": _ToolConfig(enabled=True, timeout=30), + "browser": _ToolConfig(enabled=False, timeout=120), + "code": _ToolConfig(), + } + + def test_merge_conflict_via_field(self) -> None: + info = MergeFieldMetadata.from_field( + _SampleConfig.model_fields["allowed_hosts"] + ) + assert info is not None + with pytest.raises(MergeConflictError): + info.merge_strategy.apply(["host1"], ["host2"]) + + def test_merge_conflict_single_side_ok(self) -> None: + info = MergeFieldMetadata.from_field( + _SampleConfig.model_fields["allowed_hosts"] + ) + assert info is not None + assert info.merge_strategy.apply(None, ["host1"]) == ["host1"] + assert info.merge_strategy.apply(["host1"], None) == ["host1"] + + def test_full_merge_through_model_validate(self) -> None: + base_layer: dict[str, object] = { + "active_model": "devstral-2", + "disabled_tools": ["tool_a"], + "models": [{"alias": "m1", "provider": "p1"}], + "tools": {"search": {"enabled": True, "timeout": 30}}, + } + override_layer: dict[str, object] = { + "active_model": "mistral-large", + "disabled_tools": ["tool_b"], + "models": [ + {"alias": "m1", "provider": "p1-override"}, + {"alias": "m2", "provider": "p2"}, + ], + "tools": {"code": {"enabled": True, "timeout": 10}}, + } + + merged = {**base_layer} + for key, override_val in override_layer.items(): + info = MergeFieldMetadata.from_field(_SampleConfig.model_fields[key]) + assert info is not None + + key_fn = None + if info.merge_key is not None: + attr = info.merge_key + key_fn = lambda item, a=attr: item[a] + + merged[key] = info.merge_strategy.apply( + base_layer.get(key), override_val, key_fn=key_fn + ) + + cfg = _SampleConfig.model_validate(merged) + + assert cfg.active_model == "mistral-large" + assert cfg.disabled_tools == ["tool_a", "tool_b"] + assert len(cfg.models) == 2 + assert cfg.models[0].alias == "m1" + assert cfg.models[0].provider == "p1-override" + assert cfg.models[1].alias == "m2" + assert cfg.tools["search"].enabled is True + assert cfg.tools["code"].timeout == 10 + assert cfg.allowed_hosts is None diff --git a/tests/core/test_merge.py b/tests/core/test_merge.py index db9088e..3440651 100644 --- a/tests/core/test_merge.py +++ b/tests/core/test_merge.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock import pytest -from vibe.core.utils.merge import MergeConflictError, MergeStrategy +from vibe.core.utils.merge import MergeConflictError, MergeKeyError, MergeStrategy class TestMergeStrategyEnum: @@ -103,6 +103,13 @@ class TestUnion: with pytest.raises(TypeError, match="UNION requires list operands"): MergeStrategy.UNION.apply("a", [1], key_fn=str) + def test_raises_merge_key_error_for_missing_key(self) -> None: + base = [{"name": "a", "v": 1}] + override = [{"v": 2}] # missing "name" + with pytest.raises(MergeKeyError) as exc_info: + MergeStrategy.UNION.apply(base, override, key_fn=lambda x: x["name"]) + assert exc_info.value.key == "name" + class TestMerge: def test_dicts_merged_one_level(self) -> None: @@ -161,3 +168,16 @@ class TestMergeConflictError: err = MergeConflictError("active_model") assert str(err) == "Merge conflict on field 'active_model'" assert err.field_name == "active_model" + + +class TestMergeKeyError: + def test_message_includes_key_and_item(self) -> None: + item = {"v": 2} + err = MergeKeyError("name", item) + assert "name" in str(err) + assert str(item) in str(err) + assert err.key == "name" + assert err.item == item + + def test_is_key_error(self) -> None: + assert issubclass(MergeKeyError, KeyError) diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py index 686d1ac..8395b0a 100644 --- a/tests/core/test_telemetry_send.py +++ b/tests/core/test_telemetry_send.py @@ -135,6 +135,7 @@ class TestTelemetryClient: status="success", decision=decision, agent_profile_name="default", + model="mistral-large", ) assert len(telemetry_events) == 1 @@ -146,6 +147,7 @@ class TestTelemetryClient: assert properties["decision"] == "execute" assert properties["approval_type"] == "always" assert properties["agent_profile_name"] == "default" + assert properties["model"] == "mistral-large" assert properties["nb_files_created"] == 0 assert properties["nb_files_modified"] == 0 @@ -161,6 +163,7 @@ class TestTelemetryClient: status="success", decision=None, agent_profile_name="default", + model="mistral-large", result={"file_existed": False}, ) @@ -179,6 +182,7 @@ class TestTelemetryClient: status="success", decision=None, agent_profile_name="default", + model="mistral-large", result={"file_existed": True}, ) @@ -197,6 +201,7 @@ class TestTelemetryClient: status="skipped", decision=None, agent_profile_name="default", + model="mistral-large", ) assert telemetry_events[0]["properties"]["decision"] is None @@ -384,6 +389,27 @@ class TestTelemetryClient: calls[1].kwargs["json"]["properties"]["session_id"] == "second-session-id" ) + def test_send_request_sent_payload( + self, telemetry_events: list[dict[str, Any]] + ) -> None: + config = build_test_vibe_config(enable_telemetry=True) + client = TelemetryClient(config_getter=lambda: config) + + client.send_request_sent( + model="codestral", + nb_context_chars=1234, + nb_context_messages=5, + nb_prompt_chars=42, + ) + + assert len(telemetry_events) == 1 + assert telemetry_events[0]["event_name"] == "vibe.request_sent" + properties = telemetry_events[0]["properties"] + assert properties["model"] == "codestral" + assert properties["nb_context_chars"] == 1234 + assert properties["nb_context_messages"] == 5 + assert properties["nb_prompt_chars"] == 42 + def test_send_user_rating_feedback_payload( self, telemetry_events: list[dict[str, Any]] ) -> None: diff --git a/tests/skills/test_builtin_sync.py b/tests/skills/test_builtin_sync.py new file mode 100644 index 0000000..3031552 --- /dev/null +++ b/tests/skills/test_builtin_sync.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from tests.conftest import build_test_vibe_config +from tests.skills.conftest import create_skill +from vibe.core.skills.builtins import BUILTIN_SKILLS +from vibe.core.skills.manager import SkillManager + + +class TestBuiltinSkills: + def test_vibe_skill_is_registered(self) -> None: + assert "vibe" in BUILTIN_SKILLS + + def test_vibe_skill_has_no_path(self) -> None: + assert BUILTIN_SKILLS["vibe"].skill_path is None + + def test_vibe_skill_has_inline_prompt(self) -> None: + assert BUILTIN_SKILLS["vibe"].prompt + + def test_discovers_builtin_skills(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("vibe.core.skills.manager.BUILTIN_SKILLS", BUILTIN_SKILLS) + config = build_test_vibe_config( + system_prompt_id="tests", include_project_context=False + ) + manager = SkillManager(lambda: config) + + assert "vibe" in manager.available_skills + + def test_user_skill_cannot_override_builtin( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setattr("vibe.core.skills.manager.BUILTIN_SKILLS", BUILTIN_SKILLS) + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + create_skill(skills_dir, "vibe", "Custom vibe override") + + config = build_test_vibe_config( + system_prompt_id="tests", + include_project_context=False, + skill_paths=[skills_dir], + ) + manager = SkillManager(lambda: config) + + assert "vibe" in manager.available_skills + assert ( + manager.available_skills["vibe"].description + == BUILTIN_SKILLS["vibe"].description + ) diff --git a/tests/skills/test_manager.py b/tests/skills/test_manager.py index a5dfea9..bafd56f 100644 --- a/tests/skills/test_manager.py +++ b/tests/skills/test_manager.py @@ -1,12 +1,14 @@ from __future__ import annotations from pathlib import Path +from typing import cast import pytest from tests.conftest import build_test_vibe_config from tests.skills.conftest import create_skill from vibe.core.config import VibeConfig +from vibe.core.skills.builtins import BUILTIN_SKILLS from vibe.core.skills.manager import SkillManager from vibe.core.trusted_folders import trusted_folders_manager @@ -24,10 +26,15 @@ def skill_manager(config: VibeConfig) -> SkillManager: class TestSkillManagerDiscovery: + def test_available_skills_is_frozen(self, skill_manager: SkillManager) -> None: + frozen_skills = cast(dict[str, object], skill_manager.available_skills) + with pytest.raises(TypeError): + frozen_skills["new-skill"] = object() + def test_discovers_no_skills_when_directory_empty( self, skill_manager: SkillManager ) -> None: - assert skill_manager.available_skills == {} + assert skill_manager.available_skills == BUILTIN_SKILLS def test_discovers_skill_from_skill_paths(self, skills_dir: Path) -> None: create_skill(skills_dir, "test-skill", "A test skill") @@ -54,7 +61,7 @@ class TestSkillManagerDiscovery: ) manager = SkillManager(lambda: config) - assert len(manager.available_skills) == 3 + assert len(manager.available_skills) == 3 + len(BUILTIN_SKILLS) assert "skill-one" in manager.available_skills assert "skill-two" in manager.available_skills assert "skill-three" in manager.available_skills @@ -76,7 +83,7 @@ class TestSkillManagerDiscovery: manager = SkillManager(lambda: config) skills = manager.available_skills - assert len(skills) == 1 + assert len(skills) == 1 + len(BUILTIN_SKILLS) assert "valid-skill" in skills assert "not-a-skill" not in skills @@ -95,7 +102,7 @@ class TestSkillManagerDiscovery: manager = SkillManager(lambda: config) skills = manager.available_skills - assert len(skills) == 1 + assert len(skills) == 1 + len(BUILTIN_SKILLS) assert "valid-skill" in skills @@ -159,7 +166,7 @@ class TestSkillManagerParsing: manager = SkillManager(lambda: config) skills = manager.available_skills - assert len(skills) == 1 + assert len(skills) == 1 + len(BUILTIN_SKILLS) assert "valid-skill" in skills assert "invalid-skill" not in skills @@ -180,7 +187,7 @@ class TestSkillManagerParsing: manager = SkillManager(lambda: config) skills = manager.available_skills - assert len(skills) == 1 + assert len(skills) == 1 + len(BUILTIN_SKILLS) assert "valid-skill" in skills @@ -239,9 +246,10 @@ class TestSkillManagerSearchPaths: ) manager = SkillManager(lambda: config) - assert len(manager.available_skills) == 2 - assert manager.available_skills["vibe-only"].description == "From .vibe" - assert manager.available_skills["agents-only"].description == "From .agents" + skills = manager.available_skills + assert len(skills) == 2 + len(BUILTIN_SKILLS) + assert skills["vibe-only"].description == "From .vibe" + assert skills["agents-only"].description == "From .agents" def test_first_discovered_wins_when_same_skill_in_vibe_and_agents( self, tmp_working_directory: Path @@ -259,10 +267,9 @@ class TestSkillManagerSearchPaths: ) manager = SkillManager(lambda: config) - assert len(manager.available_skills) == 1 - assert ( - manager.available_skills["shared-skill"].description == "First from .vibe" - ) + skills = manager.available_skills + assert len(skills) == 1 + len(BUILTIN_SKILLS) + assert skills["shared-skill"].description == "First from .vibe" def test_discovers_from_multiple_skill_paths(self, tmp_path: Path) -> None: # Create two separate skill directories @@ -282,7 +289,7 @@ class TestSkillManagerSearchPaths: manager = SkillManager(lambda: config) skills = manager.available_skills - assert len(skills) == 2 + assert len(skills) == 2 + len(BUILTIN_SKILLS) assert "skill-from-dir1" in skills assert "skill-from-dir2" in skills @@ -304,7 +311,7 @@ class TestSkillManagerSearchPaths: manager = SkillManager(lambda: config) skills = manager.available_skills - assert len(skills) == 1 + assert len(skills) == 1 + len(BUILTIN_SKILLS) assert skills["duplicate-skill"].description == "First version" def test_ignores_nonexistent_skill_paths(self, tmp_path: Path) -> None: @@ -319,8 +326,9 @@ class TestSkillManagerSearchPaths: ) manager = SkillManager(lambda: config) - assert len(manager.available_skills) == 1 - assert "valid-skill" in manager.available_skills + skills = manager.available_skills + assert len(skills) == 1 + len(BUILTIN_SKILLS) + assert "valid-skill" in skills class TestSkillManagerGetSkill: @@ -376,7 +384,7 @@ class TestSkillManagerFiltering: manager = SkillManager(lambda: config) skills = manager.available_skills - assert len(skills) == 2 + assert len(skills) == 2 + len(BUILTIN_SKILLS) assert "skill-a" in skills assert "skill-b" not in skills assert "skill-c" in skills @@ -514,7 +522,7 @@ class TestSkillUserInvocable: manager = SkillManager(lambda: config) skills = manager.available_skills - assert len(skills) == 3 + assert len(skills) == 3 + len(BUILTIN_SKILLS) assert skills["visible-skill"].user_invocable is True assert skills["hidden-skill"].user_invocable is False assert skills["default-skill"].user_invocable is True @@ -561,16 +569,6 @@ class TestParseSkillCommand: assert parsed is not None assert parsed.name == "my-skill" - def test_raises_on_unreadable_skill( - self, skills_dir: Path, skill_config: VibeConfig - ) -> None: - create_skill(skills_dir, "bad-skill", body="content") - manager = SkillManager(lambda: skill_config) - (skills_dir / "bad-skill" / "SKILL.md").unlink() - - with pytest.raises(OSError): - manager.parse_skill_command("/bad-skill") - class TestBuildSkillPrompt: def test_without_args(self, skills_dir: Path, skill_config: VibeConfig) -> None: diff --git a/tests/skills/test_models.py b/tests/skills/test_models.py index 8a451ef..e8731c2 100644 --- a/tests/skills/test_models.py +++ b/tests/skills/test_models.py @@ -127,13 +127,14 @@ class TestSkillInfo: meta = SkillMetadata( name="test-skill", description="A test skill", license="MIT" ) - info = SkillInfo.from_metadata(meta, skill_path) + info = SkillInfo.from_metadata(meta, skill_path, prompt="Skill body") + skill_dir = info.skill_dir assert info.name == "test-skill" assert info.description == "A test skill" assert info.license == "MIT" assert info.skill_path == skill_path.resolve() - assert info.skill_dir == skill_path.parent.resolve() + assert skill_dir == skill_path.parent.resolve() def test_creates_with_all_fields(self, tmp_path: Path) -> None: skill_path = tmp_path / "full-skill" / "SKILL.md" @@ -149,6 +150,7 @@ class TestSkillInfo: allowed_tools=["bash"], user_invocable=False, skill_path=skill_path, + prompt="Skill body", ) assert info.name == "full-skill" @@ -167,9 +169,11 @@ class TestSkillInfo: skill_path.touch() meta = SkillMetadata(name="test-skill", description="A test skill") - info = SkillInfo.from_metadata(meta, skill_path) + info = SkillInfo.from_metadata(meta, skill_path, prompt="Skill body") + assert info.skill_path is not None assert info.skill_path.is_absolute() + assert info.skill_dir is not None assert info.skill_dir.is_absolute() def test_inherits_all_metadata_fields(self, tmp_path: Path) -> None: @@ -186,10 +190,18 @@ class TestSkillInfo: allowed_tools=["bash", "grep"], user_invocable=False, ) - info = SkillInfo.from_metadata(meta, skill_path) + info = SkillInfo.from_metadata(meta, skill_path, prompt="Skill body") assert info.license == meta.license assert info.compatibility == meta.compatibility assert info.metadata == meta.metadata assert info.allowed_tools == meta.allowed_tools assert info.user_invocable == meta.user_invocable + + def test_can_omit_skill_path_for_builtin_inline_prompt(self) -> None: + info = SkillInfo( + name="vibe", description="Builtin skill", prompt="Inline prompt" + ) + + assert info.skill_path is None + assert info.skill_dir is None diff --git a/tests/skills/test_parser.py b/tests/skills/test_parser.py index 5138771..69508a3 100644 --- a/tests/skills/test_parser.py +++ b/tests/skills/test_parser.py @@ -2,10 +2,10 @@ from __future__ import annotations import pytest -from vibe.core.skills.parser import SkillParseError, parse_frontmatter +from vibe.core.skills.parser import SkillParseError, parse_skill_markdown -class TestParseFrontmatter: +class TestParseSkillMarkdown: def test_parses_valid_frontmatter(self) -> None: content = """--- name: test-skill @@ -14,7 +14,7 @@ description: A test skill ## Body content here """ - frontmatter, body = parse_frontmatter(content) + frontmatter, body = parse_skill_markdown(content) assert frontmatter["name"] == "test-skill" assert frontmatter["description"] == "A test skill" @@ -34,7 +34,7 @@ allowed-tools: bash read_file Instructions here. """ - frontmatter, body = parse_frontmatter(content) + frontmatter, body = parse_skill_markdown(content) assert frontmatter["name"] == "full-skill" assert frontmatter["description"] == "A skill with all fields" @@ -49,7 +49,7 @@ Instructions here. content = "Just markdown content without frontmatter" with pytest.raises(SkillParseError) as exc_info: - parse_frontmatter(content) + parse_skill_markdown(content) assert "Missing or invalid YAML frontmatter" in str(exc_info.value) @@ -60,7 +60,7 @@ description: Missing closing delimiter """ with pytest.raises(SkillParseError) as exc_info: - parse_frontmatter(content) + parse_skill_markdown(content) assert "Missing or invalid YAML frontmatter" in str(exc_info.value) @@ -74,7 +74,7 @@ Body here. """ with pytest.raises(SkillParseError) as exc_info: - parse_frontmatter(content) + parse_skill_markdown(content) assert "Invalid YAML frontmatter" in str(exc_info.value) @@ -88,7 +88,7 @@ Body here. """ with pytest.raises(SkillParseError) as exc_info: - parse_frontmatter(content) + parse_skill_markdown(content) assert "must be a mapping" in str(exc_info.value) @@ -98,7 +98,7 @@ Body here. Body content. """ - frontmatter, body = parse_frontmatter(content) + frontmatter, body = parse_skill_markdown(content) assert frontmatter == {} assert "Body content." in body @@ -109,7 +109,7 @@ name: minimal description: No body --- """ - frontmatter, body = parse_frontmatter(content) + frontmatter, body = parse_skill_markdown(content) assert frontmatter["name"] == "minimal" assert body.strip() == "" diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg index f389abc..6c8dfdf 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg @@ -19,187 +19,187 @@ font-weight: 700; } - .terminal-matrix { + .terminal-4926488215-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-4926488215-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } -.terminal-r6 { fill: #608ab1;font-weight: bold } -.terminal-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-r8 { fill: #9cafbd;font-weight: bold } -.terminal-r9 { fill: #868887 } + .terminal-4926488215-r1 { fill: #c5c8c6 } +.terminal-4926488215-r2 { fill: #ff8205;font-weight: bold } +.terminal-4926488215-r3 { fill: #68a0b3 } +.terminal-4926488215-r4 { fill: #ff8205 } +.terminal-4926488215-r5 { fill: #9a9b99 } +.terminal-4926488215-r6 { fill: #608ab1;font-weight: bold } +.terminal-4926488215-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-4926488215-r8 { fill: #9cafbd;font-weight: bold } +.terminal-4926488215-r9 { fill: #868887 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp filesystem -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool - -↑↓ Navigate  Enter Show tools  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp filesystem +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool + +↑↓ Navigate  Enter Show tools  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg index 3fd3bce..edf910a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg @@ -19,187 +19,187 @@ font-weight: 700; } - .terminal-matrix { + .terminal-2811199383-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-2811199383-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } -.terminal-r6 { fill: #608ab1;font-weight: bold } -.terminal-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-r8 { fill: #9cafbd;font-weight: bold } -.terminal-r9 { fill: #868887 } + .terminal-2811199383-r1 { fill: #c5c8c6 } +.terminal-2811199383-r2 { fill: #ff8205;font-weight: bold } +.terminal-2811199383-r3 { fill: #68a0b3 } +.terminal-2811199383-r4 { fill: #ff8205 } +.terminal-2811199383-r5 { fill: #9a9b99 } +.terminal-2811199383-r6 { fill: #608ab1;font-weight: bold } +.terminal-2811199383-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-2811199383-r8 { fill: #9cafbd;font-weight: bold } +.terminal-2811199383-r9 { fill: #868887 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithBrokenMcpServer + SnapshotTestAppWithBrokenMcpServer - + - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem     [stdio]  1 tool -  broken-server  [stdio]  no tools -  search         [http]   1 tool - -↑↓ Navigate  Enter Show tools  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem     [stdio]  1 tool +  broken-server  [stdio]  no tools +  search         [http]   1 tool + +↑↓ Navigate  Enter Show tools  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg index ee3ceb9..cd77d5e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg @@ -19,188 +19,188 @@ font-weight: 700; } - .terminal-matrix { + .terminal-6499786123-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-6499786123-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } -.terminal-r6 { fill: #608ab1;font-weight: bold } -.terminal-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-r8 { fill: #9cafbd;font-weight: bold } -.terminal-r9 { fill: #868887 } -.terminal-r10 { fill: #98a84b } + .terminal-6499786123-r1 { fill: #c5c8c6 } +.terminal-6499786123-r2 { fill: #ff8205;font-weight: bold } +.terminal-6499786123-r3 { fill: #68a0b3 } +.terminal-6499786123-r4 { fill: #ff8205 } +.terminal-6499786123-r5 { fill: #9a9b99 } +.terminal-6499786123-r6 { fill: #608ab1;font-weight: bold } +.terminal-6499786123-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-6499786123-r8 { fill: #9cafbd;font-weight: bold } +.terminal-6499786123-r9 { fill: #868887 } +.terminal-6499786123-r10 { fill: #98a84b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithConnectors + SnapshotTestAppWithConnectors - + - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers & Connectors - -Local MCP Servers -  filesystem  [stdio]  1 tool - -Workspace Connectors -  gmail  [connector]  3 tools connected -  slack  [connector]  2 tools connected - -↑↓ Navigate  Enter Show tools  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Local MCP Servers +  filesystem  [stdio]  1 tool + +Workspace Connectors +  gmail  [connector]  3 tools connected +  slack  [connector]  2 tools connected + +↑↓ Navigate  Enter Show tools  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg index 9384cec..f2a1d97 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg @@ -19,189 +19,189 @@ font-weight: 700; } - .terminal-matrix { + .terminal-4242019355-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-4242019355-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } -.terminal-r6 { fill: #608ab1;font-weight: bold } -.terminal-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-r8 { fill: #9cafbd;font-weight: bold } -.terminal-r9 { fill: #98a84b;font-weight: bold } -.terminal-r10 { fill: #868887 } -.terminal-r11 { fill: #98a84b } + .terminal-4242019355-r1 { fill: #c5c8c6 } +.terminal-4242019355-r2 { fill: #ff8205;font-weight: bold } +.terminal-4242019355-r3 { fill: #68a0b3 } +.terminal-4242019355-r4 { fill: #ff8205 } +.terminal-4242019355-r5 { fill: #9a9b99 } +.terminal-4242019355-r6 { fill: #608ab1;font-weight: bold } +.terminal-4242019355-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-4242019355-r8 { fill: #9cafbd;font-weight: bold } +.terminal-4242019355-r9 { fill: #98a84b;font-weight: bold } +.terminal-4242019355-r10 { fill: #868887 } +.terminal-4242019355-r11 { fill: #98a84b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppConnectorsOnly + SnapshotTestAppConnectorsOnly - + - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers & Connectors - -Workspace Connectors -  gmail  [connector]  3 tools connected -  slack  [connector]  2 tools connected - -↑↓ Navigate  Enter Show tools  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Workspace Connectors +  gmail  [connector]  3 tools connected +  slack  [connector]  2 tools connected + +↑↓ Navigate  Enter Show tools  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg new file mode 100644 index 0000000..eacd6d8 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg @@ -0,0 +1,206 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppConnectorsMixedState + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Workspace Connectors +  alpha  [connector]  1 tool   connected +  beta   [connector]  no tools not connected +  zeta   [connector]  no tools not connected + +↑↓ Navigate  Enter Show tools  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg index 37b0e29..760f20a 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg @@ -19,185 +19,185 @@ font-weight: 700; } - .terminal-matrix { + .terminal-1928557542-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-1928557542-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } -.terminal-r6 { fill: #608ab1;font-weight: bold } -.terminal-r7 { fill: #c5c8c6;font-weight: bold } + .terminal-1928557542-r1 { fill: #c5c8c6 } +.terminal-1928557542-r2 { fill: #ff8205;font-weight: bold } +.terminal-1928557542-r3 { fill: #68a0b3 } +.terminal-1928557542-r4 { fill: #ff8205 } +.terminal-1928557542-r5 { fill: #9a9b99 } +.terminal-1928557542-r6 { fill: #608ab1;font-weight: bold } +.terminal-1928557542-r7 { fill: #c5c8c6;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithConnectors + SnapshotTestAppWithConnectors - + - - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -Connector: slack - -search_messages  -  Search Slack messages -send_message  -  Send a Slack message - -↑↓ Navigate  Backspace Back  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +Connector: slack + +search_messages  -  Search Slack messages +send_message  -  Send a Slack message + +↑↓ Navigate  Backspace Back  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg index 93c0672..59cc3b7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg @@ -19,185 +19,185 @@ font-weight: 700; } - .terminal-matrix { + .terminal-4237726511-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-4237726511-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } -.terminal-r6 { fill: #608ab1;font-weight: bold } -.terminal-r7 { fill: #c5c8c6;font-weight: bold } + .terminal-4237726511-r1 { fill: #c5c8c6 } +.terminal-4237726511-r2 { fill: #ff8205;font-weight: bold } +.terminal-4237726511-r3 { fill: #68a0b3 } +.terminal-4237726511-r4 { fill: #ff8205 } +.terminal-4237726511-r5 { fill: #9a9b99 } +.terminal-4237726511-r6 { fill: #608ab1;font-weight: bold } +.terminal-4237726511-r7 { fill: #c5c8c6;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Server: filesystem - -fake_tool  -  A fake tool for filesystem - -↑↓ Navigate  Backspace Back  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Server: filesystem + +fake_tool  -  A fake tool for filesystem + +↑↓ Navigate  Backspace Back  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg index 3367c44..9e3b2e7 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_escape_closes.svg @@ -19,183 +19,183 @@ font-weight: 700; } - .terminal-matrix { + .terminal-7514237046-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-7514237046-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } + .terminal-7514237046-r1 { fill: #c5c8c6 } +.terminal-7514237046-r2 { fill: #ff8205;font-weight: bold } +.terminal-7514237046-r3 { fill: #68a0b3 } +.terminal-7514237046-r4 { fill: #ff8205 } +.terminal-7514237046-r5 { fill: #9a9b99 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... -MCP servers closed. - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ -> - - -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... +MCP servers closed. + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg index df1b902..57b68f5 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_no_servers.svg @@ -19,183 +19,183 @@ font-weight: 700; } - .terminal-matrix { + .terminal-9108451134-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-9108451134-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } + .terminal-9108451134-r1 { fill: #c5c8c6 } +.terminal-9108451134-r2 { fill: #ff8205;font-weight: bold } +.terminal-9108451134-r3 { fill: #68a0b3 } +.terminal-9108451134-r4 { fill: #ff8205 } +.terminal-9108451134-r5 { fill: #9a9b99 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppNoMcpServers + SnapshotTestAppNoMcpServers - + - - - - - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -No MCP servers configured. - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ -> - - -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +No MCP servers configured. + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─┐ +> + + +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg index a289eb1..fdee10e 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg @@ -19,187 +19,187 @@ font-weight: 700; } - .terminal-matrix { + .terminal-606925797-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-606925797-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } -.terminal-r6 { fill: #608ab1;font-weight: bold } -.terminal-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-r8 { fill: #9cafbd;font-weight: bold } -.terminal-r9 { fill: #868887 } + .terminal-606925797-r1 { fill: #c5c8c6 } +.terminal-606925797-r2 { fill: #ff8205;font-weight: bold } +.terminal-606925797-r3 { fill: #68a0b3 } +.terminal-606925797-r4 { fill: #ff8205 } +.terminal-606925797-r5 { fill: #9a9b99 } +.terminal-606925797-r6 { fill: #608ab1;font-weight: bold } +.terminal-606925797-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-606925797-r8 { fill: #9cafbd;font-weight: bold } +.terminal-606925797-r9 { fill: #868887 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool - -↑↓ Navigate  Enter Show tools  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool + +↑↓ Navigate  Enter Show tools  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg index 65bbfde..4a22e1f 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg @@ -19,187 +19,187 @@ font-weight: 700; } - .terminal-matrix { + .terminal-9601757275-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-9601757275-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } -.terminal-r6 { fill: #608ab1;font-weight: bold } -.terminal-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-r8 { fill: #868887 } -.terminal-r9 { fill: #9cafbd;font-weight: bold } + .terminal-9601757275-r1 { fill: #c5c8c6 } +.terminal-9601757275-r2 { fill: #ff8205;font-weight: bold } +.terminal-9601757275-r3 { fill: #68a0b3 } +.terminal-9601757275-r4 { fill: #ff8205 } +.terminal-9601757275-r5 { fill: #9a9b99 } +.terminal-9601757275-r6 { fill: #608ab1;font-weight: bold } +.terminal-9601757275-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-9601757275-r8 { fill: #868887 } +.terminal-9601757275-r9 { fill: #9cafbd;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers - -Local MCP Servers -  filesystem  [stdio]  1 tool -  search      [http]   1 tool - -↑↓ Navigate  Enter Show tools  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool + +↑↓ Navigate  Enter Show tools  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg new file mode 100644 index 0000000..010c423 --- /dev/null +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SnapshotTestAppWithMcpServers + + + + + + + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers + +Local MCP Servers +  filesystem  [stdio]  1 tool +  search      [http]   1 tool + +Refreshed.  ↑↓ Navigate  Enter Show tools  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens + + + diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg index 2943b02..95b8cbd 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg @@ -19,185 +19,185 @@ font-weight: 700; } - .terminal-matrix { + .terminal-5744808340-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-5744808340-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } -.terminal-r6 { fill: #608ab1;font-weight: bold } -.terminal-r7 { fill: #c5c8c6;font-weight: bold } + .terminal-5744808340-r1 { fill: #c5c8c6 } +.terminal-5744808340-r2 { fill: #ff8205;font-weight: bold } +.terminal-5744808340-r3 { fill: #68a0b3 } +.terminal-5744808340-r4 { fill: #ff8205 } +.terminal-5744808340-r5 { fill: #9a9b99 } +.terminal-5744808340-r6 { fill: #608ab1;font-weight: bold } +.terminal-5744808340-r7 { fill: #c5c8c6;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithMcpServers + SnapshotTestAppWithMcpServers - + - - - - - - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp filesystem -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Server: filesystem - -fake_tool  -  A fake tool for filesystem - -↑↓ Navigate  Backspace Back  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp filesystem +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Server: filesystem + +fake_tool  -  A fake tool for filesystem + +↑↓ Navigate  Backspace Back  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg index ee3ceb9..b7cb050 100644 --- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg +++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg @@ -19,188 +19,188 @@ font-weight: 700; } - .terminal-matrix { + .terminal-4913728344-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-title { + .terminal-4913728344-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-r1 { fill: #c5c8c6 } -.terminal-r2 { fill: #ff8205;font-weight: bold } -.terminal-r3 { fill: #68a0b3 } -.terminal-r4 { fill: #ff8205 } -.terminal-r5 { fill: #9a9b99 } -.terminal-r6 { fill: #608ab1;font-weight: bold } -.terminal-r7 { fill: #c5c8c6;font-weight: bold } -.terminal-r8 { fill: #9cafbd;font-weight: bold } -.terminal-r9 { fill: #868887 } -.terminal-r10 { fill: #98a84b } + .terminal-4913728344-r1 { fill: #c5c8c6 } +.terminal-4913728344-r2 { fill: #ff8205;font-weight: bold } +.terminal-4913728344-r3 { fill: #68a0b3 } +.terminal-4913728344-r4 { fill: #ff8205 } +.terminal-4913728344-r5 { fill: #9a9b99 } +.terminal-4913728344-r6 { fill: #608ab1;font-weight: bold } +.terminal-4913728344-r7 { fill: #c5c8c6;font-weight: bold } +.terminal-4913728344-r8 { fill: #9cafbd;font-weight: bold } +.terminal-4913728344-r9 { fill: #868887 } +.terminal-4913728344-r10 { fill: #98a84b } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SnapshotTestAppWithConnectors + SnapshotTestAppWithConnectors - + - - - - - - - - - - - - - - - -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information - - -/mcp -MCP servers opened... - - -┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ -MCP Servers & Connectors - -Local MCP Servers -  filesystem  [stdio]  1 tool - -Workspace Connectors -  gmail  [connector]  3 tools connected -  slack  [connector]  2 tools connected - -↑↓ Navigate  Enter Show tools  Esc Close -└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ -/test/workdir0% of 200k tokens + + + + + + + + + + + + + + + +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest · [Subscription] Pro + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information + + +/mcp +MCP servers opened... + + +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +MCP Servers & Connectors + +Local MCP Servers +  filesystem  [stdio]  1 tool + +Workspace Connectors +  gmail  [connector]  3 tools connected +  slack  [connector]  2 tools connected + +↑↓ Navigate  Enter Show tools  R Refresh  Esc Close +└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘ +/test/workdir0% of 200k tokens 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 6bd7a48..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 @@ -180,9 +180,9 @@ -  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 ·  - ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣Type /help for more information -  ⠉⠒⠣⠤⠵⠤⠬⠮⠆ +  ⡠⣒⠄  ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest + ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 0 MCP servers · 0 skills +  ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information What's New • Feature 1 diff --git a/tests/snapshots/test_ui_snapshot_mcp_command.py b/tests/snapshots/test_ui_snapshot_mcp_command.py index 3a432ba..e77c771 100644 --- a/tests/snapshots/test_ui_snapshot_mcp_command.py +++ b/tests/snapshots/test_ui_snapshot_mcp_command.py @@ -29,6 +29,12 @@ _FAKE_CONNECTORS = { ], } +_FAKE_CONNECTORS_MIXED_CONNECTION = { + "zeta": [], + "alpha": [RemoteTool(name="lookup", description="Lookup Alpha records")], + "beta": [], +} + class SnapshotTestAppNoMcpServers(BaseSnapshotTestApp): def __init__(self) -> None: @@ -174,6 +180,20 @@ def test_snapshot_mcp_escape_closes(snap_compare: SnapCompare) -> None: ) +def test_snapshot_mcp_refresh_shortcut(snap_compare: SnapCompare) -> None: + async def run_before(pilot: Pilot) -> None: + await _run_mcp_command(pilot, "/mcp") + await pilot.press("r") + await pilot.pause(0.2) + + with patch(_MCP_PATCH, FakeMCPRegistry): + assert snap_compare( + "test_ui_snapshot_mcp_command.py:SnapshotTestAppWithMcpServers", + terminal_size=(120, 36), + run_before=run_before, + ) + + # --------------------------------------------------------------------------- # Apps with connectors # --------------------------------------------------------------------------- @@ -203,6 +223,17 @@ class SnapshotTestAppConnectorsOnly(BaseSnapshotTestApp): self.agent_loop.tool_manager.integrate_connectors() +class SnapshotTestAppConnectorsMixedState(BaseSnapshotTestApp): + def __init__(self) -> None: + config = default_config() + config.mcp_servers = [] + super().__init__(config=config) + registry = FakeConnectorRegistry(connectors=_FAKE_CONNECTORS_MIXED_CONNECTION) + self.agent_loop.connector_registry = registry + self.agent_loop.tool_manager._connector_registry = registry + self.agent_loop.tool_manager.integrate_connectors() + + # --------------------------------------------------------------------------- # Connector snapshot tests # --------------------------------------------------------------------------- @@ -235,6 +266,19 @@ def test_snapshot_mcp_connectors_only(snap_compare: SnapCompare) -> None: ) +@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"}) +def test_snapshot_mcp_connectors_sorted_by_status(snap_compare: SnapCompare) -> None: + + async def run_before(pilot: Pilot) -> None: + await _run_mcp_command(pilot, "/mcp") + + assert snap_compare( + "test_ui_snapshot_mcp_command.py:SnapshotTestAppConnectorsMixedState", + terminal_size=(120, 36), + run_before=run_before, + ) + + @patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"}) def test_snapshot_mcp_drill_into_connector(snap_compare: SnapCompare) -> None: diff --git a/tests/stubs/fake_connector_registry.py b/tests/stubs/fake_connector_registry.py index 4a18ad9..610a87b 100644 --- a/tests/stubs/fake_connector_registry.py +++ b/tests/stubs/fake_connector_registry.py @@ -39,8 +39,14 @@ class FakeConnectorRegistry(ConnectorRegistry): self._connector_connected[alias] = bool(tool_map) def get_tools(self) -> dict[str, type[BaseTool]]: + if self._cache is None: + self._build_cache() + result: dict[str, type[BaseTool]] = {} if self._cache: for tools in self._cache.values(): result.update(tools) return result + + async def get_tools_async(self) -> dict[str, type[BaseTool]]: + return self.get_tools() diff --git a/tests/test_deferred_init.py b/tests/test_deferred_init.py index 50c97e7..a369ddf 100644 --- a/tests/test_deferred_init.py +++ b/tests/test_deferred_init.py @@ -1,53 +1,73 @@ -"""Tests for deferred initialization: _complete_init, wait_for_init, integrate_mcp idempotency.""" +"""Tests for deferred initialization: _complete_init, _wait_for_init, integrate_mcp idempotency.""" from __future__ import annotations import threading -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from tests.conftest import build_test_agent_loop, build_test_vibe_config +from tests.mock.utils import mock_llm_chunk +from tests.stubs.fake_backend import FakeBackend +from tests.stubs.fake_connector_registry import FakeConnectorRegistry from tests.stubs.fake_mcp_registry import FakeMCPRegistry +from vibe.core.agent_loop import AgentLoop from vibe.core.config import MCPStdio from vibe.core.tools.manager import ToolManager +from vibe.core.tools.mcp.tools import RemoteTool + + +def _build_uninitiated_loop(**kwargs): + """Build a test loop with defer_heavy_init=True but without auto-starting the init thread.""" + with patch.object(AgentLoop, "_start_deferred_init"): + return build_test_agent_loop(defer_heavy_init=True, **kwargs) + # --------------------------------------------------------------------------- # _complete_init # --------------------------------------------------------------------------- +def _run_init(loop: AgentLoop) -> None: + """Run _complete_init in a thread (matching production behavior) and wait.""" + thread = threading.Thread(target=loop._complete_init, daemon=True) + loop._deferred_init_thread = thread + thread.start() + thread.join() + + class TestCompleteInit: def test_success_sets_init_complete(self) -> None: - loop = build_test_agent_loop(defer_heavy_init=True) + loop = _build_uninitiated_loop() assert not loop.is_initialized - loop._complete_init() + _run_init(loop) assert loop.is_initialized assert loop._init_error is None def test_failure_sets_init_complete_and_stores_error(self) -> None: - loop = build_test_agent_loop(defer_heavy_init=True) + loop = _build_uninitiated_loop() error = RuntimeError("mcp boom") with patch.object(loop.tool_manager, "integrate_all", side_effect=error): - loop._complete_init() + _run_init(loop) assert loop.is_initialized assert loop._init_error is error - def test_mcp_integration_internal_failure_sets_init_error(self) -> None: + def test_mcp_failure_sets_init_error(self) -> None: mcp_server = MCPStdio(name="test-server", transport="stdio", command="echo") config = build_test_vibe_config(mcp_servers=[mcp_server]) - loop = build_test_agent_loop(config=config, defer_heavy_init=True) + loop = _build_uninitiated_loop(config=config) with patch.object( loop.tool_manager._mcp_registry, "get_tools_async", side_effect=RuntimeError("mcp discovery boom"), ): - loop._complete_init() + _run_init(loop) assert loop.is_initialized assert isinstance(loop._init_error, RuntimeError) @@ -55,7 +75,7 @@ class TestCompleteInit: # --------------------------------------------------------------------------- -# wait_for_init +# wait_until_ready # --------------------------------------------------------------------------- @@ -63,46 +83,43 @@ class TestWaitForInit: @pytest.mark.asyncio async def test_returns_immediately_when_already_complete(self) -> None: loop = build_test_agent_loop(defer_heavy_init=True) - loop._complete_init() - await loop.wait_for_init() # should not block + await loop.wait_until_ready() # should not block + + assert loop.is_initialized @pytest.mark.asyncio async def test_waits_for_background_thread(self) -> None: loop = build_test_agent_loop(defer_heavy_init=True) - thread = threading.Thread(target=loop._complete_init, daemon=True) - thread.start() - - await loop.wait_for_init() - thread.join(timeout=1) + await loop.wait_until_ready() assert loop.is_initialized @pytest.mark.asyncio async def test_raises_stored_error(self) -> None: - loop = build_test_agent_loop(defer_heavy_init=True) + loop = _build_uninitiated_loop() error = RuntimeError("init failed") with patch.object(loop.tool_manager, "integrate_all", side_effect=error): loop._complete_init() with pytest.raises(RuntimeError, match="init failed"): - await loop.wait_for_init() + await loop.wait_until_ready() @pytest.mark.asyncio async def test_raises_error_for_every_caller(self) -> None: - loop = build_test_agent_loop(defer_heavy_init=True) + loop = _build_uninitiated_loop() error = RuntimeError("once only") with patch.object(loop.tool_manager, "integrate_all", side_effect=error): loop._complete_init() with pytest.raises(RuntimeError): - await loop.wait_for_init() + await loop.wait_until_ready() with pytest.raises(RuntimeError): - await loop.wait_for_init() + await loop.wait_until_ready() # --------------------------------------------------------------------------- @@ -136,3 +153,106 @@ class TestIntegrateMcpIdempotency: # No servers means the method returns early without setting the flag, # so a future call with servers would still run discovery. assert not manager._mcp_integrated + + +class TestRefreshRemoteTools: + @pytest.mark.asyncio + async def test_refresh_rediscovers_mcp_and_connector_tools(self) -> None: + mcp_server = MCPStdio(name="srv", transport="stdio", command="echo") + config = build_test_vibe_config(mcp_servers=[mcp_server]) + registry = FakeMCPRegistry() + registry.get_tools_async = AsyncMock(wraps=registry.get_tools_async) + connector_registry = FakeConnectorRegistry({ + "alpha": [RemoteTool(name="search", description="Search alpha")] + }) + manager = ToolManager( + lambda: config, + mcp_registry=registry, + connector_registry=connector_registry, + defer_mcp=True, + ) + + await manager.refresh_remote_tools_async() + + assert "srv_fake_tool" in manager.registered_tools + assert "connector_alpha_search" in manager.registered_tools + + connector_registry._fake_connectors = { + "beta": [RemoteTool(name="list", description="List beta")] + } + + await manager.refresh_remote_tools_async() + + assert registry.get_tools_async.await_count == 2 + assert "srv_fake_tool" in manager.registered_tools + assert "connector_alpha_search" not in manager.registered_tools + assert "connector_beta_list" in manager.registered_tools + + +class TestDeferredInitPublicMethods: + @pytest.mark.asyncio + async def test_act_waits_for_deferred_init(self) -> None: + loop = build_test_agent_loop( + defer_heavy_init=True, backend=FakeBackend(mock_llm_chunk(content="hello")) + ) + + events = [event async for event in loop.act("Hello")] + + assert loop.is_initialized + assert [event.content for event in events if hasattr(event, "content")][ + -1 + ] == "hello" + + @pytest.mark.asyncio + async def test_reload_with_initial_messages_waits_for_deferred_init(self) -> None: + loop = build_test_agent_loop(defer_heavy_init=True) + + await loop.reload_with_initial_messages() + + assert loop.is_initialized + + @pytest.mark.asyncio + async def test_switch_agent_waits_for_deferred_init(self) -> None: + loop = build_test_agent_loop(defer_heavy_init=True) + + await loop.switch_agent("plan") + + assert loop.is_initialized + assert loop.agent_profile.name == "plan" + + @pytest.mark.asyncio + async def test_clear_history_waits_for_deferred_init(self) -> None: + loop = build_test_agent_loop( + defer_heavy_init=True, backend=FakeBackend(mock_llm_chunk(content="hello")) + ) + [_ async for _ in loop.act("Hello")] + + await loop.clear_history() + + assert loop.is_initialized + assert len(loop.messages) == 1 + + @pytest.mark.asyncio + async def test_compact_waits_for_deferred_init(self) -> None: + loop = build_test_agent_loop( + defer_heavy_init=True, + backend=FakeBackend([ + [mock_llm_chunk(content="hello")], + [mock_llm_chunk(content="summary")], + ]), + ) + [_ async for _ in loop.act("Hello")] + + summary = await loop.compact() + + assert loop.is_initialized + assert summary == "summary" + + @pytest.mark.asyncio + async def test_inject_user_context_waits_for_deferred_init(self) -> None: + loop = build_test_agent_loop(defer_heavy_init=True) + + await loop.inject_user_context("context") + + assert loop.is_initialized + assert loop.messages[-1].content == "context" diff --git a/tests/tools/test_mcp.py b/tests/tools/test_mcp.py index 74e48b0..ebe690d 100644 --- a/tests/tools/test_mcp.py +++ b/tests/tools/test_mcp.py @@ -4,7 +4,8 @@ import logging import os import threading import time -from unittest.mock import MagicMock, patch +from typing import Any, cast +from unittest.mock import AsyncMock, MagicMock, patch from pydantic import ValidationError import pytest @@ -17,8 +18,10 @@ from vibe.core.tools.mcp import ( _mcp_stderr_capture, _parse_call_result, _stderr_logger_thread, + call_tool_stdio, create_mcp_http_proxy_tool_class, create_mcp_stdio_proxy_tool_class, + list_tools_stdio, ) @@ -566,3 +569,144 @@ class TestMCPRegistry: assert "cached_ct" in tools assert "new_nt" in tools assert len(registry._cache) == 2 + + +class TestMCPStdioCwd: + def test_mcp_stdio_cwd_defaults_to_none(self): + config = MCPStdio(name="test", transport="stdio", command="python -m srv") + + assert config.cwd is None + + def test_mcp_stdio_cwd_accepts_string(self): + config = MCPStdio( + name="test", + transport="stdio", + command="python -m srv", + cwd="/tmp/myproject", + ) + + assert config.cwd == "/tmp/myproject" + + @pytest.mark.asyncio + async def test_list_tools_stdio_passes_cwd_to_params(self): + with ( + patch("vibe.core.tools.mcp.tools.stdio_client") as mock_client, + patch("vibe.core.tools.mcp.tools.ClientSession") as mock_session_cls, + patch("vibe.core.tools.mcp.tools.StdioServerParameters") as mock_params_cls, + ): + mock_client.return_value.__aenter__ = AsyncMock( + return_value=(MagicMock(), MagicMock()) + ) + mock_client.return_value.__aexit__ = AsyncMock(return_value=False) + mock_session = MagicMock() + mock_session.initialize = AsyncMock() + mock_session.list_tools = AsyncMock(return_value=MagicMock(tools=[])) + mock_session_cls.return_value.__aenter__ = AsyncMock( + return_value=mock_session + ) + mock_session_cls.return_value.__aexit__ = AsyncMock(return_value=False) + + await list_tools_stdio(["python", "-m", "srv"], cwd="/tmp/myproject") + + mock_params_cls.assert_called_once_with( + command="python", args=["-m", "srv"], env=None, cwd="/tmp/myproject" + ) + + @pytest.mark.asyncio + async def test_call_tool_stdio_passes_cwd_to_params(self): + with ( + patch("vibe.core.tools.mcp.tools.stdio_client") as mock_client, + patch("vibe.core.tools.mcp.tools.ClientSession") as mock_session_cls, + patch("vibe.core.tools.mcp.tools.StdioServerParameters") as mock_params_cls, + patch("vibe.core.tools.mcp.tools._parse_call_result") as mock_parse, + ): + mock_client.return_value.__aenter__ = AsyncMock( + return_value=(MagicMock(), MagicMock()) + ) + mock_client.return_value.__aexit__ = AsyncMock(return_value=False) + mock_session = MagicMock() + mock_session.initialize = AsyncMock() + mock_session.call_tool = AsyncMock(return_value=MagicMock()) + mock_session_cls.return_value.__aenter__ = AsyncMock( + return_value=mock_session + ) + mock_session_cls.return_value.__aexit__ = AsyncMock(return_value=False) + mock_parse.return_value = MagicMock(spec=MCPToolResult) + + await call_tool_stdio( + ["python", "-m", "srv"], "my_tool", {}, cwd="/tmp/myproject" + ) + + mock_params_cls.assert_called_once_with( + command="python", args=["-m", "srv"], env=None, cwd="/tmp/myproject" + ) + + @pytest.mark.asyncio + async def test_discover_stdio_passes_cwd_to_list_tools(self): + registry = MCPRegistry() + srv = MCPStdio( + name="local", + transport="stdio", + command="python -m srv", + cwd="/tmp/myproject", + ) + remote = RemoteTool(name="run", description="Run it") + + with patch( + "vibe.core.tools.mcp.registry.list_tools_stdio", return_value=[remote] + ) as mock_list: + await registry._discover_stdio(srv) + + mock_list.assert_called_once_with( + ["python", "-m", "srv"], + env=None, + cwd="/tmp/myproject", + startup_timeout_sec=srv.startup_timeout_sec, + ) + + @pytest.mark.asyncio + async def test_discover_stdio_passes_cwd_to_proxy_class(self): + registry = MCPRegistry() + srv = MCPStdio( + name="local", + transport="stdio", + command="python -m srv", + cwd="/tmp/myproject", + ) + remote = RemoteTool(name="run", description="Run it") + + with ( + patch( + "vibe.core.tools.mcp.registry.list_tools_stdio", return_value=[remote] + ), + patch( + "vibe.core.tools.mcp.registry.create_mcp_stdio_proxy_tool_class", + wraps=create_mcp_stdio_proxy_tool_class, + ) as mock_create, + ): + await registry._discover_stdio(srv) + + _, kwargs = mock_create.call_args + assert kwargs["cwd"] == "/tmp/myproject" + + def test_proxy_tool_stores_cwd(self): + remote = RemoteTool(name="run") + proxy_cls = cast( + Any, + create_mcp_stdio_proxy_tool_class( + command=["python", "-m", "srv"], remote=remote, cwd="/tmp/myproject" + ), + ) + + assert proxy_cls._cwd == "/tmp/myproject" + + def test_proxy_tool_cwd_defaults_to_none(self): + remote = RemoteTool(name="run") + proxy_cls = cast( + Any, + create_mcp_stdio_proxy_tool_class( + command=["python", "-m", "srv"], remote=remote + ), + ) + + assert proxy_cls._cwd is None diff --git a/tests/tools/test_skill.py b/tests/tools/test_skill.py index fa368c2..714946f 100644 --- a/tests/tools/test_skill.py +++ b/tests/tools/test_skill.py @@ -8,14 +8,13 @@ import pytest from tests.mock.utils import collect_result from vibe.core.skills.manager import SkillManager from vibe.core.skills.models import SkillInfo -from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError +from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError, ToolPermission from vibe.core.tools.builtins.skill import ( Skill, SkillArgs, SkillResult, SkillToolConfig, ) -from vibe.core.tools.permissions import PermissionScope def _make_skill_dir( @@ -37,7 +36,10 @@ def _make_skill_dir( file_path.write_text(f"content of {f}", encoding="utf-8") return SkillInfo( - name=name, description=description, skill_path=skill_dir / "SKILL.md" + name=name, + description=description, + skill_path=skill_dir / "SKILL.md", + prompt=body, ) @@ -83,11 +85,13 @@ class TestSkillRun: ctx = _make_ctx(manager) result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx)) + skill_dir = info.skill_dir + assert skill_dir is not None assert "" in result.content assert "scripts/run.sh" in result.content assert "references/guide.md" in result.content - assert f"{info.skill_dir / 'scripts/run.sh'}" not in result.content + assert f"{skill_dir / 'scripts/run.sh'}" not in result.content @pytest.mark.asyncio async def test_excludes_skill_md_from_file_list( @@ -137,8 +141,10 @@ class TestSkillRun: ctx = _make_ctx(manager) result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx)) + skill_dir = info.skill_dir + assert skill_dir is not None - assert result.skill_dir == str(info.skill_dir) + assert result.skill_dir == str(skill_dir) @pytest.mark.asyncio async def test_includes_base_directory( @@ -149,8 +155,30 @@ class TestSkillRun: ctx = _make_ctx(manager) result = await collect_result(skill_tool.run(SkillArgs(name="my-skill"), ctx)) + skill_dir = info.skill_dir + assert skill_dir is not None - assert f"Base directory for this skill: {info.skill_dir}" in result.content + assert f"Base directory for this skill: {skill_dir}" in result.content + + @pytest.mark.asyncio + async def test_uses_in_memory_prompt_when_available( + self, skill_tool: Skill + ) -> None: + info = SkillInfo( + name="inline-skill", + description="Inline prompt skill", + prompt="Inline instructions from Python object.", + ) + manager = _make_skill_manager({"inline-skill": info}) + ctx = _make_ctx(manager) + + result = await collect_result( + skill_tool.run(SkillArgs(name="inline-skill"), ctx) + ) + + assert "Inline instructions from Python object." in result.content + assert "Base directory for this skill:" not in result.content + assert result.skill_dir is None class TestSkillErrors: @@ -182,30 +210,35 @@ class TestSkillErrors: await collect_result(skill_tool.run(SkillArgs(name="missing"), ctx=ctx)) @pytest.mark.asyncio - async def test_unreadable_skill_file( + async def test_ignores_unreadable_file_when_prompt_is_available( self, tmp_path: Path, skill_tool: Skill ) -> None: info = SkillInfo( name="broken", description="Broken skill", skill_path=tmp_path / "nonexistent" / "SKILL.md", + prompt="Use prompt from state.", ) manager = _make_skill_manager({"broken": info}) ctx = _make_ctx(manager) - with pytest.raises(ToolError, match="Cannot load skill file"): - await collect_result(skill_tool.run(SkillArgs(name="broken"), ctx=ctx)) + result = await collect_result(skill_tool.run(SkillArgs(name="broken"), ctx=ctx)) + assert "Use prompt from state." in result.content class TestSkillPermission: - def test_resolve_permission_returns_file_pattern(self, skill_tool: Skill) -> None: + def test_resolve_permission_always_allowed(self, skill_tool: Skill) -> None: perm = skill_tool.resolve_permission(SkillArgs(name="my-skill")) assert perm is not None - assert len(perm.required_permissions) == 1 - assert perm.required_permissions[0].scope == PermissionScope.FILE_PATTERN - assert perm.required_permissions[0].invocation_pattern == "my-skill" - assert perm.required_permissions[0].session_pattern == "my-skill" + assert perm.permission == ToolPermission.ALWAYS + assert perm.required_permissions == [] + + def test_non_builtin_skill_is_still_always_allowed(self, skill_tool: Skill) -> None: + perm = skill_tool.resolve_permission(SkillArgs(name="custom-skill")) + + assert perm is not None + assert perm.permission == ToolPermission.ALWAYS class TestSkillMeta: diff --git a/uv.lock b/uv.lock index 2059750..d5baa80 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.12" [options] -exclude-newer = "2026-04-09T07:58:59.192295Z" +exclude-newer = "2026-04-14T12:03:01.278401Z" exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -808,7 +808,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.7.6" +version = "2.8.0" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, diff --git a/vibe/__init__.py b/vibe/__init__.py index 4ebcbf1..459398f 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.7.6" +__version__ = "2.8.0" diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py index eacb313..af383f1 100644 --- a/vibe/acp/acp_agent_loop.py +++ b/vibe/acp/acp_agent_loop.py @@ -295,6 +295,7 @@ class VibeAcpAgentLoop(AcpAgent): agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True, entrypoint_metadata=self._build_entrypoint_metadata(), + defer_heavy_init=True, ) agent_loop.agent_manager.register_agent(CHAT_AGENT) # NOTE: For now, we pin session.id to agent_loop.session_id right after init time. @@ -541,6 +542,7 @@ class VibeAcpAgentLoop(AcpAgent): agent_name=BuiltinAgentName.DEFAULT, enable_streaming=True, entrypoint_metadata=self._build_entrypoint_metadata(), + defer_heavy_init=True, ) agent_loop.agent_manager.register_agent(CHAT_AGENT) diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py index c436810..d601a47 100644 --- a/vibe/cli/cli.py +++ b/vibe/cli/cli.py @@ -213,8 +213,6 @@ def run_cli(args: argparse.Namespace) -> None: if loaded_session: _resume_previous_session(agent_loop, *loaded_session) - agent_loop.start_deferred_init() - run_textual_ui( agent_loop=agent_loop, startup=StartupOptions( diff --git a/vibe/cli/commands.py b/vibe/cli/commands.py index 69665bb..919c37a 100644 --- a/vibe/cli/commands.py +++ b/vibe/cli/commands.py @@ -65,11 +65,6 @@ class CommandRegistry: handler="_exit_app", exits=True, ), - "terminal-setup": Command( - aliases=frozenset(["/terminal-setup"]), - description="Configure Shift+Enter for newlines", - handler="_setup_terminal", - ), "status": Command( aliases=frozenset(["/status"]), description="Display agent statistics", @@ -91,15 +86,13 @@ class CommandRegistry: handler="_show_session_picker", ), "mcp": Command( - aliases=frozenset(["/mcp"]), - description="Display available MCP servers. Pass the name of a server to list its tools", + aliases=frozenset(["/mcp", "/connectors"]), + description=( + "Display available MCP servers and connectors. " + "Pass a name to list its tools" + ), handler="_show_mcp", ), - "connectors": Command( - aliases=frozenset(["/connectors"]), - description="Manage workspace connectors. Subcommands: refresh", - handler="_handle_connectors", - ), "voice": Command( aliases=frozenset(["/voice"]), description="Configure voice settings", diff --git a/vibe/cli/terminal_detect.py b/vibe/cli/terminal_detect.py new file mode 100644 index 0000000..cd1326e --- /dev/null +++ b/vibe/cli/terminal_detect.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from enum import Enum +import os +from typing import Literal + + +class Terminal(Enum): + VSCODE = "vscode" + VSCODE_INSIDERS = "vscode_insiders" + CURSOR = "cursor" + JETBRAINS = "jetbrains" + ITERM2 = "iterm2" + WEZTERM = "wezterm" + GHOSTTY = "ghostty" + ALACRITTY = "alacritty" + KITTY = "kitty" + HYPER = "hyper" + WINDOWS_TERMINAL = "windows_terminal" + UNKNOWN = "unknown" + + +def _is_cursor() -> bool: + path_indicators = [ + "VSCODE_GIT_ASKPASS_NODE", + "VSCODE_GIT_ASKPASS_MAIN", + "VSCODE_IPC_HOOK_CLI", + "VSCODE_NLS_CONFIG", + ] + for var in path_indicators: + val = os.environ.get(var, "").lower() + if "cursor" in val: + return True + return False + + +def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDERS]: + term_version = os.environ.get("TERM_PROGRAM_VERSION", "").lower() + if term_version.endswith("-insider"): + return Terminal.VSCODE_INSIDERS + + return Terminal.VSCODE + + +def _detect_terminal_from_env() -> Terminal | None: + env_markers: dict[str, Terminal] = { + "WEZTERM_PANE": Terminal.WEZTERM, + "GHOSTTY_RESOURCES_DIR": Terminal.GHOSTTY, + "KITTY_WINDOW_ID": Terminal.KITTY, + "ALACRITTY_SOCKET": Terminal.ALACRITTY, + "ALACRITTY_LOG": Terminal.ALACRITTY, + "WT_SESSION": Terminal.WINDOWS_TERMINAL, + } + for var, terminal in env_markers.items(): + if os.environ.get(var): + return terminal + + if "jetbrains" in os.environ.get("TERMINAL_EMULATOR", "").lower(): + return Terminal.JETBRAINS + + return None + + +def detect_terminal() -> Terminal: + term_program = os.environ.get("TERM_PROGRAM", "").lower() + + if term_program == "vscode": + if _is_cursor(): + return Terminal.CURSOR + return _detect_vscode_terminal() + + term_map: dict[str, Terminal] = { + "iterm.app": Terminal.ITERM2, + "wezterm": Terminal.WEZTERM, + "ghostty": Terminal.GHOSTTY, + "alacritty": Terminal.ALACRITTY, + "kitty": Terminal.KITTY, + "hyper": Terminal.HYPER, + } + if term_program in term_map: + return term_map[term_program] + + return _detect_terminal_from_env() or Terminal.UNKNOWN diff --git a/vibe/cli/terminal_setup.py b/vibe/cli/terminal_setup.py deleted file mode 100644 index e132bcb..0000000 --- a/vibe/cli/terminal_setup.py +++ /dev/null @@ -1,338 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -import json -import os -from pathlib import Path -import platform -import subprocess -from typing import Any, Literal - -from vibe.core.utils.io import read_safe - - -class Terminal(Enum): - VSCODE = "vscode" - VSCODE_INSIDERS = "vscode_insiders" - CURSOR = "cursor" - JETBRAINS = "jetbrains" - ITERM2 = "iterm2" - WEZTERM = "wezterm" - GHOSTTY = "ghostty" - UNKNOWN = "unknown" - - -@dataclass -class SetupResult: - success: bool - terminal: Terminal - message: str - requires_restart: bool = False - - -def _is_cursor() -> bool: - path_indicators = [ - "VSCODE_GIT_ASKPASS_NODE", - "VSCODE_GIT_ASKPASS_MAIN", - "VSCODE_IPC_HOOK_CLI", - "VSCODE_NLS_CONFIG", - ] - for var in path_indicators: - val = os.environ.get(var, "").lower() - if "cursor" in val: - return True - return False - - -def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDERS]: - term_version = os.environ.get("TERM_PROGRAM_VERSION", "").lower() - if term_version.endswith("-insider"): - return Terminal.VSCODE_INSIDERS - - return Terminal.VSCODE - - -def _detect_terminal_from_env() -> Terminal | None: - if os.environ.get("WEZTERM_PANE"): - return Terminal.WEZTERM - if os.environ.get("GHOSTTY_RESOURCES_DIR"): - return Terminal.GHOSTTY - if "jetbrains" in os.environ.get("TERMINAL_EMULATOR", "").lower(): - return Terminal.JETBRAINS - return None - - -def detect_terminal() -> Terminal: - term_program = os.environ.get("TERM_PROGRAM", "").lower() - - if term_program == "vscode": - if _is_cursor(): - return Terminal.CURSOR - return _detect_vscode_terminal() - - term_map = { - "iterm.app": Terminal.ITERM2, - "wezterm": Terminal.WEZTERM, - "ghostty": Terminal.GHOSTTY, - } - if term_program in term_map: - return term_map[term_program] - - return _detect_terminal_from_env() or Terminal.UNKNOWN - - -def _get_vscode_keybindings_path(is_stable: bool) -> Path | None: - system = platform.system() - - app_name = "Code" if is_stable else "Code - Insiders" - - if system == "Darwin": - base = Path.home() / "Library" / "Application Support" / app_name / "User" - elif system == "Linux": - base = Path.home() / ".config" / app_name / "User" - elif system == "Windows": - appdata = os.environ.get("APPDATA", "") - if appdata: - base = Path(appdata) / app_name / "User" - else: - return None - else: - return None - - return base / "keybindings.json" - - -def _get_cursor_keybindings_path() -> Path | None: - system = platform.system() - - if system == "Darwin": - base = Path.home() / "Library" / "Application Support" / "Cursor" / "User" - elif system == "Linux": - base = Path.home() / ".config" / "Cursor" / "User" - elif system == "Windows": - appdata = os.environ.get("APPDATA", "") - if appdata: - base = Path(appdata) / "Cursor" / "User" - else: - return None - else: - return None - - return base / "keybindings.json" - - -def _parse_keybindings(content: str) -> list[dict[str, Any]]: - content = content.strip() - if not content or content.startswith("//"): - return [] - - lines = [line for line in content.split("\n") if not line.strip().startswith("//")] - clean_content = "\n".join(lines) - - try: - return json.loads(clean_content) - except json.JSONDecodeError: - return [] - - -def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult: - """Setup keybindings for VS Code or Cursor.""" - if terminal == Terminal.CURSOR: - keybindings_path = _get_cursor_keybindings_path() - editor_name = "Cursor" - else: - keybindings_path = _get_vscode_keybindings_path(terminal == Terminal.VSCODE) - editor_name = "VS Code" if terminal == Terminal.VSCODE else "VS Code Insiders" - - if keybindings_path is None: - return SetupResult( - success=False, - terminal=terminal, - message=f"Could not determine keybindings path for {editor_name}", - ) - - new_binding = { - "key": "shift+enter", - "command": "workbench.action.terminal.sendSequence", - "args": {"text": "\u001b[13;2u"}, - "when": "terminalFocus", - } - - try: - keybindings = _read_existing_keybindings(keybindings_path) - - if _has_shift_enter_binding(keybindings): - return SetupResult( - success=True, - terminal=terminal, - message=f"Shift+Enter already configured in {editor_name}", - ) - - keybindings.append(new_binding) - keybindings_path.write_text( - json.dumps(keybindings, indent=2, ensure_ascii=False) + "\n" - ) - - return SetupResult( - success=True, - terminal=terminal, - message=f"Added Shift+Enter binding to {keybindings_path}", - requires_restart=True, - ) - - except Exception as e: - return SetupResult( - success=False, - terminal=terminal, - message=f"Failed to configure {editor_name}: {e}", - ) - - -def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]: - if keybindings_path.exists(): - content = read_safe(keybindings_path).text - return _parse_keybindings(content) - keybindings_path.parent.mkdir(parents=True, exist_ok=True) - return [] - - -def _has_shift_enter_binding(keybindings: list[dict[str, Any]]) -> bool: - for binding in keybindings: - if ( - binding.get("key") == "shift+enter" - and binding.get("command") == "workbench.action.terminal.sendSequence" - and binding.get("when") == "terminalFocus" - ): - return True - return False - - -def _setup_iterm2() -> SetupResult: - if platform.system() != "Darwin": - return SetupResult( - success=False, - terminal=Terminal.ITERM2, - message="iTerm2 is only available on macOS", - ) - - plist_key = "0xd-0x20000-0x24" - plist_value = """ - Text - \\n - Action - 12 - Version - 1 - Keycode - 13 - Modifiers - 131072 -""" - - try: - result = subprocess.run( - ["defaults", "read", "com.googlecode.iterm2", "GlobalKeyMap"], - capture_output=True, - text=True, - ) - - if plist_key in result.stdout: - return SetupResult( - success=True, - terminal=Terminal.ITERM2, - message="Shift+Enter already configured in iTerm2", - ) - - subprocess.run( - [ - "defaults", - "write", - "com.googlecode.iterm2", - "GlobalKeyMap", - "-dict-add", - plist_key, - plist_value, - ], - check=True, - capture_output=True, - ) - - return SetupResult( - success=True, - terminal=Terminal.ITERM2, - message="Added Shift+Enter binding to iTerm2 preferences", - requires_restart=True, - ) - - except subprocess.CalledProcessError as e: - return SetupResult( - success=False, - terminal=Terminal.ITERM2, - message=f"Failed to configure iTerm2: {e.stderr}", - ) - except Exception as e: - return SetupResult( - success=False, - terminal=Terminal.ITERM2, - message=f"Failed to configure iTerm2: {e}", - ) - - -def _setup_wezterm() -> SetupResult: - return SetupResult( - success=True, - terminal=Terminal.WEZTERM, - message="Please manually add the following to your .wezterm.lua:\n" - "local wezterm = require 'wezterm'\n" - "local config = wezterm.config_builder()\n\n" - "config.keys = {\n" - " {\n" - ' key = "Enter",\n' - ' mods = "SHIFT",\n' - ' action = wezterm.action.SendString("\\x1b[13;2u"),\n' - " }\n" - "}\n\n" - "return config", - ) - - -def _setup_ghostty() -> SetupResult: - return SetupResult( - success=True, - terminal=Terminal.GHOSTTY, - message="Shift+Enter is already configured in Ghostty", - ) - - -def setup_terminal() -> SetupResult: - terminal = detect_terminal() - - match terminal: - case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR: - return _setup_vscode_like_terminal(terminal) - case Terminal.JETBRAINS: - return SetupResult( - success=False, - terminal=Terminal.JETBRAINS, - message="Jetbrains terminal is not supported.\n" - "You can manually configure Shift+Enter to send: \\x1b[13;2u", - ) - case Terminal.ITERM2: - return _setup_iterm2() - case Terminal.WEZTERM: - return _setup_wezterm() - case Terminal.GHOSTTY: - return _setup_ghostty() - case Terminal.UNKNOWN: - return SetupResult( - success=False, - terminal=Terminal.UNKNOWN, - message="Could not detect terminal. Supported terminals:\n" - "- VS Code\n" - "- Cursor\n" - "- iTerm2\n" - "- WezTerm\n" - "- Ghostty\n\n" - "You can manually configure Shift+Enter to send: \\x1b[13;2u", - ) diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 895ab39..be90905 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -41,7 +41,6 @@ from vibe.cli.plan_offer.decide_plan_offer import ( resolve_api_key_for_plan, ) from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway, WhoAmIPlanType -from vibe.cli.terminal_setup import setup_terminal from vibe.cli.textual_ui.handlers.event_handler import EventHandler from vibe.cli.textual_ui.notifications import ( NotificationContext, @@ -145,8 +144,7 @@ from vibe.core.tools.builtins.ask_user_question import ( Choice, Question, ) -from vibe.core.tools.connectors import CONNECTORS_ENV_VAR, connectors_enabled -from vibe.core.tools.mcp.tools import MCPTool +from vibe.core.tools.connectors import connectors_enabled from vibe.core.tools.permissions import RequiredPermission from vibe.core.transcribe import make_transcribe_client from vibe.core.types import ( @@ -334,8 +332,6 @@ class VibeApp(App): # noqa: PLR0904 excluded_commands = [] if not self.config.nuage_enabled: excluded_commands.append("teleport") - if not connectors_enabled(): - excluded_commands.append("connectors") self.commands = CommandRegistry(excluded_commands=excluded_commands) self._chat_input_container: ChatInputContainer | None = None @@ -478,7 +474,7 @@ class VibeApp(App): # noqa: PLR0904 if not self.agent_loop.is_initialized: await self._ensure_loading_widget("Initializing") init_widget = self._loading_widget - await self.agent_loop.wait_for_init() + await self.agent_loop.wait_until_ready() except Exception as e: await self._mount_and_scroll( ErrorMessage( @@ -788,15 +784,7 @@ class VibeApp(App): # noqa: PLR0904 if not self.agent_loop: return False - try: - skill = self.agent_loop.skill_manager.parse_skill_command(user_input) - except OSError as e: - await self._mount_and_scroll( - ErrorMessage( - f"Failed to read skill file: {e}", collapsed=self._tools_collapsed - ) - ) - return True + skill = self.agent_loop.skill_manager.parse_skill_command(user_input) if skill is None: return False @@ -1104,7 +1092,7 @@ class VibeApp(App): # noqa: PLR0904 show_init_spinner = not self.agent_loop.is_initialized if show_init_spinner: await self._ensure_loading_widget("Initializing") - await self.agent_loop.wait_for_init() + await self.agent_loop.wait_until_ready() if show_init_spinner: await self._remove_loading_widget() self._refresh_banner() @@ -1328,6 +1316,12 @@ class VibeApp(App): # noqa: PLR0904 help_text = self.commands.get_help_text() await self._mount_and_scroll(UserCommandMessage(help_text)) + async def _refresh_mcp_browser(self) -> str: + await self.agent_loop.tool_manager.refresh_remote_tools_async() + await self.agent_loop.refresh_system_prompt() + self._refresh_banner() + return "Refreshed." + async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None: mcp_servers = self.config.mcp_servers connector_registry = ( @@ -1371,51 +1365,10 @@ class VibeApp(App): # noqa: PLR0904 tool_manager=self.agent_loop.tool_manager, initial_server=name, connector_registry=connector_registry, + refresh_callback=self._refresh_mcp_browser, ) ) - async def _handle_connectors(self, cmd_args: str = "", **kwargs: Any) -> None: - if not self._connectors_enabled: - await self._mount_and_scroll( - UserCommandMessage( - f"Connectors are disabled. Set {CONNECTORS_ENV_VAR}=1 to enable." - ) - ) - return - registry = self.agent_loop.connector_registry - assert registry is not None # guaranteed by _connectors_enabled - - subcmd = cmd_args.strip().lower() - match subcmd: - case "refresh": - registry.clear() - tool_manager = self.agent_loop.tool_manager - await tool_manager.integrate_connectors_async() - self.agent_loop.refresh_system_prompt() - self._refresh_banner() - count = registry.connector_count - tools = sum( - 1 - for cls in tool_manager.registered_tools.values() - if issubclass(cls, MCPTool) and cls.is_connector() - ) - await self._mount_and_scroll( - UserCommandMessage( - f"Connectors refreshed: {count} connectors, {tools} tools" - ) - ) - case "": - await self._mount_and_scroll( - UserCommandMessage("Usage: /connectors refresh") - ) - case _: - await self._mount_and_scroll( - ErrorMessage( - f"Unknown subcommand: {subcmd}. Available: refresh", - collapsed=self._tools_collapsed, - ) - ) - async def _show_status(self, **kwargs: Any) -> None: stats = self.agent_loop.stats status_text = f"""## Agent Statistics @@ -1820,25 +1773,6 @@ class VibeApp(App): # noqa: PLR0904 await self._narrator_manager.close() self.exit(result=self._get_session_resume_info()) - async def _setup_terminal(self, **kwargs: Any) -> None: - result = setup_terminal() - - if result.success: - if result.requires_restart: - message = f"{result.message or 'Set up Shift+Enter keybind'} (You may need to restart your terminal.)" - await self._mount_and_scroll( - UserCommandMessage(f"{result.terminal.value}: {message}") - ) - else: - message = result.message or "Shift+Enter keybind already set up" - await self._mount_and_scroll( - WarningMessage(f"{result.terminal.value}: {message}") - ) - else: - await self._mount_and_scroll( - ErrorMessage(result.message, collapsed=self._tools_collapsed) - ) - def _make_default_voice_manager(self) -> VoiceManager: try: model = self.config.get_active_transcribe_model() @@ -2427,7 +2361,7 @@ class VibeApp(App): # noqa: PLR0904 self.call_after_refresh(schedule_switch) - async def action_toggle_debug_console(self) -> None: + async def action_toggle_debug_console(self, **kwargs: Any) -> None: if self._debug_console is not None: await self._debug_console.remove() self._debug_console = None diff --git a/vibe/cli/textual_ui/widgets/banner/banner.py b/vibe/cli/textual_ui/widgets/banner/banner.py index 127df0c..8094ec7 100644 --- a/vibe/cli/textual_ui/widgets/banner/banner.py +++ b/vibe/cli/textual_ui/widgets/banner/banner.py @@ -53,7 +53,7 @@ class Banner(Static): models_count=len(config.models), mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers), connectors_count=_connector_count(connector_registry), - skills_count=len(skill_manager.available_skills), + skills_count=skill_manager.custom_skills_count, plan_description=None, ) self._animated = not config.disable_welcome_banner_animation @@ -80,7 +80,7 @@ class Banner(Static): self.state = self._initial_state def watch_state(self) -> None: - if not self.is_mounted: + if not self.is_attached: return self.query_one("#banner-model", NoMarkupStatic).update(self.state.active_model) self.query_one("#banner-meta-counts", NoMarkupStatic).update( @@ -105,7 +105,7 @@ class Banner(Static): models_count=len(config.models), mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers), connectors_count=_connector_count(connector_registry), - skills_count=len(skill_manager.available_skills), + skills_count=skill_manager.custom_skills_count, plan_description=plan_description, ) diff --git a/vibe/cli/textual_ui/widgets/mcp_app.py b/vibe/cli/textual_ui/widgets/mcp_app.py index 67cd9bc..fb5190f 100644 --- a/vibe/cli/textual_ui/widgets/mcp_app.py +++ b/vibe/cli/textual_ui/widgets/mcp_app.py @@ -1,6 +1,6 @@ from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Awaitable, Callable, Sequence from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple from rich.text import Text @@ -11,6 +11,7 @@ from textual.events import DescendantBlur from textual.message import Message from textual.widgets import OptionList from textual.widgets.option_list import Option +from textual.worker import Worker from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled @@ -58,6 +59,7 @@ class MCPApp(Container): BINDINGS: ClassVar[list[BindingType]] = [ Binding("escape", "close", "Close", show=False), Binding("backspace", "back", "Back", show=False), + Binding("r", "refresh", "Refresh", show=False), ] class MCPClosed(Message): @@ -69,6 +71,7 @@ class MCPApp(Container): tool_manager: ToolManager, initial_server: str = "", connector_registry: ConnectorRegistry | None = None, + refresh_callback: Callable[[], Awaitable[str]] | None = None, ) -> None: super().__init__(id="mcp-app") self._mcp_servers = mcp_servers @@ -83,6 +86,9 @@ class MCPApp(Container): # disambiguate entries that share the same normalised name. self._viewing_server: str | None = initial_server.strip() or None self._viewing_kind: str | None = None + self._refresh_callback = refresh_callback + self._status_message: str | None = None + self._refreshing = False def compose(self) -> ComposeResult: with Vertical(id="mcp-content"): @@ -116,12 +122,44 @@ class MCPApp(Container): self._refresh_view(option_id.removeprefix("connector:"), kind="connector") def action_back(self) -> None: + if self._refreshing: + return if self._viewing_server is not None: self._refresh_view(None) def action_close(self) -> None: + if self._refreshing: + return self.post_message(self.MCPClosed()) + async def action_refresh(self) -> None: + if self._refresh_callback is None: + return + + self._status_message = "Refreshing..." + self._refresh_view(self._viewing_server, kind=self._viewing_kind) + + self._refreshing = True + self.run_worker(self._run_refresh(), exclusive=True, group="refresh") + + async def _run_refresh(self) -> str: + assert self._refresh_callback is not None + return await self._refresh_callback() + + def on_worker_state_changed(self, event: Worker.StateChanged) -> None: + if event.worker.group != "refresh": + return + if event.worker.is_finished: + self._refreshing = False + result = event.worker.result + self._status_message = result if isinstance(result, str) else "Refreshed." + self.refresh_index() + + def _set_help_text(self, text: str) -> None: + if self._status_message: + text = f"{self._status_message} {text}" + self.query_one("#mcp-help", NoMarkupStatic).update(text) + # ── list view ──────────────────────────────────────────────────── def _refresh_view( @@ -153,9 +191,7 @@ class MCPApp(Container): has_connectors = connectors_enabled() and bool(self._connector_names) title = "MCP Servers & Connectors" if has_connectors else "MCP Servers" self.query_one("#mcp-title", NoMarkupStatic).update(title) - self.query_one("#mcp-help", NoMarkupStatic).update( - "↑↓ Navigate Enter Show tools Esc Close" - ) + self._set_help_text("↑↓ Navigate Enter Show tools R Refresh Esc Close") has_servers = bool(self._mcp_servers) @@ -180,47 +216,11 @@ class MCPApp(Container): # ── Workspace Connectors ── if has_connectors: - max_name = max(len(n) for n in self._connector_names) - type_tag = "[connector]" - type_width = len(type_tag) - tool_texts = { - n: _tool_count_text( - sum( - 1 - for t, _ in index.connector_tools.get(n, []) - if t in index.enabled_tools - ) - ) - for n in self._connector_names - } - max_tools = max(len(t) for t in tool_texts.values()) if has_servers: option_list.add_option(Option(Text("", no_wrap=True), disabled=True)) - option_list.add_option( - Option( - Text("Workspace Connectors", style="bold", no_wrap=True), - disabled=True, - ) + self._add_connector_options( + option_list, index, self._connector_names, self._connector_registry ) - for cname in self._connector_names: - connected = ( - self._connector_registry.is_connected(cname) - if self._connector_registry - else False - ) - label = Text(no_wrap=True) - label.append(f" {cname:<{max_name}}") - label.append(f" {type_tag:<{type_width}}", style="dim") - label.append(f" {tool_texts[cname]:<{max_tools}}", style="dim") - if connected: - label.append(" ") - label.append("●", style="green") - label.append(" connected", style="dim") - else: - label.append(" ") - label.append("○", style="dim") - label.append(" not connected", style="dim") - option_list.add_option(Option(label, id=f"connector:{cname}")) if not has_servers and not has_connectors: empty_msg = ( @@ -237,6 +237,55 @@ class MCPApp(Container): ) option_list.highlighted = first_enabled + def _add_connector_options( + self, + option_list: OptionList, + index: MCPToolIndex, + connector_names: Sequence[str], + connector_registry: ConnectorRegistry | None, + ) -> None: + ordered_connector_names = _sort_connector_names_for_menu( + connector_names, connector_registry + ) + max_name = max(len(name) for name in ordered_connector_names) + type_tag = "[connector]" + type_width = len(type_tag) + tool_texts = { + name: _tool_count_text( + sum( + 1 + for tool_name, _ in index.connector_tools.get(name, []) + if tool_name in index.enabled_tools + ) + ) + for name in ordered_connector_names + } + max_tools = max(len(text) for text in tool_texts.values()) + option_list.add_option( + Option( + Text("Workspace Connectors", style="bold", no_wrap=True), disabled=True + ) + ) + for connector_name in ordered_connector_names: + connected = ( + connector_registry.is_connected(connector_name) + if connector_registry + else False + ) + label = Text(no_wrap=True) + label.append(f" {connector_name:<{max_name}}") + label.append(f" {type_tag:<{type_width}}", style="dim") + label.append(f" {tool_texts[connector_name]:<{max_tools}}", style="dim") + if connected: + label.append(" ") + label.append("●", style="green") + label.append(" connected", style="dim") + else: + label.append(" ") + label.append("○", style="dim") + label.append(" not connected", style="dim") + option_list.add_option(Option(label, id=f"connector:{connector_name}")) + # ── detail view ────────────────────────────────────────────────── def _show_detail_view( @@ -254,9 +303,7 @@ class MCPApp(Container): self.query_one("#mcp-title", NoMarkupStatic).update( f"{title_prefix}: {server_name}" ) - self.query_one("#mcp-help", NoMarkupStatic).update( - "↑↓ Navigate Backspace Back Esc Close" - ) + self._set_help_text("↑↓ Navigate Backspace Back R Refresh Esc Close") tools_source = index.connector_tools if is_connector else index.server_tools all_tools = sorted(tools_source.get(server_name, []), key=lambda t: t[0]) visible_tools = [(n, c) for n, c in all_tools if n in index.enabled_tools] @@ -284,3 +331,15 @@ def _tool_count_text(count: int) -> str: return "no tools" noun = "tool" if count == 1 else "tools" return f"{count} {noun}" + + +def _sort_connector_names_for_menu( + connector_names: Sequence[str], connector_registry: ConnectorRegistry | None +) -> list[str]: + return sorted( + connector_names, + key=lambda name: ( + not connector_registry.is_connected(name) if connector_registry else True, + name.lower(), + ), + ) diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index 16d0efe..13bdd50 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -5,7 +5,9 @@ from collections.abc import AsyncGenerator, Callable, Generator import contextlib import copy from enum import StrEnum, auto +from functools import wraps from http import HTTPStatus +import inspect import os from pathlib import Path import threading @@ -17,7 +19,7 @@ from uuid import uuid4 from opentelemetry import trace from pydantic import BaseModel -from vibe.cli.terminal_setup import detect_terminal +from vibe.cli.terminal_detect import detect_terminal from vibe.core.agents.manager import AgentManager from vibe.core.agents.models import AgentProfile, BuiltinAgentName from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig @@ -117,7 +119,6 @@ except ImportError: _TeleportService = None if TYPE_CHECKING: - from vibe.core.teleport.nuage import TeleportSession from vibe.core.teleport.teleport import TeleportService from vibe.core.teleport.types import TeleportPushResponseEvent, TeleportYieldEvent @@ -153,6 +154,33 @@ def _should_raise_rate_limit_error(e: Exception) -> bool: return isinstance(e, BackendError) and e.status == HTTPStatus.TOO_MANY_REQUESTS +def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]: + """Decorator that awaits deferred initialization before executing the method.""" + if inspect.isasyncgenfunction(fn): + + @wraps(fn) + async def gen_wrapper(self: AgentLoop, *args: Any, **kwargs: Any) -> Any: + await self.wait_until_ready() + agen = fn(self, *args, **kwargs) + sent: Any = None + try: + while True: + sent = yield await agen.asend(sent) + except StopAsyncIteration: + return + finally: + await agen.aclose() + + return gen_wrapper + + @wraps(fn) + async def wrapper(self: AgentLoop, *args: Any, **kwargs: Any) -> Any: + await self.wait_until_ready() + return await fn(self, *args, **kwargs) + + return wrapper + + class AgentLoop: def __init__( self, @@ -169,7 +197,10 @@ class AgentLoop: defer_heavy_init: bool = False, ) -> None: self._base_config = config - self._init_complete = threading.Event() + + self._defer_heavy_init = defer_heavy_init + self._deferred_init_thread: threading.Thread | None = None + self._deferred_init_lock = threading.Lock() self._init_error: Exception | None = None self.mcp_registry = MCPRegistry() @@ -213,9 +244,6 @@ class AgentLoop: system_message = LLMMessage(role=Role.system, content=system_prompt) self.messages = MessageList(initial=[system_message], observer=message_observer) - if not defer_heavy_init: - self._init_complete.set() - self.stats = AgentStats() self.approval_callback: ApprovalCallback | None = None self.user_input_callback: UserInputCallback | None = None @@ -246,32 +274,39 @@ class AgentLoop: ) self._teleport_service: TeleportService | None = None - thread = Thread( + Thread( target=migrate_sessions_entrypoint, args=(config.session_logging,), daemon=True, name="migrate_sessions", - ) - thread.start() + ).start() - def start_deferred_init(self) -> threading.Thread: - """Spawn a daemon thread that finishes deferred heavy I/O. + if defer_heavy_init: + self._start_deferred_init() - Returns the started thread so callers can join if needed. - """ - thread = threading.Thread( - target=self._complete_init, daemon=True, name="agent_loop_init" - ) - thread.start() - return thread + def _start_deferred_init(self) -> threading.Thread: + """Spawn a daemon thread that finishes deferred heavy I/O once.""" + with self._deferred_init_lock: + if self._deferred_init_thread is not None: + return self._deferred_init_thread + + thread = threading.Thread( + target=self._complete_init, daemon=True, name="agent_loop_init" + ) + self._deferred_init_thread = thread + thread.start() + return thread @property def is_initialized(self) -> bool: """Whether deferred initialization has completed (successfully or not).""" - return self._init_complete.is_set() + if not self._defer_heavy_init: + return True + thread = self._deferred_init_thread + return thread is not None and not thread.is_alive() def _complete_init(self) -> None: - """Run deferred heavy I/O: MCP discovery, connectors, and git status. + """Run deferred heavy I/O: MCP and connector discovery. Intended to be called from a background thread when ``defer_heavy_init=True`` was passed to ``__init__``. @@ -284,22 +319,13 @@ class AgentLoop: self.messages.update_system_prompt(system_prompt) except Exception as exc: self._init_error = exc - finally: - self._init_complete.set() - async def wait_for_init(self) -> None: - """Await deferred initialization from an async context. - - If deferred init failed, all callers raise the stored error. - - A copy of the stored exception is raised each time so that concurrent - callers do not share (and mutate) the same ``__traceback__`` object. - """ - if self.is_initialized: - if err := self._init_error: - raise copy.copy(err).with_traceback(err.__traceback__) + async def wait_until_ready(self) -> None: + """Await deferred initialization from an async context.""" + if not self._defer_heavy_init: return - await asyncio.to_thread(self._init_complete.wait) + thread = self._start_deferred_init() + await asyncio.to_thread(thread.join) if err := self._init_error: raise copy.copy(err).with_traceback(err.__traceback__) @@ -424,7 +450,8 @@ class AgentLoop: server_url = get_server_url_from_api_base(provider.api_base) return ConnectorRegistry(api_key=api_key, server_url=server_url) - def refresh_system_prompt(self) -> None: + @requires_init + async def refresh_system_prompt(self) -> None: """Rebuild and replace the system prompt with current tool/skill state.""" system_prompt = get_universal_system_prompt( self.tool_manager, self.config, self.skill_manager, self.agent_manager @@ -446,10 +473,12 @@ class AgentLoop: self.agent_profile, ) + @requires_init async def inject_user_context(self, content: str) -> None: self.messages.append(LLMMessage(role=Role.user, content=content, injected=True)) await self._save_messages() + @requires_init async def act( self, msg: str, client_message_id: str | None = None ) -> AsyncGenerator[BaseEvent, None]: @@ -486,9 +515,11 @@ class AgentLoop: ) return self._teleport_service - def teleport_to_vibe_nuage( + @requires_init + async def teleport_to_vibe_nuage( self, prompt: str | None ) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]: + from vibe.core.teleport.errors import ServiceTeleportError from vibe.core.teleport.nuage import TeleportSession session = TeleportSession( @@ -499,13 +530,6 @@ class AgentLoop: }, messages=[msg.model_dump(exclude_none=True) for msg in self.messages[1:]], ) - return self._teleport_generator(prompt, session) - - async def _teleport_generator( - self, prompt: str | None, session: TeleportSession - ) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]: - from vibe.core.teleport.errors import ServiceTeleportError - try: async with self.teleport_service: gen = self.teleport_service.execute(prompt=prompt, session=session) @@ -979,6 +1003,7 @@ class AgentLoop: self.telemetry_client.send_tool_call_finished( tool_call=tool_call, agent_profile_name=self.agent_profile.name, + model=self.config.active_model, status=status, decision=decision, result=result, @@ -1011,6 +1036,23 @@ class AgentLoop: available_tools = self.format_handler.get_available_tools(self.tool_manager) tool_choice = self.format_handler.get_tool_choice() + last_user_message = next( + ( + m + for m in reversed(self.messages) + if m.role == Role.user and not m.injected + ), + None, + ) + self.telemetry_client.send_request_sent( + model=active_model.alias, + nb_context_chars=sum(len(m.content or "") for m in self.messages), + nb_context_messages=len(self.messages), + nb_prompt_chars=len(last_user_message.content or "") + if last_user_message + else 0, + ) + try: start_time = time.perf_counter() result = await self.backend.complete( @@ -1056,6 +1098,24 @@ class AgentLoop: available_tools = self.format_handler.get_available_tools(self.tool_manager) tool_choice = self.format_handler.get_tool_choice() + + last_user_message = next( + ( + m + for m in reversed(self.messages) + if m.role == Role.user and not m.injected + ), + None, + ) + self.telemetry_client.send_request_sent( + model=active_model.alias, + nb_context_chars=sum(len(m.content or "") for m in self.messages), + nb_context_messages=len(self.messages), + nb_prompt_chars=len(last_user_message.content or "") + if last_user_message + else 0, + ) + try: start_time = time.perf_counter() usage = LLMUsage() @@ -1239,6 +1299,7 @@ class AgentLoop: self.session_id = str(uuid4()) self.session_logger.reset_session(self.session_id) + @requires_init async def clear_history(self) -> None: await self.session_logger.save_interaction( self.messages, @@ -1264,6 +1325,7 @@ class AgentLoop: self.tool_manager.reset_all() self._reset_session() + @requires_init async def compact(self) -> str: try: self._clean_message_history() @@ -1332,12 +1394,14 @@ class AgentLoop: ) raise + @requires_init async def switch_agent(self, agent_name: str) -> None: if agent_name == self.agent_profile.name: return self.agent_manager.switch_profile(agent_name) await self.reload_with_initial_messages(reset_middleware=False) + @requires_init async def reload_with_initial_messages( self, base_config: VibeConfig | None = None, diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py index d87ed8e..5d1cdad 100644 --- a/vibe/core/config/__init__.py +++ b/vibe/core/config/__init__.py @@ -29,6 +29,15 @@ from vibe.core.config._settings import ( VibeConfig, load_dotenv_values, ) +from vibe.core.config.schema import ( + DuplicateMergeMetadataError, + MergeFieldMetadata, + WithConcatMerge, + WithConflictMerge, + WithReplaceMerge, + WithShallowMerge, + WithUnionMerge, +) __all__ = [ "DEFAULT_MISTRAL_API_ENV_KEY", @@ -38,10 +47,12 @@ __all__ = [ "DEFAULT_TRANSCRIBE_PROVIDERS", "DEFAULT_TTS_MODELS", "DEFAULT_TTS_PROVIDERS", + "DuplicateMergeMetadataError", "MCPHttp", "MCPServer", "MCPStdio", "MCPStreamableHttp", + "MergeFieldMetadata", "MissingAPIKeyError", "MissingPromptFileError", "ModelConfig", @@ -57,5 +68,10 @@ __all__ = [ "TranscribeModelConfig", "TranscribeProviderConfig", "VibeConfig", + "WithConcatMerge", + "WithConflictMerge", + "WithReplaceMerge", + "WithShallowMerge", + "WithUnionMerge", "load_dotenv_values", ] diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py index abc2bf1..e996294 100644 --- a/vibe/core/config/_settings.py +++ b/vibe/core/config/_settings.py @@ -290,6 +290,9 @@ class MCPStdio(_MCPBase): default_factory=dict, description="Environment variables to set for the MCP server process.", ) + cwd: str | None = Field( + default=None, description="Working directory for the MCP server process." + ) def argv(self) -> list[str]: base = ( diff --git a/vibe/core/config/schema.py b/vibe/core/config/schema.py new file mode 100644 index 0000000..185acf0 --- /dev/null +++ b/vibe/core/config/schema.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from dataclasses import dataclass, field + +from pydantic.fields import FieldInfo + +from vibe.core.utils.merge import MergeStrategy + + +class DuplicateMergeMetadataError(TypeError): + """Raised when a field declares more than one MergeFieldMetadata marker.""" + + +@dataclass(frozen=True) +class MergeFieldMetadata: + """Base Pydantic Annotated marker that declares how a config field merges across layers. + + Usage:: + + active_model: Annotated[str, WithReplaceMerge()] = "devstral-2" + models: Annotated[list[M], WithUnionMerge(merge_key="alias")] + + Use the concrete subclasses (``WithReplaceMerge``, ``WithConcatMerge``, etc.) + rather than instantiating this base class directly. + """ + + merge_strategy: MergeStrategy + merge_key: str | None = None + + @classmethod + def from_field(cls, field_info: FieldInfo) -> MergeFieldMetadata | None: + """Extract MergeFieldMetadata from a FieldInfo's metadata, if present. + + Raises DuplicateMergeMetadataError if more than one marker is found. + """ + result: MergeFieldMetadata | None = None + for item in field_info.metadata: + if isinstance(item, cls): + if result is not None: + raise DuplicateMergeMetadataError( + f"Field has multiple MergeFieldMetadata markers: " + f"{result} and {item}" + ) + result = item + return result + + +@dataclass(frozen=True) +class WithReplaceMerge(MergeFieldMetadata): + """Higher layer wins outright.""" + + merge_strategy: MergeStrategy = field(default=MergeStrategy.REPLACE, init=False) + + +@dataclass(frozen=True) +class WithConcatMerge(MergeFieldMetadata): + """Lists appended in layer order.""" + + merge_strategy: MergeStrategy = field(default=MergeStrategy.CONCAT, init=False) + + +@dataclass(frozen=True) +class WithUnionMerge(MergeFieldMetadata): + """Lists merged by key, higher layer wins per-key.""" + + merge_strategy: MergeStrategy = field(default=MergeStrategy.UNION, init=False) + merge_key: str = "" + + def __post_init__(self) -> None: + if not self.merge_key: + raise TypeError("WithUnionMerge requires merge_key") + + +@dataclass(frozen=True) +class WithShallowMerge(MergeFieldMetadata): + """Dicts shallow-merged, absent keys preserved.""" + + merge_strategy: MergeStrategy = field(default=MergeStrategy.MERGE, init=False) + + +@dataclass(frozen=True) +class WithConflictMerge(MergeFieldMetadata): + """Raises error if more than one layer provides a value.""" + + merge_strategy: MergeStrategy = field(default=MergeStrategy.CONFLICT, init=False) diff --git a/vibe/core/skills/builtins/__init__.py b/vibe/core/skills/builtins/__init__.py new file mode 100644 index 0000000..b25bd6b --- /dev/null +++ b/vibe/core/skills/builtins/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from vibe.core.skills.builtins.vibe import SKILL as VIBE_SKILL +from vibe.core.skills.models import SkillInfo + +BUILTIN_SKILLS: dict[str, SkillInfo] = {skill.name: skill for skill in [VIBE_SKILL]} diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py new file mode 100644 index 0000000..3af24e7 --- /dev/null +++ b/vibe/core/skills/builtins/vibe.py @@ -0,0 +1,350 @@ +from __future__ import annotations + +from vibe.core.skills.models import SkillInfo + +SKILL = SkillInfo( + name="vibe", + description="Understand the Vibe CLI application internals: configuration, VIBE_HOME structure, available parameters, agents, skills, tools, and how to inspect or update the user's setup. Use this skill when the user asks about how Vibe works, wants to configure it, or when you need to understand the runtime environment.", + prompt="""# Vibe CLI Self-Awareness + +You are running inside **Mistral Vibe**, a CLI coding agent built by Mistral AI. +This skill gives you full knowledge of the application internals so you can help +the user understand, configure, and troubleshoot their Vibe installation. + +## VIBE_HOME + +The user's Vibe home directory defaults to `~/.vibe` but can be overridden via +the `VIBE_HOME` environment variable. All user-level configuration, skills, tools, +agents, prompts, logs, and session data live here. + +### Directory Structure + +``` +~/.vibe/ + config.toml # Main configuration file (TOML format) + .env # API keys and credentials (dotenv format) + vibehistory # Command history + trusted_folders.toml # Trust database for project folders + agents/ # Custom agent profiles (*.toml) + prompts/ # Custom system prompts (*.md) + skills/ # User-level skills (each skill is a subdirectory with SKILL.md) + tools/ # Custom tool definitions + logs/ + vibe.log # Main log file + session/ # Session log files + plans/ # Session plans +``` + +### Project-Local Configuration + +When in a trusted folder, Vibe also looks for project-local configuration: +- `.vibe/config.toml` - Project-specific config (overrides user config) +- `.vibe/skills/` - Project-specific skills +- `.vibe/tools/` - Project-specific tools +- `.vibe/agents/` - Project-specific agents +- `.vibe/prompts/` - Project-specific prompts +- `.agents/skills/` - Standard agent skills directory + +## Configuration (config.toml) + +The configuration file uses TOML format. Settings can also be overridden via +environment variables with the `VIBE_` prefix (e.g., `VIBE_ACTIVE_MODEL=local`). + +### Key Settings + +```toml +# Model selection +active_model = "devstral-2" # Model alias to use (see [[models]]) + +# UI preferences +vim_keybindings = false +disable_welcome_banner_animation = false +autocopy_to_clipboard = true +file_watcher_for_autocomplete = false + +# Behavior +auto_approve = false # Skip tool approval prompts +system_prompt_id = "cli" # System prompt: "cli", "lean", or custom .md filename +enable_telemetry = true +enable_update_checks = true +enable_auto_update = true +enable_notifications = true +api_timeout = 720.0 # API request timeout in seconds +auto_compact_threshold = 200000 # Token count before auto-compaction + +# Git commit behavior +include_commit_signature = true # Add "Co-Authored-By" to commits + +# System prompt composition +include_model_info = true # Include model name in system prompt +include_project_context = true # Include project context (git info, cwd) in system prompt +include_prompt_detail = true # Include OS info, tool prompts, skills, and agents in system prompt + +# Voice features +voice_mode_enabled = false +narrator_enabled = false +active_transcribe_model = "voxtral-realtime" +active_tts_model = "voxtral-tts" +``` + +### Providers + +```toml +[[providers]] +name = "mistral" +api_base = "https://api.mistral.ai/v1" +api_key_env_var = "MISTRAL_API_KEY" +backend = "mistral" + +[[providers]] +name = "llamacpp" +api_base = "http://127.0.0.1:8080/v1" +api_key_env_var = "" +``` + +### Models + +```toml +[[models]] +name = "mistral-vibe-cli-latest" +provider = "mistral" +alias = "devstral-2" +temperature = 0.2 +input_price = 0.4 +output_price = 2.0 +thinking = "off" # "off", "low", "medium", "high" +auto_compact_threshold = 200000 + +[[models]] +name = "devstral-small-latest" +provider = "mistral" +alias = "devstral-small" +input_price = 0.1 +output_price = 0.3 + +[[models]] +name = "devstral" +provider = "llamacpp" +alias = "local" +``` + +### Tool Configuration + +```toml +# Additional tool search paths +tool_paths = ["/path/to/custom/tools"] + +# Enable only specific tools (glob and regex supported) +enabled_tools = ["bash", "read_file", "grep"] + +# Disable specific tools +disabled_tools = ["webfetch"] + +# Per-tool configuration +[tools.bash] +allowlist = ["git", "npm", "python"] +``` + +### Skill Configuration + +```toml +# Additional skill search paths +skill_paths = ["/path/to/custom/skills"] + +# Enable only specific skills +enabled_skills = ["vibe", "custom-*"] + +# Disable specific skills +disabled_skills = ["experimental-*"] +``` + +### Agent Configuration + +```toml +# Additional agent search paths +agent_paths = ["/path/to/custom/agents"] + +# Enable/disable agents +enabled_agents = ["default", "plan"] +disabled_agents = ["auto-approve"] + +# Opt-in builtin agents (only affects agents with install_required=True, e.g. lean) +installed_agents = ["lean"] +``` + +### MCP Servers + +```toml +[[mcp_servers]] +name = "my-server" +transport = "stdio" +command = "npx" +args = ["-y", "@my/mcp-server"] + +[[mcp_servers]] +name = "remote-server" +transport = "http" +url = "https://mcp.example.com" +api_key_env = "MCP_API_KEY" +``` + +### Session Logging + +```toml +[session_logging] +enabled = true +save_dir = "" # Defaults to ~/.vibe/logs/session +session_prefix = "session" +``` + +### Pattern Matching + +Tool, skill, and agent names support three matching modes: +- **Exact**: `"bash"`, `"read_file"` +- **Glob**: `"bash*"`, `"mcp_*"` +- **Regex**: `"re:^serena_.*$"` (full match, case-insensitive) + +## CLI Parameters + +``` +vibe [PROMPT] # Start interactive session with optional prompt +vibe -p TEXT / --prompt TEXT # Programmatic mode (auto-approve, one-shot, exit) +vibe --agent NAME # Select agent profile +vibe --workdir DIR # Change working directory +vibe -c / --continue # Continue most recent session +vibe --resume [SESSION_ID] # Resume a specific session +vibe -v / --version # Show version +vibe --setup # Run onboarding/setup +vibe --max-turns N # Max assistant turns (programmatic mode) +vibe --max-price DOLLARS # Max cost limit (programmatic mode) +vibe --enabled-tools TOOL # Enable specific tools (repeatable) +vibe --output text|json|streaming # Output format (programmatic mode) +``` + +## Built-in Agents + +There are two kinds of agents: +- **Agents** are user-facing profiles selectable via `--agent` or `Shift+Tab`. + They configure the model's behavior, tools, and system prompt. +- **Subagents** are model-facing: the model can spawn them autonomously to delegate + subtasks (e.g. exploring the codebase). Users cannot select subagents directly. + +### Agents + +- **default**: Standard interactive agent +- **plan**: Planning-focused agent +- **accept-edits**: Auto-approves file edits but asks for other tools +- **auto-approve**: Auto-approves all tool calls +- **lean**: Specialized Lean 4 proof assistant. Not available by default — must be + installed with `/leanstall` (removed with `/unleanstall`) + +### Subagents + +- **explore**: Read-only codebase exploration subagent (grep + read_file only). + Spawned by the model, not selectable by the user. + +Custom agents are TOML files in `~/.vibe/agents/NAME.toml`. + +## Built-in Slash Commands + +- `/help` - Show help message +- `/config` - Edit config settings +- `/model` - Select active model +- `/reload` - Reload configuration, agent instructions, and skills from disk +- `/clear` - Clear conversation history +- `/log` - Show path to current interaction log file +- `/debug` - Toggle debug console +- `/compact` - Compact conversation history by summarizing +- `/status` - Display agent statistics +- `/voice` - Configure voice settings +- `/mcp` - Display available MCP servers (pass a server name to list its tools) +- `/resume` (or `/continue`) - Browse and resume past sessions +- `/rewind` - Rewind to a previous message +- `/terminal-setup` - Configure Shift+Enter for newlines +- `/proxy-setup` - Configure proxy and SSL certificate settings +- `/leanstall` - Install the Lean 4 agent (leanstral) +- `/unleanstall` - Uninstall the Lean 4 agent +- `/data-retention` - Show data retention information +- `/teleport` - Teleport session to Vibe Nuage (only available when Nuage is enabled) +- `/exit` - Exit the application + +## Skills System + +Skills are specialized instruction sets the model can load on demand. +Each skill is a directory containing a `SKILL.md` file with YAML frontmatter. + +### Skill File Format + +```markdown +--- +name: my-skill +description: What this skill does and when to use it. +user-invocable: true +allowed-tools: bash read_file +--- + +# Skill Instructions + +Detailed instructions for the model... +``` + +### Skill Search Order (first match wins) + +1. `skill_paths` from config.toml +2. `.vibe/skills/` in trusted project directory +3. `.agents/skills/` in trusted project directory +4. `~/.vibe/skills/` (user global) + +## Environment Variables + +- `VIBE_HOME` - Override the Vibe home directory (default: `~/.vibe`) +- `MISTRAL_API_KEY` - API key for Mistral provider +- `VIBE_ACTIVE_MODEL` - Override active model +- `VIBE_*` - Any config field can be overridden with the `VIBE_` prefix + +## API Keys (.env file) + +The `.env` file in VIBE_HOME stores API keys in dotenv format: + +``` +MISTRAL_API_KEY=your-key-here +``` + +This file is loaded on startup and its values are injected into the environment. + +## Trusted Folders + +Vibe uses a trust system to prevent executing project-local config from untrusted +directories. The trust database is stored in `~/.vibe/trusted_folders.toml`. +Project-local config (`.vibe/` directory) is only loaded when the current +directory is explicitly trusted. + +## Sensitive Files — DO NOT READ OR EDIT + +NEVER read, display, or edit any of these files: +- `~/.vibe/.env` (or `$VIBE_HOME/.env`) — contains API keys and secrets +- Any `.env`, `.env.*` file in the project or VIBE_HOME + +If the user asks to set or change an API key, instruct them to edit the `.env` +file themselves. Do not offer to read it, write it, or display its contents. +Do not use tools (read_file, write_file, bash cat/echo, etc.) to access these files. + +## How to Modify Configuration + +To help the user modify their Vibe configuration: + +1. **Read current config**: Read the file at `~/.vibe/config.toml` (or the path + from `VIBE_HOME` env var if set) +2. **Create a backup**: Before any edit, copy the file to `config.toml.bak` in the + same directory (e.g. `cp ~/.vibe/config.toml ~/.vibe/config.toml.bak`). This + applies to any config file you are about to modify (`config.toml`, + `trusted_folders.toml`, agent TOML files, etc.) +3. **Edit the TOML file**: Make changes using the search_replace or write_file tool +4. **Reload**: The user can run `/reload` to apply changes without restarting + +For API keys, tell the user to edit `~/.vibe/.env` directly — never read or +write that file yourself. + +For project-specific configuration, create/edit `.vibe/config.toml` in the +project root (the folder must be trusted first).""", +) diff --git a/vibe/core/skills/manager.py b/vibe/core/skills/manager.py index a9ab3d6..dc4233b 100644 --- a/vibe/core/skills/manager.py +++ b/vibe/core/skills/manager.py @@ -1,13 +1,15 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from pathlib import Path +from types import MappingProxyType from typing import TYPE_CHECKING from vibe.core.config.harness_files import get_harness_files_manager from vibe.core.logger import logger +from vibe.core.skills.builtins import BUILTIN_SKILLS from vibe.core.skills.models import ParsedSkillCommand, SkillInfo, SkillMetadata -from vibe.core.skills.parser import SkillParseError, parse_frontmatter +from vibe.core.skills.parser import SkillParseError, parse_skill_markdown from vibe.core.utils import name_matches from vibe.core.utils.io import read_safe @@ -19,12 +21,14 @@ class SkillManager: def __init__(self, config_getter: Callable[[], VibeConfig]) -> None: self._config_getter = config_getter self._search_paths = self._compute_search_paths(self._config) - self._available: dict[str, SkillInfo] = self._discover_skills() + self.available_skills: Mapping[str, SkillInfo] = MappingProxyType( + self._apply_filters(self._discover_skills()) + ) - if self._available: + if self.available_skills: logger.info( "Discovered %d skill(s) from %d search path(s)", - len(self._available), + len(self.available_skills), len(self._search_paths), ) @@ -32,21 +36,20 @@ class SkillManager: def _config(self) -> VibeConfig: return self._config_getter() - @property - def available_skills(self) -> dict[str, SkillInfo]: + def _apply_filters(self, skills: dict[str, SkillInfo]) -> dict[str, SkillInfo]: if self._config.enabled_skills: return { name: info - for name, info in self._available.items() + for name, info in skills.items() if name_matches(name, self._config.enabled_skills) } if self._config.disabled_skills: return { name: info - for name, info in self._available.items() + for name, info in skills.items() if not name_matches(name, self._config.disabled_skills) } - return dict(self._available) + return dict(skills) @staticmethod def _compute_search_paths(config: VibeConfig) -> list[Path]: @@ -69,7 +72,7 @@ class SkillManager: return unique def _discover_skills(self) -> dict[str, SkillInfo]: - skills: dict[str, SkillInfo] = {} + skills: dict[str, SkillInfo] = {**BUILTIN_SKILLS} for base in self._search_paths: if not base.is_dir(): continue @@ -93,8 +96,24 @@ class SkillManager: skill_file = skill_dir / "SKILL.md" if not skill_file.is_file(): continue - if (skill_info := self._try_load_skill(skill_file)) is not None: - skills[skill_info.name] = skill_info + if (skill_info := self._try_load_skill(skill_file)) is None: + continue + if skill_info.name in BUILTIN_SKILLS: + logger.debug( + "Skipping skill '%s' at %s because builtin skill names are reserved", + skill_info.name, + skill_info.skill_path, + ) + continue + if skill_info.name in skills: + logger.debug( + "Skipping duplicate skill '%s' at %s (already loaded from %s)", + skill_info.name, + skill_info.skill_path, + skills[skill_info.name].skill_path, + ) + continue + skills[skill_info.name] = skill_info return skills def _try_load_skill(self, skill_file: Path) -> SkillInfo | None: @@ -111,7 +130,7 @@ class SkillManager: except OSError as e: raise SkillParseError(f"Cannot read file: {e}") from e - frontmatter, _ = parse_frontmatter(content) + frontmatter, body = parse_skill_markdown(content) metadata = SkillMetadata.model_validate(frontmatter) skill_name_from_dir = skill_path.parent.name @@ -123,7 +142,11 @@ class SkillManager: skill_path, ) - return SkillInfo.from_metadata(metadata, skill_path) + return SkillInfo.from_metadata(metadata, skill_path, prompt=body.strip()) + + @property + def custom_skills_count(self) -> int: + return sum(name not in BUILTIN_SKILLS for name in self.available_skills) def get_skill(self, name: str) -> SkillInfo | None: return self.available_skills.get(name) @@ -142,12 +165,11 @@ class SkillManager: if skill_info is None: return None - skill_content = read_safe(skill_info.skill_path).text extra_instructions = parts[1] if len(parts) > 1 else None return ParsedSkillCommand( name=skill_name, - content=skill_content, + content=skill_info.prompt, extra_instructions=extra_instructions, ) diff --git a/vibe/core/skills/models.py b/vibe/core/skills/models.py index 0b69704..13d40d5 100644 --- a/vibe/core/skills/models.py +++ b/vibe/core/skills/models.py @@ -70,16 +70,21 @@ class SkillInfo(BaseModel): metadata: dict[str, str] = Field(default_factory=dict) allowed_tools: list[str] = Field(default_factory=list) user_invocable: bool = True - skill_path: Path + skill_path: Path | None = None + prompt: str model_config = {"arbitrary_types_allowed": True} @property - def skill_dir(self) -> Path: + def skill_dir(self) -> Path | None: + if self.skill_path is None: + return None return self.skill_path.parent.resolve() @classmethod - def from_metadata(cls, meta: SkillMetadata, skill_path: Path) -> SkillInfo: + def from_metadata( + cls, meta: SkillMetadata, skill_path: Path, prompt: str + ) -> SkillInfo: return cls( name=meta.name, description=meta.description, @@ -89,6 +94,7 @@ class SkillInfo(BaseModel): allowed_tools=meta.allowed_tools, user_invocable=meta.user_invocable, skill_path=skill_path.resolve(), + prompt=prompt, ) diff --git a/vibe/core/skills/parser.py b/vibe/core/skills/parser.py index ed3a442..7ed1a3b 100644 --- a/vibe/core/skills/parser.py +++ b/vibe/core/skills/parser.py @@ -15,7 +15,7 @@ class SkillParseError(Exception): FM_BOUNDARY = re.compile(r"^-{3,}\s*$", re.MULTILINE) -def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]: +def parse_skill_markdown(content: str) -> tuple[dict[str, Any], str]: splits = FM_BOUNDARY.split(content, 2) if len(splits) < 3 or splits[0].strip(): # noqa: PLR2004 raise SkillParseError( diff --git a/vibe/core/system_prompt.py b/vibe/core/system_prompt.py index cad127d..1b51341 100644 --- a/vibe/core/system_prompt.py +++ b/vibe/core/system_prompt.py @@ -207,7 +207,7 @@ def _get_available_skills_section(skill_manager: SkillManager) -> str: "# Available Skills", "", "You have access to the following skills. When a task matches a skill's description,", - "use the `skill` tool if available to load the full skill instructions, if it is not available, read the files manually.", + "use the `skill` tool if available to load the full skill instructions, if it is not available, read the files manually if they exist.", "", "", ] @@ -218,7 +218,8 @@ def _get_available_skills_section(skill_manager: SkillManager) -> str: lines.append( f" {html.escape(str(info.description))}" ) - lines.append(f" {html.escape(str(info.skill_path))}") + if info.skill_path is not None: + lines.append(f" {html.escape(str(info.skill_path))}") lines.append(" ") lines.append("") diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py index d47f152..2556f05 100644 --- a/vibe/core/telemetry/send.py +++ b/vibe/core/telemetry/send.py @@ -157,6 +157,7 @@ class TelemetryClient: status: Literal["success", "failure", "skipped"], decision: ToolDecision | None, agent_profile_name: str, + model: str, result: dict[str, Any] | None = None, ) -> None: verdict_value = decision.verdict.value if decision else None @@ -172,6 +173,7 @@ class TelemetryClient: "decision": verdict_value, "approval_type": approval_type_value, "agent_profile_name": agent_profile_name, + "model": model, "nb_files_created": nb_files_created, "nb_files_modified": nb_files_modified, } @@ -224,6 +226,22 @@ class TelemetryClient: "vibe.onboarding_api_key_added", {"version": __version__} ) + def send_request_sent( + self, + *, + model: str, + nb_context_chars: int, + nb_context_messages: int, + nb_prompt_chars: int, + ) -> None: + payload = { + "model": model, + "nb_context_chars": nb_context_chars, + "nb_context_messages": nb_context_messages, + "nb_prompt_chars": nb_prompt_chars, + } + self.send_telemetry_event("vibe.request_sent", payload) + def send_user_rating_feedback(self, rating: int, model: str) -> None: self.send_telemetry_event( "vibe.user_rating_feedback", diff --git a/vibe/core/tools/builtins/skill.py b/vibe/core/tools/builtins/skill.py index 33ef0d1..64fb9e5 100644 --- a/vibe/core/tools/builtins/skill.py +++ b/vibe/core/tools/builtins/skill.py @@ -5,7 +5,6 @@ from typing import ClassVar from pydantic import BaseModel, Field -from vibe.core.skills.parser import SkillParseError, parse_frontmatter from vibe.core.tools.base import ( BaseTool, BaseToolConfig, @@ -14,14 +13,9 @@ from vibe.core.tools.base import ( ToolError, ToolPermission, ) -from vibe.core.tools.permissions import ( - PermissionContext, - PermissionScope, - RequiredPermission, -) +from vibe.core.tools.permissions import PermissionContext from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData from vibe.core.types import ToolResultEvent, ToolStreamEvent -from vibe.core.utils.io import read_safe _MAX_LISTED_FILES = 10 @@ -33,11 +27,13 @@ class SkillArgs(BaseModel): class SkillResult(BaseModel): name: str = Field(description="The name of the loaded skill") content: str = Field(description="The full skill content block") - skill_dir: str = Field(description="Absolute path to the skill directory") + skill_dir: str | None = Field( + default=None, description="Absolute path to the skill directory when available" + ) class SkillToolConfig(BaseToolConfig): - permission: ToolPermission = ToolPermission.ASK + permission: ToolPermission = ToolPermission.ALWAYS class Skill( @@ -71,17 +67,7 @@ class Skill( return "Loading skill" def resolve_permission(self, args: SkillArgs) -> PermissionContext | None: - return PermissionContext( - permission=self.config.permission, - required_permissions=[ - RequiredPermission( - scope=PermissionScope.FILE_PATTERN, - invocation_pattern=args.name, - session_pattern=args.name, - label=f"Load skill: {args.name}", - ) - ], - ) + return PermissionContext(permission=ToolPermission.ALWAYS) async def run( self, args: SkillArgs, ctx: InvokeContext | None = None @@ -98,36 +84,36 @@ class Skill( f'Skill "{args.name}" not found. Available skills: {available or "none"}' ) - try: - raw = read_safe(skill_info.skill_path).text - _, body = parse_frontmatter(raw) - except (OSError, SkillParseError) as e: - raise ToolError(f"Cannot load skill file: {e}") from e - skill_dir = skill_info.skill_dir files: list[str] = [] - try: - for entry in sorted(skill_dir.rglob("*")): - if not entry.is_file(): - continue - if entry.name == "SKILL.md": - continue - files.append(str(entry.relative_to(skill_dir))) - if len(files) >= _MAX_LISTED_FILES: - break - except OSError: - pass + if skill_dir is not None: + try: + for entry in sorted(skill_dir.rglob("*")): + if not entry.is_file(): + continue + if entry.name == "SKILL.md": + continue + files.append(str(entry.relative_to(skill_dir))) + if len(files) >= _MAX_LISTED_FILES: + break + except OSError: + pass file_lines = "\n".join(f"{f}" for f in files) + base_dir_lines: list[str] = [] + if skill_dir is not None: + base_dir_lines = [ + f"Base directory for this skill: {skill_dir}", + "Relative paths in this skill are relative to this base directory.", + ] output = "\n".join([ f'', f"# Skill: {args.name}", "", - body.strip(), + skill_info.prompt.strip(), "", - f"Base directory for this skill: {skill_dir}", - "Relative paths in this skill are relative to this base directory.", + *base_dir_lines, "Note: file list is sampled.", "", "", @@ -136,4 +122,5 @@ class Skill( "", ]) - yield SkillResult(name=args.name, content=output, skill_dir=str(skill_dir)) + resolved_skill_dir = None if skill_dir is None else str(skill_dir) + yield SkillResult(name=args.name, content=output, skill_dir=resolved_skill_dir) diff --git a/vibe/core/tools/builtins/task.py b/vibe/core/tools/builtins/task.py index 4d445f1..f880fe2 100644 --- a/vibe/core/tools/builtins/task.py +++ b/vibe/core/tools/builtins/task.py @@ -135,6 +135,7 @@ class Task( agent_name=args.agent, entrypoint_metadata=ctx.entrypoint_metadata, is_subagent=True, + defer_heavy_init=True, ) if ctx and ctx.approval_callback: diff --git a/vibe/core/tools/manager.py b/vibe/core/tools/manager.py index 4184bc6..50cb397 100644 --- a/vibe/core/tools/manager.py +++ b/vibe/core/tools/manager.py @@ -254,6 +254,17 @@ class ToolManager: self._available.pop(key, None) self._instances.pop(key, None) + def _purge_mcp_state(self) -> None: + """Remove stale MCP tool classes and cached instances.""" + stale_keys = [ + name + for name, cls in self._available.items() + if issubclass(cls, MCPTool) and not cls.is_connector() + ] + for key in stale_keys: + self._available.pop(key, None) + self._instances.pop(key, None) + def integrate_connectors(self) -> None: """Discover and register connector tools (sync wrapper).""" run_sync(self.integrate_connectors_async()) @@ -279,6 +290,22 @@ class ToolManager: self._available.update(connector_tools) logger.info(f"Connector integration registered {len(connector_tools)} tools") + async def refresh_remote_tools_async(self) -> None: + """Force MCP and connector re-discovery for the current config.""" + with self._lock: + self._mcp_registry.clear() + self._purge_mcp_state() + self._mcp_integrated = False + self._purge_connector_state() + if self._connector_registry is not None: + self._connector_registry.clear() + + await self._integrate_all_async() + + def refresh_remote_tools(self) -> None: + """Sync wrapper for :meth:`refresh_remote_tools_async`.""" + run_sync(self.refresh_remote_tools_async()) + def integrate_all(self, *, raise_on_mcp_failure: bool = False) -> None: """Discover MCP and connector tools in parallel. diff --git a/vibe/core/tools/mcp/registry.py b/vibe/core/tools/mcp/registry.py index 7f6c19d..a207a03 100644 --- a/vibe/core/tools/mcp/registry.py +++ b/vibe/core/tools/mcp/registry.py @@ -139,7 +139,10 @@ class MCPRegistry: try: remotes = await list_tools_stdio( - cmd, env=srv.env or None, startup_timeout_sec=srv.startup_timeout_sec + cmd, + env=srv.env or None, + cwd=srv.cwd, + startup_timeout_sec=srv.startup_timeout_sec, ) except Exception as exc: logger.warning("MCP stdio discovery failed for %r: %s", cmd, exc) @@ -154,6 +157,7 @@ class MCPRegistry: alias=srv.name, server_hint=srv.prompt, env=srv.env or None, + cwd=srv.cwd, startup_timeout_sec=srv.startup_timeout_sec, tool_timeout_sec=srv.tool_timeout_sec, sampling_enabled=srv.sampling_enabled, diff --git a/vibe/core/tools/mcp/tools.py b/vibe/core/tools/mcp/tools.py index c8cb363..51940d1 100644 --- a/vibe/core/tools/mcp/tools.py +++ b/vibe/core/tools/mcp/tools.py @@ -297,9 +297,12 @@ async def list_tools_stdio( command: list[str], *, env: dict[str, str] | None = None, + cwd: str | None = None, startup_timeout_sec: float | None = None, ) -> list[RemoteTool]: - params = StdioServerParameters(command=command[0], args=command[1:], env=env) + params = StdioServerParameters( + command=command[0], args=command[1:], env=env, cwd=cwd + ) timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None async with ( _mcp_stderr_capture() as errlog, @@ -317,11 +320,14 @@ async def call_tool_stdio( arguments: dict[str, Any], *, env: dict[str, str] | None = None, + cwd: str | None = None, startup_timeout_sec: float | None = None, tool_timeout_sec: float | None = None, sampling_callback: MCPSamplingHandler | None = None, ) -> MCPToolResult: - params = StdioServerParameters(command=command[0], args=command[1:], env=env) + params = StdioServerParameters( + command=command[0], args=command[1:], env=env, cwd=cwd + ) init_timeout = ( timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None ) @@ -350,6 +356,7 @@ def create_mcp_stdio_proxy_tool_class( alias: str | None = None, server_hint: str | None = None, env: dict[str, str] | None = None, + cwd: str | None = None, startup_timeout_sec: float | None = None, tool_timeout_sec: float | None = None, sampling_enabled: bool = True, @@ -378,6 +385,7 @@ def create_mcp_stdio_proxy_tool_class( _remote_name: ClassVar[str] = remote.name _input_schema: ClassVar[dict[str, Any]] = remote.input_schema _env: ClassVar[dict[str, str] | None] = env + _cwd: ClassVar[str | None] = cwd _startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec _tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec _sampling_enabled: ClassVar[bool] = sampling_enabled @@ -403,6 +411,7 @@ def create_mcp_stdio_proxy_tool_class( self._remote_name, payload, env=self._env, + cwd=self._cwd, startup_timeout_sec=self._startup_timeout_sec, tool_timeout_sec=self._tool_timeout_sec, sampling_callback=sampling_callback, diff --git a/vibe/core/types.py b/vibe/core/types.py index bd9438a..a1a7a03 100644 --- a/vibe/core/types.py +++ b/vibe/core/types.py @@ -491,7 +491,7 @@ class MessageList(Sequence[LLMMessage]): Called from a background thread during deferred init. A single list-item assignment is atomic under CPython's GIL, and the - ``_init_complete`` event ensures no ``act()`` call reads the + ``@requires_init`` decorator ensures no ``act()`` call reads the prompt concurrently, so no additional lock is needed here. """ self._data[0] = LLMMessage(role=Role.system, content=new) diff --git a/vibe/core/utils/merge.py b/vibe/core/utils/merge.py index 1bb3043..2cb9e86 100644 --- a/vibe/core/utils/merge.py +++ b/vibe/core/utils/merge.py @@ -18,6 +18,16 @@ class MergeConflictError(Exception): self.field_name = field_name +class MergeKeyError(KeyError): + """Raised when a UNION merge item is missing the expected merge key.""" + + def __init__(self, key: str, item: Any) -> None: + msg = f"UNION merge key {key!r} not found in item {item!r}" + super().__init__(msg) + self.key = key + self.item = item + + class MergeStrategy(StrEnum): REPLACE = auto() CONCAT = auto() @@ -81,10 +91,11 @@ class MergeStrategy(StrEnum): f"UNION requires list operands, got {type(base).__name__} and {type(override).__name__}" ) merged: dict[str, Any] = {} - for item in base: - merged[key_fn(item)] = item - for item in override: - merged[key_fn(item)] = item + for item in [*base, *override]: + try: + merged[key_fn(item)] = item + except KeyError as exc: + raise MergeKeyError(exc.args[0], item) from exc return list(merged.values()) def _merge(self, base: Any, override: Any) -> Any: diff --git a/vibe/whats_new.md b/vibe/whats_new.md index d4909f1..66d065b 100644 --- a/vibe/whats_new.md +++ b/vibe/whats_new.md @@ -1,3 +1,8 @@ -# What's new in v2.7.6 +# What's new in v2.8.0 -- **Streaming fix**: Fixed markdown fence context loss causing streaming rendering problems +- **Builtin skills system**: Added self-awareness skill for enhanced functionality +- **Copy command**: Introduced `/copy` slash command to copy messages to clipboard +- **Git branch display**: Current git branch and GitHub PR now shown in the bottom bar +- **Diff view**: Added diff view for `write_file` overwrites in approval and result widgets +- **Exit command**: Added `exit` (without slash) to exit the application +- **Glob tool**: Added glob tool for file pattern matching