diff --git a/.github/workflows/build-and-upload.yml b/.github/workflows/build-and-upload.yml index 559dfe9..2f811b2 100644 --- a/.github/workflows/build-and-upload.yml +++ b/.github/workflows/build-and-upload.yml @@ -64,8 +64,10 @@ jobs: enable-cache: true cache-dependency-glob: "uv.lock" - - name: Install Python - run: uv python install 3.12 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" - name: Sync dependencies run: uv sync --no-dev --group build diff --git a/.vscode/launch.json b/.vscode/launch.json index 0651cb4..ed38a33 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,5 +1,5 @@ { - "version": "2.7.1", + "version": "2.7.2", "configurations": [ { "name": "ACP Server", diff --git a/CHANGELOG.md b/CHANGELOG.md index 079edca..ee88be8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,24 @@ 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.7.2] - 2026-04-01 + +### Added + +- Alt+Left / Alt+Right keyboard shortcuts for word-wise cursor movement in chat input + +### Changed + +- Refactored narrator into a dedicated narrator manager + +### Fixed + +- Broken build on Linux +- Errored MCP servers are now excluded from the banner count +- Improved bash denylist matching and error messages +- Command messages are now skipped during rewind navigation + + ## [2.7.1] - 2026-03-31 ### Added diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml index 7fc87fc..d770d9d 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.1" +version = "2.7.2" 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.1/vibe-acp-darwin-aarch64-2.7.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.2/vibe-acp-darwin-aarch64-2.7.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.darwin-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.1/vibe-acp-darwin-x86_64-2.7.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.2/vibe-acp-darwin-x86_64-2.7.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.1/vibe-acp-linux-aarch64-2.7.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.2/vibe-acp-linux-aarch64-2.7.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.linux-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.1/vibe-acp-linux-x86_64-2.7.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.2/vibe-acp-linux-x86_64-2.7.2.zip" cmd = "./vibe-acp" [agent_servers.mistral-vibe.targets.windows-aarch64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.1/vibe-acp-windows-aarch64-2.7.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.2/vibe-acp-windows-aarch64-2.7.2.zip" cmd = "./vibe-acp.exe" [agent_servers.mistral-vibe.targets.windows-x86_64] -archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.1/vibe-acp-windows-x86_64-2.7.1.zip" +archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.7.2/vibe-acp-windows-x86_64-2.7.2.zip" cmd = "./vibe-acp.exe" diff --git a/pyproject.toml b/pyproject.toml index 63f473b..3c0a45d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mistral-vibe" -version = "2.7.1" +version = "2.7.2" description = "Minimal CLI coding agent by Mistral" readme = "README.md" requires-python = ">=3.12" diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py index a1d0146..bcb7dcc 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.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.2" ) 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.1" + name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.7.2" ) assert response.auth_methods is not None diff --git a/tests/snapshots/test_ui_snapshot_narrator_flow.py b/tests/snapshots/test_ui_snapshot_narrator_flow.py index 142882b..f3e6d85 100644 --- a/tests/snapshots/test_ui_snapshot_narrator_flow.py +++ b/tests/snapshots/test_ui_snapshot_narrator_flow.py @@ -12,8 +12,9 @@ from tests.snapshots.snap_compare import SnapCompare from tests.stubs.fake_audio_player import FakeAudioPlayer from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_tts_client import FakeTTSClient +from vibe.cli.narrator_manager import NarratorManager, NarratorState import vibe.cli.textual_ui.widgets.narrator_status as narrator_status_mod -from vibe.cli.textual_ui.widgets.narrator_status import NarratorState, NarratorStatus +from vibe.cli.textual_ui.widgets.narrator_status import NarratorStatus from vibe.cli.turn_summary import TurnSummaryTracker narrator_status_mod.SHRINK_FRAMES = "█" @@ -65,6 +66,16 @@ class NarratorFlowApp(BaseSnapshotTestApp): mock_llm_chunk(content="Summary of the conversation") ) self.tts_gate = GatedTTSClient() + self.fake_audio_player = FakeAudioPlayer() + narrator_manager = NarratorManager( + config_getter=_narrator_config, audio_player=self.fake_audio_player + ) + # Override turn_summary and tts_client with gated test doubles. + narrator_manager._turn_summary = TurnSummaryTracker( + backend=self.summary_gate, model=_TEST_MODEL + ) + narrator_manager._turn_summary.on_summary = narrator_manager._on_turn_summary + narrator_manager._tts_client = self.tts_gate super().__init__( config=_narrator_config(), backend=FakeBackend( @@ -74,13 +85,7 @@ class NarratorFlowApp(BaseSnapshotTestApp): completion_tokens=2_500, ) ), - ) - self._tts_client = self.tts_gate - self._audio_player = FakeAudioPlayer() - self._turn_summary = TurnSummaryTracker( - backend=self.summary_gate, - model=_TEST_MODEL, - on_summary=self._on_turn_summary, + narrator_manager=narrator_manager, ) @@ -91,7 +96,7 @@ def test_snapshot_narrator_summarizing(snap_compare: SnapCompare) -> None: await pilot.press(*"Hello") await pilot.press("enter") await pilot.pause(0.5) - # end_turn has fired, SUMMARIZING is set, summary backend is gated + # on_turn_end has fired, SUMMARIZING is set, summary backend is gated assert app.summary_gate._gate.is_set() is False # Freeze animation at frame 0 for deterministic snapshot app.query_one(NarratorStatus)._stop_timer() @@ -137,8 +142,9 @@ def test_snapshot_narrator_idle_after_speaking(snap_compare: SnapCompare) -> Non app.tts_gate.release() await pilot.pause(0.2) # Simulate playback finishing (same thread, so call directly) - app._audio_player.stop() - app._set_narrator_state(NarratorState.IDLE) + app.fake_audio_player.stop() + narrator = cast(NarratorManager, app._narrator_manager) + narrator._set_state(NarratorState.IDLE) await pilot.pause(0.2) assert snap_compare( diff --git a/tests/test_ui_input_history.py b/tests/test_ui_input_history.py index 79b8d27..9d0c36b 100644 --- a/tests/test_ui_input_history.py +++ b/tests/test_ui_input_history.py @@ -9,6 +9,7 @@ from vibe.cli.history_manager import HistoryManager from vibe.cli.textual_ui.app import VibeApp from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody from vibe.cli.textual_ui.widgets.chat_input.container import ChatInputContainer +from vibe.cli.textual_ui.widgets.messages import UserMessage @pytest.fixture @@ -112,6 +113,29 @@ async def test_ui_does_not_prevent_arrow_down_to_move_cursor_to_bottom_lines( assert final_row == 1, f"cursor is still on line {final_row}." +@pytest.mark.asyncio +async def test_ui_alt_left_and_alt_right_move_by_word(vibe_app: VibeApp) -> None: + async with vibe_app.run_test() as pilot: + chat_input = vibe_app.query_one(ChatInputContainer) + textarea = chat_input.input_widget + assert textarea is not None + + await pilot.press(*"hello brave world") + assert textarea.cursor_location == (0, len("hello brave world")) + + await pilot.press("alt+left") + assert textarea.cursor_location == (0, len("hello brave ")) + + await pilot.press("alt+left") + assert textarea.cursor_location == (0, len("hello ")) + + await pilot.press("alt+right") + assert textarea.cursor_location == (0, len("hello brave")) + + assert chat_input.value == "hello brave world" + assert len(vibe_app.query(UserMessage)) == 0 + + @pytest.mark.asyncio async def test_ui_resumes_arrow_down_after_manual_move( vibe_app: VibeApp, tmp_path: Path diff --git a/tests/test_ui_rewind.py b/tests/test_ui_rewind.py index 5a86f6a..ffae36b 100644 --- a/tests/test_ui_rewind.py +++ b/tests/test_ui_rewind.py @@ -50,7 +50,7 @@ async def test_rewind_highlights_last_user_message() -> None: await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None - assert app._rewind_highlighted_widget._content == "world" + assert app._rewind_highlighted_widget.get_content() == "world" @pytest.mark.asyncio @@ -67,7 +67,7 @@ async def test_rewind_navigates_to_previous_message() -> None: await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None - assert app._rewind_highlighted_widget._content == "hello" + assert app._rewind_highlighted_widget.get_content() == "hello" @pytest.mark.asyncio @@ -88,7 +88,7 @@ async def test_rewind_navigates_down() -> None: await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None - assert app._rewind_highlighted_widget._content == "world" + assert app._rewind_highlighted_widget.get_content() == "world" @pytest.mark.asyncio @@ -123,7 +123,7 @@ async def test_rewind_ctrl_p_n_alternate_bindings() -> None: assert app._rewind_mode is True assert app._rewind_highlighted_widget is not None - assert app._rewind_highlighted_widget._content == "world" + assert app._rewind_highlighted_widget.get_content() == "world" # ctrl+p again goes to previous await pilot.press("ctrl+p") @@ -131,7 +131,7 @@ async def test_rewind_ctrl_p_n_alternate_bindings() -> None: await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None - assert app._rewind_highlighted_widget._content == "hello" + assert app._rewind_highlighted_widget.get_content() == "hello" # ctrl+n goes back await pilot.press("ctrl+n") @@ -139,7 +139,7 @@ async def test_rewind_ctrl_p_n_alternate_bindings() -> None: await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None - assert app._rewind_highlighted_widget._content == "world" + assert app._rewind_highlighted_widget.get_content() == "world" @pytest.mark.asyncio @@ -180,7 +180,7 @@ async def test_rewind_removes_messages_after_selected() -> None: await pilot.pause(0.1) assert app._rewind_highlighted_widget is not None - assert app._rewind_highlighted_widget._content == "second" + assert app._rewind_highlighted_widget.get_content() == "second" # Confirm await pilot.press("enter") @@ -193,7 +193,37 @@ async def test_rewind_removes_messages_after_selected() -> None: child for child in messages_area.children if isinstance(child, UserMessage) ] assert len(user_widgets) == 1 - assert user_widgets[0]._content == "first" + assert user_widgets[0].get_content() == "first" + + +@pytest.mark.asyncio +async def test_rewind_skips_command_messages() -> None: + """Slash-command echo messages (message_index=None) are not rewind-selectable.""" + app = _make_app() + async with app.run_test() as pilot: + await _send_messages(pilot, ["hello"]) + + # Simulate a slash command inserting a UserMessage without message_index + await app._mount_and_scroll(UserMessage("/model")) + await pilot.pause(0.1) + + await _send_messages(pilot, ["world"]) + + # First alt+up should land on "world", not the command message + await pilot.press("alt+up") + await pilot.app.workers.wait_for_complete() + await pilot.pause(0.1) + + assert app._rewind_highlighted_widget is not None + assert app._rewind_highlighted_widget.get_content() == "world" + + # Second alt+up should land on "hello", skipping the command message + await pilot.press("alt+up") + await pilot.app.workers.wait_for_complete() + await pilot.pause(0.1) + + assert app._rewind_highlighted_widget is not None + assert app._rewind_highlighted_widget.get_content() == "hello" @pytest.mark.asyncio diff --git a/tests/tools/test_bash.py b/tests/tools/test_bash.py index c27e9d8..00bd843 100644 --- a/tests/tools/test_bash.py +++ b/tests/tools/test_bash.py @@ -208,3 +208,66 @@ class TestResolvePermissionWindowsSyntax: ) assert isinstance(result, PermissionContext) assert result.permission is ToolPermission.ALWAYS + + +class TestDenylistWordBoundary: + """Verify denylist matches whole command names, not prefixes.""" + + def _make_bash(self, **kwargs) -> Bash: + config = BashToolConfig(**kwargs) + return Bash(config=config, state=BaseToolState()) + + def test_vi_blocks_vi_exact(self): + bash_tool = self._make_bash(denylist=["vi"]) + result = bash_tool.resolve_permission(BashArgs(command="vi")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER + + def test_vi_blocks_vi_with_args(self): + bash_tool = self._make_bash(denylist=["vi"]) + result = bash_tool.resolve_permission(BashArgs(command="vi file.txt")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER + + def test_vi_does_not_block_vibe(self): + bash_tool = self._make_bash(denylist=["vi"]) + result = bash_tool.resolve_permission(BashArgs(command="vibe -p hello")) + assert result is None or result.permission is not ToolPermission.NEVER + + def test_multiword_pattern_still_works(self): + bash_tool = self._make_bash(denylist=["bash -i"]) + result = bash_tool.resolve_permission(BashArgs(command="bash -i")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER + + def test_multiword_pattern_with_trailing_args(self): + bash_tool = self._make_bash(denylist=["bash -i"]) + result = bash_tool.resolve_permission(BashArgs(command="bash -i extra")) + assert isinstance(result, PermissionContext) + assert result.permission is ToolPermission.NEVER + + def test_multiword_pattern_does_not_match_partial(self): + bash_tool = self._make_bash(denylist=["bash -i"]) + result = bash_tool.resolve_permission(BashArgs(command="bash -init")) + assert result is None or result.permission is not ToolPermission.NEVER + + def test_deny_reason_is_set(self): + bash_tool = self._make_bash(denylist=["vim"]) + result = bash_tool.resolve_permission(BashArgs(command="vim file.txt")) + assert isinstance(result, PermissionContext) + assert result.reason is not None + assert "vim" in result.reason + + def test_standalone_deny_reason_is_set(self): + bash_tool = self._make_bash(denylist_standalone=["python"]) + result = bash_tool.resolve_permission(BashArgs(command="python")) + assert isinstance(result, PermissionContext) + assert result.reason is not None + assert result.permission is ToolPermission.NEVER + assert "python" in result.reason + assert "standalone" in result.reason + + def test_allowlist_does_not_match_prefix(self): + bash_tool = self._make_bash(allowlist=["cat"]) + result = bash_tool.resolve_permission(BashArgs(command="catalog")) + assert result is not None and result.permission is not ToolPermission.ALWAYS diff --git a/tests/tools/test_mcp.py b/tests/tools/test_mcp.py index 451796d..74e48b0 100644 --- a/tests/tools/test_mcp.py +++ b/tests/tools/test_mcp.py @@ -430,6 +430,21 @@ class TestMCPRegistry: assert len(registry._cache) == 0 + def test_count_loaded_excludes_failed_servers(self): + registry = MCPRegistry() + ok_srv = self._make_http_server("ok", url="http://ok:1") + fail_srv = self._make_http_server("fail", url="http://fail:2") + + proxy = create_mcp_http_proxy_tool_class( + url="http://ok:1", remote=RemoteTool(name="t"), alias="ok" + ) + registry._cache[registry._server_key(ok_srv)] = {proxy.get_name(): proxy} + + assert registry.count_loaded([ok_srv, fail_srv]) == 1 + assert registry.count_loaded([ok_srv]) == 1 + assert registry.count_loaded([fail_srv]) == 0 + assert registry.count_loaded([]) == 0 + def test_cache_survives_multiple_get_tools_calls(self): registry = MCPRegistry() srv = self._make_http_server("stable") diff --git a/uv.lock b/uv.lock index c24dcf4..f2337e8 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = ">=3.12" [options] -exclude-newer = "2026-03-24T11:14:04.796843Z" +exclude-newer = "2026-03-25T16:07:22.482308Z" exclude-newer-span = "P7D" [options.exclude-newer-package] @@ -787,7 +787,7 @@ wheels = [ [[package]] name = "mistral-vibe" -version = "2.7.1" +version = "2.7.2" source = { editable = "." } dependencies = [ { name = "agent-client-protocol" }, diff --git a/vibe-acp.spec b/vibe-acp.spec index 569b785..d0cb7b1 100644 --- a/vibe-acp.spec +++ b/vibe-acp.spec @@ -45,6 +45,7 @@ exe = EXE( pyz, a.scripts, [], + exclude_binaries=True, name='vibe-acp', debug=False, bootloader_ignore_signals=False, @@ -62,6 +63,7 @@ exe = EXE( coll = COLLECT( exe, a.binaries, + a.zipfiles, a.datas, strip=False, upx=True, diff --git a/vibe/__init__.py b/vibe/__init__.py index 9152801..b5630ed 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.1" +__version__ = "2.7.2" diff --git a/vibe/cli/narrator_manager/__init__.py b/vibe/cli/narrator_manager/__init__.py new file mode 100644 index 0000000..17c3be9 --- /dev/null +++ b/vibe/cli/narrator_manager/__init__.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from vibe.cli.narrator_manager.narrator_manager import NarratorManager +from vibe.cli.narrator_manager.narrator_manager_port import ( + NarratorManagerListener, + NarratorManagerPort, + NarratorState, +) + +__all__ = [ + "NarratorManager", + "NarratorManagerListener", + "NarratorManagerPort", + "NarratorState", +] diff --git a/vibe/cli/narrator_manager/narrator_manager.py b/vibe/cli/narrator_manager/narrator_manager.py new file mode 100644 index 0000000..826fef2 --- /dev/null +++ b/vibe/cli/narrator_manager/narrator_manager.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from vibe.cli.narrator_manager.narrator_manager_port import ( + NarratorManagerListener, + NarratorState, +) +from vibe.cli.turn_summary import ( + NoopTurnSummary, + TurnSummaryResult, + TurnSummaryTracker, + create_narrator_backend, +) +from vibe.core.audio_player.audio_player_port import AudioFormat +from vibe.core.logger import logger +from vibe.core.tts.factory import make_tts_client + +if TYPE_CHECKING: + from collections.abc import Callable + from typing import Any + + from vibe.cli.turn_summary import TurnSummaryPort + from vibe.core.audio_player.audio_player_port import AudioPlayerPort + from vibe.core.config import VibeConfig + from vibe.core.tts.tts_client_port import TTSClientPort + from vibe.core.types import BaseEvent + + +class NarratorManager: + def __init__( + self, config_getter: Callable[[], VibeConfig], audio_player: AudioPlayerPort + ) -> None: + self._config_getter = config_getter + self._audio_player = audio_player + config = config_getter() + self._turn_summary: TurnSummaryPort = self._make_turn_summary(config) + self._turn_summary.on_summary = self._on_turn_summary + self._tts_client: TTSClientPort | None = self._make_tts_client(config) + self._state = NarratorState.IDLE + self._speak_task: asyncio.Task[None] | None = None + self._cancel_summary: Callable[[], bool] | None = None + self._close_tasks: set[asyncio.Task[Any]] = set() + self._listeners: list[NarratorManagerListener] = [] + + @property + def state(self) -> NarratorState: + return self._state + + @property + def is_playing(self) -> bool: + return self._audio_player.is_playing + + @property + def turn_summary(self) -> TurnSummaryPort: + return self._turn_summary + + @turn_summary.setter + def turn_summary(self, value: TurnSummaryPort) -> None: + old = self._turn_summary + self._turn_summary = value + self._turn_summary.on_summary = self._on_turn_summary + task = asyncio.create_task(old.close()) + self._close_tasks.add(task) + task.add_done_callback(self._close_tasks.discard) + + @property + def tts_client(self) -> TTSClientPort | None: + return self._tts_client + + @tts_client.setter + def tts_client(self, value: TTSClientPort | None) -> None: + old = self._tts_client + self._tts_client = value + if old is not None: + task = asyncio.create_task(old.close()) + self._close_tasks.add(task) + task.add_done_callback(self._close_tasks.discard) + + def on_turn_start(self, user_message: str) -> None: + self._turn_summary.start_turn(user_message) + + def on_turn_event(self, event: BaseEvent) -> None: + self._turn_summary.track(event) + + def on_turn_error(self, message: str) -> None: + self._turn_summary.set_error(message) + + def on_turn_cancel(self) -> None: + self._turn_summary.cancel_turn() + + def on_turn_end(self) -> None: + cancel_summary = self._turn_summary.end_turn() + if ( + cancel_summary is not None + and self._config_getter().narrator_enabled + and self._tts_client is not None + ): + self._cancel_summary = cancel_summary + self._set_state(NarratorState.SUMMARIZING) + + def cancel(self) -> None: + if self._cancel_summary is not None: + self._cancel_summary() + self._cancel_summary = None + if self._speak_task is not None and not self._speak_task.done(): + self._speak_task.cancel() + self._speak_task = None + self._audio_player.stop() + self._set_state(NarratorState.IDLE) + + def sync(self) -> None: + self.cancel() + config = self._config_getter() + self.turn_summary = self._make_turn_summary(config) + self.tts_client = self._make_tts_client(config) + + @staticmethod + def _make_turn_summary(config: VibeConfig) -> NoopTurnSummary | TurnSummaryTracker: + if not config.narrator_enabled: + return NoopTurnSummary() + result = create_narrator_backend(config) + if result is None: + return NoopTurnSummary() + backend, model = result + return TurnSummaryTracker(backend=backend, model=model) + + @staticmethod + def _make_tts_client(config: VibeConfig) -> TTSClientPort | None: + if not config.narrator_enabled: + return None + try: + model = config.get_active_tts_model() + provider = config.get_tts_provider_for_model(model) + return make_tts_client(provider, model) + except (ValueError, KeyError) as exc: + logger.error("Failed to initialize TTS client", exc_info=exc) + return None + + def add_listener(self, listener: NarratorManagerListener) -> None: + if listener not in self._listeners: + self._listeners.append(listener) + + def remove_listener(self, listener: NarratorManagerListener) -> None: + try: + self._listeners.remove(listener) + except ValueError: + pass + + async def close(self) -> None: + self.cancel() + await self._turn_summary.close() + if self._tts_client is not None: + await self._tts_client.close() + for task in self._close_tasks: + task.cancel() + await asyncio.gather(*self._close_tasks, return_exceptions=True) + self._close_tasks.clear() + + def _on_turn_summary(self, result: TurnSummaryResult) -> None: + self._cancel_summary = None + if result.generation != self._turn_summary.generation: + self._set_state(NarratorState.IDLE) + return + if result.summary is None: + self._set_state(NarratorState.IDLE) + return + if self._tts_client is not None: + self._speak_task = asyncio.create_task(self._speak_summary(result.summary)) + else: + self._set_state(NarratorState.IDLE) + + async def _speak_summary(self, text: str) -> None: + if self._tts_client is None: + return + try: + loop = asyncio.get_running_loop() + tts_result = await self._tts_client.speak(text) + self._set_state(NarratorState.SPEAKING) + self._audio_player.play( + tts_result.audio_data, + AudioFormat.WAV, + on_finished=lambda: loop.call_soon_threadsafe( + self._set_state, NarratorState.IDLE + ), + ) + except Exception: + logger.warning("TTS speak failed", exc_info=True) + self._set_state(NarratorState.IDLE) + + def _set_state(self, state: NarratorState) -> None: + self._state = state + for listener in list(self._listeners): + try: + listener.on_narrator_state_change(state) + except Exception: + logger.warning("Narrator listener error", exc_info=True) diff --git a/vibe/cli/narrator_manager/narrator_manager_port.py b/vibe/cli/narrator_manager/narrator_manager_port.py new file mode 100644 index 0000000..a7fbe30 --- /dev/null +++ b/vibe/cli/narrator_manager/narrator_manager_port.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from enum import StrEnum, auto +from typing import Protocol + +from vibe.core.types import BaseEvent + + +class NarratorState(StrEnum): + IDLE = auto() + SUMMARIZING = auto() + SPEAKING = auto() + + +class NarratorManagerListener: + def on_narrator_state_change(self, state: NarratorState) -> None: + pass + + +class NarratorManagerPort(Protocol): + @property + def state(self) -> NarratorState: ... + + @property + def is_playing(self) -> bool: ... + + def on_turn_start(self, user_message: str) -> None: ... + + def on_turn_event(self, event: BaseEvent) -> None: ... + + def on_turn_error(self, message: str) -> None: ... + + def on_turn_cancel(self) -> None: ... + + def on_turn_end(self) -> None: ... + + def cancel(self) -> None: ... + + def sync(self) -> None: ... + + def add_listener(self, listener: NarratorManagerListener) -> None: ... + + def remove_listener(self, listener: NarratorManagerListener) -> None: ... + + async def close(self) -> None: ... diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py index 5381957..998afad 100644 --- a/vibe/cli/textual_ui/app.py +++ b/vibe/cli/textual_ui/app.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum, auto import gc @@ -26,6 +25,11 @@ from textual.widgets import Static from vibe import __version__ as CORE_VERSION from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.commands import CommandRegistry +from vibe.cli.narrator_manager import ( + NarratorManager, + NarratorManagerPort, + NarratorState, +) from vibe.cli.plan_offer.adapters.http_whoami_gateway import HttpWhoAmIGateway from vibe.cli.plan_offer.decide_plan_offer import ( PlanInfo, @@ -64,7 +68,7 @@ from vibe.cli.textual_ui.widgets.messages import ( WhatsNewMessage, ) from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp -from vibe.cli.textual_ui.widgets.narrator_status import NarratorState, NarratorStatus +from vibe.cli.textual_ui.widgets.narrator_status import NarratorStatus from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.cli.textual_ui.widgets.path_display import PathDisplay from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp @@ -85,13 +89,6 @@ from vibe.cli.textual_ui.windowing import ( should_resume_history, sync_backfill_state, ) -from vibe.cli.turn_summary import ( - NoopTurnSummary, - TurnSummaryPort, - TurnSummaryResult, - TurnSummaryTracker, - create_narrator_backend, -) from vibe.cli.update_notifier import ( FileSystemUpdateCacheRepository, PyPIUpdateGateway, @@ -109,7 +106,6 @@ from vibe.cli.voice_manager.voice_manager_port import TranscribeState from vibe.core.agent_loop import AgentLoop, TeleportError from vibe.core.agents import AgentProfile from vibe.core.audio_player.audio_player import AudioPlayer -from vibe.core.audio_player.audio_player_port import AudioFormat from vibe.core.audio_recorder import AudioRecorder from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.config import VibeConfig @@ -136,8 +132,6 @@ from vibe.core.tools.builtins.ask_user_question import ( ) from vibe.core.tools.permissions import RequiredPermission from vibe.core.transcribe import make_transcribe_client -from vibe.core.tts.factory import make_tts_client -from vibe.core.tts.tts_client_port import TTSClientPort from vibe.core.types import ( AgentStats, ApprovalResponse, @@ -291,6 +285,7 @@ class VibeApp(App): # noqa: PLR0904 plan_offer_gateway: WhoAmIGateway | None = None, terminal_notifier: NotificationPort | None = None, voice_manager: VoiceManagerPort | None = None, + narrator_manager: NarratorManagerPort | None = None, **kwargs: Any, ) -> None: super().__init__(**kwargs) @@ -348,12 +343,9 @@ class VibeApp(App): # noqa: PLR0904 self._cached_loading_area: Widget | None = None self._switch_agent_generation = 0 self._plan_info: PlanInfo | None = None - self._turn_summary: TurnSummaryPort = self._make_turn_summary() - self._turn_summary_close_tasks: set[asyncio.Task[Any]] = set() - self._tts_client: TTSClientPort | None = self._make_tts_client() - self._audio_player = AudioPlayer() - self._speak_task: asyncio.Task[None] | None = None - self._cancel_summary: Callable[[], bool] | None = None + self._narrator_manager: NarratorManagerPort = ( + narrator_manager or self._make_default_narrator_manager() + ) self._rewind_mode = False self._rewind_highlighted_widget: UserMessage | None = None @@ -364,12 +356,14 @@ class VibeApp(App): # noqa: PLR0904 def compose(self) -> ComposeResult: with ChatScroll(id="chat"): - self._banner = Banner(self.config, self.agent_loop.skill_manager) + self._banner = Banner( + self.config, self.agent_loop.skill_manager, self.agent_loop.mcp_registry + ) yield self._banner yield VerticalGroup(id="messages") with Horizontal(id="loading-area"): - yield NarratorStatus() + yield NarratorStatus(self._narrator_manager) yield Static(id="loading-area-content") yield FeedbackBar() @@ -616,7 +610,7 @@ class VibeApp(App): # noqa: PLR0904 if non_voice_changes: VibeConfig.save_updates(non_voice_changes) self.agent_loop.refresh_config() - self._sync_turn_summary() + self._narrator_manager.sync() async def on_model_picker_app_model_selected( self, message: ModelPickerApp.ModelSelected @@ -897,10 +891,10 @@ class VibeApp(App): # noqa: PLR0904 try: rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) - self._cancel_speak() - self._turn_summary.start_turn(rendered_prompt) + self._narrator_manager.cancel() + self._narrator_manager.on_turn_start(rendered_prompt) async for event in self.agent_loop.act(rendered_prompt): - self._turn_summary.track(event) + self._narrator_manager.on_turn_event(event) if self.event_handler: await self.event_handler.handle_event( event, @@ -910,7 +904,7 @@ class VibeApp(App): # noqa: PLR0904 except asyncio.CancelledError: await self._handle_turn_error() - self._turn_summary.cancel_turn() + self._narrator_manager.on_turn_cancel() raise except Exception as e: await self._handle_turn_error() @@ -918,20 +912,13 @@ class VibeApp(App): # noqa: PLR0904 message = str(e) if isinstance(e, RateLimitError): message = self._rate_limit_message() - self._turn_summary.set_error(message) + self._narrator_manager.on_turn_error(message) await self._mount_and_scroll( ErrorMessage(message, collapsed=self._tools_collapsed) ) finally: - cancel_summary = self._turn_summary.end_turn() - if ( - cancel_summary is not None - and self.config.narrator_enabled - and self._tts_client is not None - ): - self._cancel_summary = cancel_summary - self.query_one(NarratorStatus).state = NarratorState.SUMMARIZING + self._narrator_manager.on_turn_end() self._agent_running = False self._interrupt_requested = False self._agent_task = None @@ -1225,12 +1212,13 @@ class VibeApp(App): # noqa: PLR0904 await self.agent_loop.reload_with_initial_messages(base_config=base_config) await self._resolve_plan() - self._sync_turn_summary() + self._narrator_manager.sync() if self._banner: self._banner.set_state( base_config, self.agent_loop.skill_manager, + self.agent_loop.mcp_registry, plan_title(self._plan_info), ) await self._mount_and_scroll(UserCommandMessage("Configuration reloaded.")) @@ -1373,6 +1361,7 @@ class VibeApp(App): # noqa: PLR0904 return self.agent_loop.session_logger.session_id[:8] async def _exit_app(self) -> None: + await self._narrator_manager.close() self.exit(result=self._get_session_resume_info()) async def _setup_terminal(self) -> None: @@ -1583,10 +1572,16 @@ class VibeApp(App): # noqa: PLR0904 # --- Rewind mode --- def _get_user_message_widgets(self) -> list[UserMessage]: - """Return all UserMessage widgets currently visible in #messages.""" + """Return all UserMessage widgets currently visible in #messages. + + Only includes messages with a valid message_index (i.e. real user + messages, not slash-command echo messages). + """ messages_area = self._cached_messages_area or self.query_one("#messages") return [ - child for child in messages_area.children if isinstance(child, UserMessage) + child + for child in messages_area.children + if isinstance(child, UserMessage) and child.message_index is not None ] def _start_rewind_mode(self) -> None: @@ -1835,9 +1830,11 @@ class VibeApp(App): # noqa: PLR0904 self._handle_input_app_escape() return - narrator_status = self.query_one(NarratorStatus) - if self._audio_player.is_playing or narrator_status.state != NarratorState.IDLE: - self._cancel_speak() + if ( + self._narrator_manager.is_playing + or self._narrator_manager.state != NarratorState.IDLE + ): + self._narrator_manager.cancel() return if self._agent_running: @@ -1911,7 +1908,10 @@ class VibeApp(App): # noqa: PLR0904 def _refresh_banner(self) -> None: if self._banner: self._banner.set_state( - self.config, self.agent_loop.skill_manager, plan_title(self._plan_info) + self.config, + self.agent_loop.skill_manager, + self.agent_loop.mcp_registry, + plan_title(self._plan_info), ) def _update_profile_widgets(self, profile: AgentProfile) -> None: @@ -1966,6 +1966,7 @@ class VibeApp(App): # noqa: PLR0904 if self._agent_task and not self._agent_task.done(): self._agent_task.cancel() + self._narrator_manager.cancel() self.exit(result=self._get_session_resume_info()) def action_scroll_chat_up(self) -> None: @@ -2169,86 +2170,11 @@ class VibeApp(App): # noqa: PLR0904 # force a full layout refresh so the UI isn't garbled. self.refresh(layout=True) - def _make_turn_summary(self) -> TurnSummaryPort: - if not self.config.narrator_enabled: - return NoopTurnSummary() - result = create_narrator_backend(self.config) - if result is None: - return NoopTurnSummary() - backend, model = result - return TurnSummaryTracker( - backend=backend, model=model, on_summary=self._on_turn_summary + def _make_default_narrator_manager(self) -> NarratorManager: + return NarratorManager( + config_getter=lambda: self.config, audio_player=AudioPlayer() ) - def _on_turn_summary(self, result: TurnSummaryResult) -> None: - self._cancel_summary = None - if result.generation != self._turn_summary.generation: - self._set_narrator_state(NarratorState.IDLE) - return - if result.summary is None: - self._set_narrator_state(NarratorState.IDLE) - return - if self._tts_client is not None: - self._speak_task = asyncio.create_task(self._speak_summary(result.summary)) - else: - self._set_narrator_state(NarratorState.IDLE) - - async def _speak_summary(self, text: str) -> None: - if self._tts_client is None: - return - try: - loop = asyncio.get_running_loop() - tts_result = await self._tts_client.speak(text) - self._set_narrator_state(NarratorState.SPEAKING) - self._audio_player.play( - tts_result.audio_data, - AudioFormat.WAV, - on_finished=lambda: loop.call_soon_threadsafe( - self._set_narrator_state, NarratorState.IDLE - ), - ) - except Exception: - logger.warning("TTS speak failed", exc_info=True) - self._set_narrator_state(NarratorState.IDLE) - - def _cancel_speak(self) -> None: - if self._cancel_summary is not None: - self._cancel_summary() - self._cancel_summary = None - if self._speak_task is not None and not self._speak_task.done(): - self._speak_task.cancel() - self._speak_task = None - self._audio_player.stop() - self._set_narrator_state(NarratorState.IDLE) - - def _set_narrator_state(self, state: NarratorState) -> None: - self.query_one(NarratorStatus).state = state - - def _make_tts_client(self) -> TTSClientPort | None: - if not self.config.narrator_enabled: - return None - try: - model = self.config.get_active_tts_model() - provider = self.config.get_tts_provider_for_model(model) - return make_tts_client(provider, model) - except (ValueError, KeyError) as exc: - logger.error("Failed to initialize TTS client", exc_info=exc) - return None - - def _sync_turn_summary(self) -> None: - self._cancel_speak() - task = asyncio.create_task(self._turn_summary.close()) - self._turn_summary_close_tasks.add(task) - task.add_done_callback(self._turn_summary_close_tasks.discard) - self._turn_summary = self._make_turn_summary() - - old_tts = self._tts_client - self._tts_client = self._make_tts_client() - if old_tts is not None: - close_task = asyncio.create_task(old_tts.close()) - self._turn_summary_close_tasks.add(close_task) - close_task.add_done_callback(self._turn_summary_close_tasks.discard) - def run_textual_ui( agent_loop: AgentLoop, startup: StartupOptions | None = None diff --git a/vibe/cli/textual_ui/widgets/banner/banner.py b/vibe/cli/textual_ui/widgets/banner/banner.py index 9451b39..9477066 100644 --- a/vibe/cli/textual_ui/widgets/banner/banner.py +++ b/vibe/cli/textual_ui/widgets/banner/banner.py @@ -13,6 +13,7 @@ from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic from vibe.core.config import VibeConfig from vibe.core.skills.manager import SkillManager +from vibe.core.tools.mcp.registry import MCPRegistry @dataclass @@ -28,14 +29,18 @@ class Banner(Static): state = reactive(BannerState(), init=False) def __init__( - self, config: VibeConfig, skill_manager: SkillManager, **kwargs: Any + self, + config: VibeConfig, + skill_manager: SkillManager, + mcp_registry: MCPRegistry, + **kwargs: Any, ) -> None: super().__init__(**kwargs) self.can_focus = False self._initial_state = BannerState( active_model=config.active_model, models_count=len(config.models), - mcp_servers_count=len(config.mcp_servers), + mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers), skills_count=len(skill_manager.available_skills), plan_description=None, ) @@ -77,12 +82,13 @@ class Banner(Static): self, config: VibeConfig, skill_manager: SkillManager, + mcp_registry: MCPRegistry, plan_description: str | None = None, ) -> None: self.state = BannerState( active_model=config.active_model, models_count=len(config.models), - mcp_servers_count=len(config.mcp_servers), + mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers), skills_count=len(skill_manager.available_skills), plan_description=plan_description, ) diff --git a/vibe/cli/textual_ui/widgets/chat_input/text_area.py b/vibe/cli/textual_ui/widgets/chat_input/text_area.py index 835a260..31a915a 100644 --- a/vibe/cli/textual_ui/widgets/chat_input/text_area.py +++ b/vibe/cli/textual_ui/widgets/chat_input/text_area.py @@ -31,6 +31,8 @@ class ChatTextArea(TextArea): show=False, priority=True, ), + Binding("alt+left", "cursor_word_left", "Cursor word left", show=False), + Binding("alt+right", "cursor_word_right", "Cursor word right", show=False), Binding("ctrl+g", "open_external_editor", "External Editor", show=False), ] diff --git a/vibe/cli/textual_ui/widgets/narrator_status.py b/vibe/cli/textual_ui/widgets/narrator_status.py index 4b22ed9..6711811 100644 --- a/vibe/cli/textual_ui/widgets/narrator_status.py +++ b/vibe/cli/textual_ui/widgets/narrator_status.py @@ -1,31 +1,40 @@ from __future__ import annotations -from enum import StrEnum, auto from typing import Any from textual.reactive import reactive from textual.timer import Timer from textual.widgets import Static +from vibe.cli.narrator_manager.narrator_manager_port import ( + NarratorManagerListener, + NarratorManagerPort, + NarratorState, +) + SHRINK_FRAMES = "█▇▆▅▄▃▂▁" BAR_FRAMES = ["▂▅▇", "▃▆▅", "▅▃▇", "▇▂▅", "▅▇▃", "▃▅▆"] ANIMATION_INTERVAL = 0.15 -class NarratorState(StrEnum): - IDLE = auto() - SUMMARIZING = auto() - SPEAKING = auto() - - -class NarratorStatus(Static): +class NarratorStatus(NarratorManagerListener, Static): state = reactive(NarratorState.IDLE) - def __init__(self, **kwargs: Any) -> None: + def __init__(self, narrator_manager: NarratorManagerPort, **kwargs: Any) -> None: super().__init__("", **kwargs) + self._narrator_manager = narrator_manager self._timer: Timer | None = None self._frame: int = 0 + def on_mount(self) -> None: + self._narrator_manager.add_listener(self) + + def on_unmount(self) -> None: + self._narrator_manager.remove_listener(self) + + def on_narrator_state_change(self, state: NarratorState) -> None: + self.state = state + def watch_state(self, new_state: NarratorState) -> None: self._stop_timer() match new_state: diff --git a/vibe/cli/turn_summary/noop.py b/vibe/cli/turn_summary/noop.py index cd1a41a..3d176cb 100644 --- a/vibe/cli/turn_summary/noop.py +++ b/vibe/cli/turn_summary/noop.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable -from vibe.cli.turn_summary.port import TurnSummaryPort +from vibe.cli.turn_summary.port import TurnSummaryPort, TurnSummaryResult from vibe.core.types import BaseEvent @@ -11,6 +11,14 @@ class NoopTurnSummary(TurnSummaryPort): def generation(self) -> int: return 0 + @property + def on_summary(self) -> Callable[[TurnSummaryResult], None] | None: + return None + + @on_summary.setter + def on_summary(self, value: Callable[[TurnSummaryResult], None] | None) -> None: + pass + def start_turn(self, user_message: str) -> None: pass diff --git a/vibe/cli/turn_summary/port.py b/vibe/cli/turn_summary/port.py index 295dd2b..d55280d 100644 --- a/vibe/cli/turn_summary/port.py +++ b/vibe/cli/turn_summary/port.py @@ -24,6 +24,14 @@ class TurnSummaryPort(ABC): @abstractmethod def generation(self) -> int: ... + @property + @abstractmethod + def on_summary(self) -> Callable[[TurnSummaryResult], None] | None: ... + + @on_summary.setter + @abstractmethod + def on_summary(self, value: Callable[[TurnSummaryResult], None] | None) -> None: ... + @abstractmethod def start_turn(self, user_message: str) -> None: ... diff --git a/vibe/cli/turn_summary/tracker.py b/vibe/cli/turn_summary/tracker.py index b47a014..840fa52 100644 --- a/vibe/cli/turn_summary/tracker.py +++ b/vibe/cli/turn_summary/tracker.py @@ -21,7 +21,7 @@ class TurnSummaryTracker(TurnSummaryPort): self, backend: BackendLike, model: ModelConfig, - on_summary: Callable[[TurnSummaryResult], None], + on_summary: Callable[[TurnSummaryResult], None] | None = None, max_tokens: int = 512, ) -> None: self._backend = backend @@ -36,6 +36,14 @@ class TurnSummaryTracker(TurnSummaryPort): def generation(self) -> int: return self._generation + @property + def on_summary(self) -> Callable[[TurnSummaryResult], None] | None: + return self._on_summary + + @on_summary.setter + def on_summary(self, value: Callable[[TurnSummaryResult], None] | None) -> None: + self._on_summary = value + def start_turn(self, user_message: str) -> None: self._generation += 1 self._data = TurnSummaryData(user_message=user_message) @@ -103,7 +111,9 @@ class TurnSummaryTracker(TurnSummaryPort): ) summary = result.message.content or "" - self._on_summary(TurnSummaryResult(generation=gen, summary=summary)) + if self._on_summary is not None: + self._on_summary(TurnSummaryResult(generation=gen, summary=summary)) except Exception: logger.warning("Turn summary generation failed", exc_info=True) - self._on_summary(TurnSummaryResult(generation=gen, summary=None)) + if self._on_summary is not None: + self._on_summary(TurnSummaryResult(generation=gen, summary=None)) diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py index a7f77bc..e5a24be 100644 --- a/vibe/core/agent_loop.py +++ b/vibe/core/agent_loop.py @@ -168,9 +168,9 @@ class AgentLoop: self.agent_manager = AgentManager( lambda: self._base_config, initial_agent=agent_name ) - self._mcp_registry = MCPRegistry() + self.mcp_registry = MCPRegistry() self.tool_manager = ToolManager( - lambda: self.config, mcp_registry=self._mcp_registry + lambda: self.config, mcp_registry=self.mcp_registry ) self.skill_manager = SkillManager(lambda: self.config) self.format_handler = APIToolFormatHandler() @@ -1029,7 +1029,8 @@ class AgentLoop: return ToolDecision( verdict=ToolExecutionResponse.SKIP, approval_type=ToolPermission.NEVER, - feedback=f"Tool '{tool_name}' is permanently disabled", + feedback=ctx.reason + or f"Tool '{tool_name}' is permanently disabled", ) case _: uncovered = [ @@ -1267,7 +1268,7 @@ class AgentLoop: self._max_price = max_price self.tool_manager = ToolManager( - lambda: self.config, mcp_registry=self._mcp_registry + lambda: self.config, mcp_registry=self.mcp_registry ) self.skill_manager = SkillManager(lambda: self.config) diff --git a/vibe/core/tools/builtins/bash.py b/vibe/core/tools/builtins/bash.py index 559b43b..ebd1e7a 100644 --- a/vibe/core/tools/builtins/bash.py +++ b/vibe/core/tools/builtins/bash.py @@ -306,8 +306,13 @@ class Bash( if not command_parts: return None - def is_denylisted(command: str) -> bool: - return any(command.startswith(pattern) for pattern in self.config.denylist) + def _matches_pattern(command: str, pattern: str) -> bool: + return command == pattern or command.startswith(pattern + " ") + + def find_denylist_match(command: str) -> str | None: + return next( + (p for p in self.config.denylist if _matches_pattern(command, p)), None + ) def is_standalone_denylisted(command: str) -> bool: parts = command.split() @@ -323,7 +328,9 @@ class Bash( return False def is_allowlisted(command: str) -> bool: - return any(command.startswith(pattern) for pattern in self.config.allowlist) + return any( + _matches_pattern(command, pattern) for pattern in self.config.allowlist + ) def is_sensitive(command: str) -> bool: tokens = command.split() @@ -332,8 +339,16 @@ class Bash( return tokens[0] in self.config.sensitive_patterns for part in command_parts: - if is_denylisted(part) or is_standalone_denylisted(part): - return PermissionContext(permission=ToolPermission.NEVER) + if matched := find_denylist_match(part): + return PermissionContext( + permission=ToolPermission.NEVER, + reason=f"Command denied: '{part}' matches denylist pattern '{matched}'. Do not attempt to run this command.", + ) + if is_standalone_denylisted(part): + return PermissionContext( + permission=ToolPermission.NEVER, + reason=f"Command denied: '{part}' is not allowed as a standalone command. Do not attempt to run this command.", + ) if self.config.permission == ToolPermission.ALWAYS: return PermissionContext(permission=ToolPermission.ALWAYS) diff --git a/vibe/core/tools/mcp/registry.py b/vibe/core/tools/mcp/registry.py index 85b1bcf..d2b787e 100644 --- a/vibe/core/tools/mcp/registry.py +++ b/vibe/core/tools/mcp/registry.py @@ -162,6 +162,10 @@ class MCPRegistry: ) return tools + def count_loaded(self, servers: list[MCPServer]) -> int: + """Return how many of *servers* were successfully discovered (cached).""" + return sum(self._server_key(srv) in self._cache for srv in servers) + def clear(self) -> None: """Drop all cached entries, forcing re-discovery on next use.""" self._cache.clear() diff --git a/vibe/core/tools/permissions.py b/vibe/core/tools/permissions.py index 357a619..25230b3 100644 --- a/vibe/core/tools/permissions.py +++ b/vibe/core/tools/permissions.py @@ -24,6 +24,7 @@ class RequiredPermission(BaseModel): class PermissionContext(BaseModel): permission: ToolPermission required_permissions: list[RequiredPermission] = Field(default_factory=list) + reason: str | None = None class ApprovedRule(BaseModel):