Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert
2026-04-01 18:38:22 +02:00
committed by GitHub
parent 54b9a17457
commit 9c1c32e058
28 changed files with 587 additions and 179 deletions

View File

@@ -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

2
.vscode/launch.json vendored
View File

@@ -1,5 +1,5 @@
{
"version": "2.7.1",
"version": "2.7.2",
"configurations": [
{
"name": "ACP Server",

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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")

4
uv.lock generated
View File

@@ -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" },

View File

@@ -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,

View File

@@ -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"

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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: ...

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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),
]

View File

@@ -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:

View File

@@ -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

View File

@@ -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: ...

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()

View File

@@ -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):