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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 587 additions and 179 deletions

View file

@ -64,8 +64,10 @@ jobs:
enable-cache: true enable-cache: true
cache-dependency-glob: "uv.lock" cache-dependency-glob: "uv.lock"
- name: Install Python - name: Set up Python
run: uv python install 3.12 uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.12"
- name: Sync dependencies - name: Sync dependencies
run: uv sync --no-dev --group build 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": [ "configurations": [
{ {
"name": "ACP Server", "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/), 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). 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 ## [2.7.1] - 2026-03-31
### Added ### Added

View file

@ -1,7 +1,7 @@
id = "mistral-vibe" id = "mistral-vibe"
name = "Mistral Vibe" name = "Mistral Vibe"
description = "Mistral's open-source coding assistant" description = "Mistral's open-source coding assistant"
version = "2.7.1" version = "2.7.2"
schema_version = 1 schema_version = 1
authors = ["Mistral AI"] authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe" repository = "https://github.com/mistralai/mistral-vibe"
@ -11,25 +11,25 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg" icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64] [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" cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64] [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" cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64] [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" cmd = "./vibe-acp.exe"

View file

@ -1,6 +1,6 @@
[project] [project]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.7.1" version = "2.7.2"
description = "Minimal CLI coding agent by Mistral" description = "Minimal CLI coding agent by Mistral"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View file

@ -28,7 +28,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()), session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
) )
assert response.agent_info == Implementation( 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 == [] assert response.auth_methods == []
@ -52,7 +52,7 @@ class TestACPInitialize:
session_capabilities=SessionCapabilities(list=SessionListCapabilities()), session_capabilities=SessionCapabilities(list=SessionListCapabilities()),
) )
assert response.agent_info == Implementation( 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 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_audio_player import FakeAudioPlayer
from tests.stubs.fake_backend import FakeBackend from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_tts_client import FakeTTSClient 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 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 from vibe.cli.turn_summary import TurnSummaryTracker
narrator_status_mod.SHRINK_FRAMES = "" narrator_status_mod.SHRINK_FRAMES = ""
@ -65,6 +66,16 @@ class NarratorFlowApp(BaseSnapshotTestApp):
mock_llm_chunk(content="Summary of the conversation") mock_llm_chunk(content="Summary of the conversation")
) )
self.tts_gate = GatedTTSClient() 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__( super().__init__(
config=_narrator_config(), config=_narrator_config(),
backend=FakeBackend( backend=FakeBackend(
@ -74,13 +85,7 @@ class NarratorFlowApp(BaseSnapshotTestApp):
completion_tokens=2_500, completion_tokens=2_500,
) )
), ),
) narrator_manager=narrator_manager,
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,
) )
@ -91,7 +96,7 @@ def test_snapshot_narrator_summarizing(snap_compare: SnapCompare) -> None:
await pilot.press(*"Hello") await pilot.press(*"Hello")
await pilot.press("enter") await pilot.press("enter")
await pilot.pause(0.5) 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 assert app.summary_gate._gate.is_set() is False
# Freeze animation at frame 0 for deterministic snapshot # Freeze animation at frame 0 for deterministic snapshot
app.query_one(NarratorStatus)._stop_timer() 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() app.tts_gate.release()
await pilot.pause(0.2) await pilot.pause(0.2)
# Simulate playback finishing (same thread, so call directly) # Simulate playback finishing (same thread, so call directly)
app._audio_player.stop() app.fake_audio_player.stop()
app._set_narrator_state(NarratorState.IDLE) narrator = cast(NarratorManager, app._narrator_manager)
narrator._set_state(NarratorState.IDLE)
await pilot.pause(0.2) await pilot.pause(0.2)
assert snap_compare( 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.app import VibeApp
from vibe.cli.textual_ui.widgets.chat_input.body import ChatInputBody 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.chat_input.container import ChatInputContainer
from vibe.cli.textual_ui.widgets.messages import UserMessage
@pytest.fixture @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}." 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 @pytest.mark.asyncio
async def test_ui_resumes_arrow_down_after_manual_move( async def test_ui_resumes_arrow_down_after_manual_move(
vibe_app: VibeApp, tmp_path: Path 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) await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None 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 @pytest.mark.asyncio
@ -67,7 +67,7 @@ async def test_rewind_navigates_to_previous_message() -> None:
await pilot.pause(0.1) await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None 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 @pytest.mark.asyncio
@ -88,7 +88,7 @@ async def test_rewind_navigates_down() -> None:
await pilot.pause(0.1) await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None 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 @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_mode is True
assert app._rewind_highlighted_widget is not None 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 # ctrl+p again goes to previous
await pilot.press("ctrl+p") await pilot.press("ctrl+p")
@ -131,7 +131,7 @@ async def test_rewind_ctrl_p_n_alternate_bindings() -> None:
await pilot.pause(0.1) await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None 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 # ctrl+n goes back
await pilot.press("ctrl+n") await pilot.press("ctrl+n")
@ -139,7 +139,7 @@ async def test_rewind_ctrl_p_n_alternate_bindings() -> None:
await pilot.pause(0.1) await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None 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 @pytest.mark.asyncio
@ -180,7 +180,7 @@ async def test_rewind_removes_messages_after_selected() -> None:
await pilot.pause(0.1) await pilot.pause(0.1)
assert app._rewind_highlighted_widget is not None assert app._rewind_highlighted_widget is not None
assert app._rewind_highlighted_widget._content == "second" assert app._rewind_highlighted_widget.get_content() == "second"
# Confirm # Confirm
await pilot.press("enter") 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) child for child in messages_area.children if isinstance(child, UserMessage)
] ]
assert len(user_widgets) == 1 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 @pytest.mark.asyncio

View file

@ -208,3 +208,66 @@ class TestResolvePermissionWindowsSyntax:
) )
assert isinstance(result, PermissionContext) assert isinstance(result, PermissionContext)
assert result.permission is ToolPermission.ALWAYS 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 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): def test_cache_survives_multiple_get_tools_calls(self):
registry = MCPRegistry() registry = MCPRegistry()
srv = self._make_http_server("stable") srv = self._make_http_server("stable")

4
uv.lock generated
View file

@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[options] [options]
exclude-newer = "2026-03-24T11:14:04.796843Z" exclude-newer = "2026-03-25T16:07:22.482308Z"
exclude-newer-span = "P7D" exclude-newer-span = "P7D"
[options.exclude-newer-package] [options.exclude-newer-package]
@ -787,7 +787,7 @@ wheels = [
[[package]] [[package]]
name = "mistral-vibe" name = "mistral-vibe"
version = "2.7.1" version = "2.7.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "agent-client-protocol" }, { name = "agent-client-protocol" },

View file

@ -45,6 +45,7 @@ exe = EXE(
pyz, pyz,
a.scripts, a.scripts,
[], [],
exclude_binaries=True,
name='vibe-acp', name='vibe-acp',
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
@ -62,6 +63,7 @@ exe = EXE(
coll = COLLECT( coll = COLLECT(
exe, exe,
a.binaries, a.binaries,
a.zipfiles,
a.datas, a.datas,
strip=False, strip=False,
upx=True, upx=True,

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
VIBE_ROOT = Path(__file__).parent 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 from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from enum import StrEnum, auto from enum import StrEnum, auto
import gc import gc
@ -26,6 +25,11 @@ from textual.widgets import Static
from vibe import __version__ as CORE_VERSION from vibe import __version__ as CORE_VERSION
from vibe.cli.clipboard import copy_selection_to_clipboard from vibe.cli.clipboard import copy_selection_to_clipboard
from vibe.cli.commands import CommandRegistry 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.adapters.http_whoami_gateway import HttpWhoAmIGateway
from vibe.cli.plan_offer.decide_plan_offer import ( from vibe.cli.plan_offer.decide_plan_offer import (
PlanInfo, PlanInfo,
@ -64,7 +68,7 @@ from vibe.cli.textual_ui.widgets.messages import (
WhatsNewMessage, WhatsNewMessage,
) )
from vibe.cli.textual_ui.widgets.model_picker import ModelPickerApp 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.no_markup_static import NoMarkupStatic
from vibe.cli.textual_ui.widgets.path_display import PathDisplay from vibe.cli.textual_ui.widgets.path_display import PathDisplay
from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp from vibe.cli.textual_ui.widgets.proxy_setup_app import ProxySetupApp
@ -85,13 +89,6 @@ from vibe.cli.textual_ui.windowing import (
should_resume_history, should_resume_history,
sync_backfill_state, sync_backfill_state,
) )
from vibe.cli.turn_summary import (
NoopTurnSummary,
TurnSummaryPort,
TurnSummaryResult,
TurnSummaryTracker,
create_narrator_backend,
)
from vibe.cli.update_notifier import ( from vibe.cli.update_notifier import (
FileSystemUpdateCacheRepository, FileSystemUpdateCacheRepository,
PyPIUpdateGateway, 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.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile from vibe.core.agents import AgentProfile
from vibe.core.audio_player.audio_player import AudioPlayer 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.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig 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.tools.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client 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 ( from vibe.core.types import (
AgentStats, AgentStats,
ApprovalResponse, ApprovalResponse,
@ -291,6 +285,7 @@ class VibeApp(App): # noqa: PLR0904
plan_offer_gateway: WhoAmIGateway | None = None, plan_offer_gateway: WhoAmIGateway | None = None,
terminal_notifier: NotificationPort | None = None, terminal_notifier: NotificationPort | None = None,
voice_manager: VoiceManagerPort | None = None, voice_manager: VoiceManagerPort | None = None,
narrator_manager: NarratorManagerPort | None = None,
**kwargs: Any, **kwargs: Any,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
@ -348,12 +343,9 @@ class VibeApp(App): # noqa: PLR0904
self._cached_loading_area: Widget | None = None self._cached_loading_area: Widget | None = None
self._switch_agent_generation = 0 self._switch_agent_generation = 0
self._plan_info: PlanInfo | None = None self._plan_info: PlanInfo | None = None
self._turn_summary: TurnSummaryPort = self._make_turn_summary() self._narrator_manager: NarratorManagerPort = (
self._turn_summary_close_tasks: set[asyncio.Task[Any]] = set() narrator_manager or self._make_default_narrator_manager()
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._rewind_mode = False self._rewind_mode = False
self._rewind_highlighted_widget: UserMessage | None = None self._rewind_highlighted_widget: UserMessage | None = None
@ -364,12 +356,14 @@ class VibeApp(App): # noqa: PLR0904
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with ChatScroll(id="chat"): 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 self._banner
yield VerticalGroup(id="messages") yield VerticalGroup(id="messages")
with Horizontal(id="loading-area"): with Horizontal(id="loading-area"):
yield NarratorStatus() yield NarratorStatus(self._narrator_manager)
yield Static(id="loading-area-content") yield Static(id="loading-area-content")
yield FeedbackBar() yield FeedbackBar()
@ -616,7 +610,7 @@ class VibeApp(App): # noqa: PLR0904
if non_voice_changes: if non_voice_changes:
VibeConfig.save_updates(non_voice_changes) VibeConfig.save_updates(non_voice_changes)
self.agent_loop.refresh_config() self.agent_loop.refresh_config()
self._sync_turn_summary() self._narrator_manager.sync()
async def on_model_picker_app_model_selected( async def on_model_picker_app_model_selected(
self, message: ModelPickerApp.ModelSelected self, message: ModelPickerApp.ModelSelected
@ -897,10 +891,10 @@ class VibeApp(App): # noqa: PLR0904
try: try:
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd()) rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._cancel_speak() self._narrator_manager.cancel()
self._turn_summary.start_turn(rendered_prompt) self._narrator_manager.on_turn_start(rendered_prompt)
async for event in self.agent_loop.act(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: if self.event_handler:
await self.event_handler.handle_event( await self.event_handler.handle_event(
event, event,
@ -910,7 +904,7 @@ class VibeApp(App): # noqa: PLR0904
except asyncio.CancelledError: except asyncio.CancelledError:
await self._handle_turn_error() await self._handle_turn_error()
self._turn_summary.cancel_turn() self._narrator_manager.on_turn_cancel()
raise raise
except Exception as e: except Exception as e:
await self._handle_turn_error() await self._handle_turn_error()
@ -918,20 +912,13 @@ class VibeApp(App): # noqa: PLR0904
message = str(e) message = str(e)
if isinstance(e, RateLimitError): if isinstance(e, RateLimitError):
message = self._rate_limit_message() message = self._rate_limit_message()
self._turn_summary.set_error(message) self._narrator_manager.on_turn_error(message)
await self._mount_and_scroll( await self._mount_and_scroll(
ErrorMessage(message, collapsed=self._tools_collapsed) ErrorMessage(message, collapsed=self._tools_collapsed)
) )
finally: finally:
cancel_summary = self._turn_summary.end_turn() self._narrator_manager.on_turn_end()
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._agent_running = False self._agent_running = False
self._interrupt_requested = False self._interrupt_requested = False
self._agent_task = None 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.agent_loop.reload_with_initial_messages(base_config=base_config)
await self._resolve_plan() await self._resolve_plan()
self._sync_turn_summary() self._narrator_manager.sync()
if self._banner: if self._banner:
self._banner.set_state( self._banner.set_state(
base_config, base_config,
self.agent_loop.skill_manager, self.agent_loop.skill_manager,
self.agent_loop.mcp_registry,
plan_title(self._plan_info), plan_title(self._plan_info),
) )
await self._mount_and_scroll(UserCommandMessage("Configuration reloaded.")) 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] return self.agent_loop.session_logger.session_id[:8]
async def _exit_app(self) -> None: async def _exit_app(self) -> None:
await self._narrator_manager.close()
self.exit(result=self._get_session_resume_info()) self.exit(result=self._get_session_resume_info())
async def _setup_terminal(self) -> None: async def _setup_terminal(self) -> None:
@ -1583,10 +1572,16 @@ class VibeApp(App): # noqa: PLR0904
# --- Rewind mode --- # --- Rewind mode ---
def _get_user_message_widgets(self) -> list[UserMessage]: 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") messages_area = self._cached_messages_area or self.query_one("#messages")
return [ 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: def _start_rewind_mode(self) -> None:
@ -1835,9 +1830,11 @@ class VibeApp(App): # noqa: PLR0904
self._handle_input_app_escape() self._handle_input_app_escape()
return return
narrator_status = self.query_one(NarratorStatus) if (
if self._audio_player.is_playing or narrator_status.state != NarratorState.IDLE: self._narrator_manager.is_playing
self._cancel_speak() or self._narrator_manager.state != NarratorState.IDLE
):
self._narrator_manager.cancel()
return return
if self._agent_running: if self._agent_running:
@ -1911,7 +1908,10 @@ class VibeApp(App): # noqa: PLR0904
def _refresh_banner(self) -> None: def _refresh_banner(self) -> None:
if self._banner: if self._banner:
self._banner.set_state( 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: 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(): if self._agent_task and not self._agent_task.done():
self._agent_task.cancel() self._agent_task.cancel()
self._narrator_manager.cancel()
self.exit(result=self._get_session_resume_info()) self.exit(result=self._get_session_resume_info())
def action_scroll_chat_up(self) -> None: 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. # force a full layout refresh so the UI isn't garbled.
self.refresh(layout=True) self.refresh(layout=True)
def _make_turn_summary(self) -> TurnSummaryPort: def _make_default_narrator_manager(self) -> NarratorManager:
if not self.config.narrator_enabled: return NarratorManager(
return NoopTurnSummary() config_getter=lambda: self.config, audio_player=AudioPlayer()
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 _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( def run_textual_ui(
agent_loop: AgentLoop, startup: StartupOptions | None = None 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.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import VibeConfig from vibe.core.config import VibeConfig
from vibe.core.skills.manager import SkillManager from vibe.core.skills.manager import SkillManager
from vibe.core.tools.mcp.registry import MCPRegistry
@dataclass @dataclass
@ -28,14 +29,18 @@ class Banner(Static):
state = reactive(BannerState(), init=False) state = reactive(BannerState(), init=False)
def __init__( def __init__(
self, config: VibeConfig, skill_manager: SkillManager, **kwargs: Any self,
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
**kwargs: Any,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.can_focus = False self.can_focus = False
self._initial_state = BannerState( self._initial_state = BannerState(
active_model=config.active_model, active_model=config.active_model,
models_count=len(config.models), 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), skills_count=len(skill_manager.available_skills),
plan_description=None, plan_description=None,
) )
@ -77,12 +82,13 @@ class Banner(Static):
self, self,
config: VibeConfig, config: VibeConfig,
skill_manager: SkillManager, skill_manager: SkillManager,
mcp_registry: MCPRegistry,
plan_description: str | None = None, plan_description: str | None = None,
) -> None: ) -> None:
self.state = BannerState( self.state = BannerState(
active_model=config.active_model, active_model=config.active_model,
models_count=len(config.models), 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), skills_count=len(skill_manager.available_skills),
plan_description=plan_description, plan_description=plan_description,
) )

View file

@ -31,6 +31,8 @@ class ChatTextArea(TextArea):
show=False, show=False,
priority=True, 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), Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
] ]

View file

@ -1,31 +1,40 @@
from __future__ import annotations from __future__ import annotations
from enum import StrEnum, auto
from typing import Any from typing import Any
from textual.reactive import reactive from textual.reactive import reactive
from textual.timer import Timer from textual.timer import Timer
from textual.widgets import Static from textual.widgets import Static
from vibe.cli.narrator_manager.narrator_manager_port import (
NarratorManagerListener,
NarratorManagerPort,
NarratorState,
)
SHRINK_FRAMES = "█▇▆▅▄▃▂▁" SHRINK_FRAMES = "█▇▆▅▄▃▂▁"
BAR_FRAMES = ["▂▅▇", "▃▆▅", "▅▃▇", "▇▂▅", "▅▇▃", "▃▅▆"] BAR_FRAMES = ["▂▅▇", "▃▆▅", "▅▃▇", "▇▂▅", "▅▇▃", "▃▅▆"]
ANIMATION_INTERVAL = 0.15 ANIMATION_INTERVAL = 0.15
class NarratorState(StrEnum): class NarratorStatus(NarratorManagerListener, Static):
IDLE = auto()
SUMMARIZING = auto()
SPEAKING = auto()
class NarratorStatus(Static):
state = reactive(NarratorState.IDLE) state = reactive(NarratorState.IDLE)
def __init__(self, **kwargs: Any) -> None: def __init__(self, narrator_manager: NarratorManagerPort, **kwargs: Any) -> None:
super().__init__("", **kwargs) super().__init__("", **kwargs)
self._narrator_manager = narrator_manager
self._timer: Timer | None = None self._timer: Timer | None = None
self._frame: int = 0 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: def watch_state(self, new_state: NarratorState) -> None:
self._stop_timer() self._stop_timer()
match new_state: match new_state:

View file

@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Callable 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 from vibe.core.types import BaseEvent
@ -11,6 +11,14 @@ class NoopTurnSummary(TurnSummaryPort):
def generation(self) -> int: def generation(self) -> int:
return 0 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: def start_turn(self, user_message: str) -> None:
pass pass

View file

@ -24,6 +24,14 @@ class TurnSummaryPort(ABC):
@abstractmethod @abstractmethod
def generation(self) -> int: ... 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 @abstractmethod
def start_turn(self, user_message: str) -> None: ... def start_turn(self, user_message: str) -> None: ...

View file

@ -21,7 +21,7 @@ class TurnSummaryTracker(TurnSummaryPort):
self, self,
backend: BackendLike, backend: BackendLike,
model: ModelConfig, model: ModelConfig,
on_summary: Callable[[TurnSummaryResult], None], on_summary: Callable[[TurnSummaryResult], None] | None = None,
max_tokens: int = 512, max_tokens: int = 512,
) -> None: ) -> None:
self._backend = backend self._backend = backend
@ -36,6 +36,14 @@ class TurnSummaryTracker(TurnSummaryPort):
def generation(self) -> int: def generation(self) -> int:
return self._generation 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: def start_turn(self, user_message: str) -> None:
self._generation += 1 self._generation += 1
self._data = TurnSummaryData(user_message=user_message) self._data = TurnSummaryData(user_message=user_message)
@ -103,7 +111,9 @@ class TurnSummaryTracker(TurnSummaryPort):
) )
summary = result.message.content or "" 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: except Exception:
logger.warning("Turn summary generation failed", exc_info=True) 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( self.agent_manager = AgentManager(
lambda: self._base_config, initial_agent=agent_name lambda: self._base_config, initial_agent=agent_name
) )
self._mcp_registry = MCPRegistry() self.mcp_registry = MCPRegistry()
self.tool_manager = ToolManager( 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.skill_manager = SkillManager(lambda: self.config)
self.format_handler = APIToolFormatHandler() self.format_handler = APIToolFormatHandler()
@ -1029,7 +1029,8 @@ class AgentLoop:
return ToolDecision( return ToolDecision(
verdict=ToolExecutionResponse.SKIP, verdict=ToolExecutionResponse.SKIP,
approval_type=ToolPermission.NEVER, approval_type=ToolPermission.NEVER,
feedback=f"Tool '{tool_name}' is permanently disabled", feedback=ctx.reason
or f"Tool '{tool_name}' is permanently disabled",
) )
case _: case _:
uncovered = [ uncovered = [
@ -1267,7 +1268,7 @@ class AgentLoop:
self._max_price = max_price self._max_price = max_price
self.tool_manager = ToolManager( 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.skill_manager = SkillManager(lambda: self.config)

View file

@ -306,8 +306,13 @@ class Bash(
if not command_parts: if not command_parts:
return None return None
def is_denylisted(command: str) -> bool: def _matches_pattern(command: str, pattern: str) -> bool:
return any(command.startswith(pattern) for pattern in self.config.denylist) 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: def is_standalone_denylisted(command: str) -> bool:
parts = command.split() parts = command.split()
@ -323,7 +328,9 @@ class Bash(
return False return False
def is_allowlisted(command: str) -> bool: 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: def is_sensitive(command: str) -> bool:
tokens = command.split() tokens = command.split()
@ -332,8 +339,16 @@ class Bash(
return tokens[0] in self.config.sensitive_patterns return tokens[0] in self.config.sensitive_patterns
for part in command_parts: for part in command_parts:
if is_denylisted(part) or is_standalone_denylisted(part): if matched := find_denylist_match(part):
return PermissionContext(permission=ToolPermission.NEVER) 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: if self.config.permission == ToolPermission.ALWAYS:
return PermissionContext(permission=ToolPermission.ALWAYS) return PermissionContext(permission=ToolPermission.ALWAYS)

View file

@ -162,6 +162,10 @@ class MCPRegistry:
) )
return tools 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: def clear(self) -> None:
"""Drop all cached entries, forcing re-discovery on next use.""" """Drop all cached entries, forcing re-discovery on next use."""
self._cache.clear() self._cache.clear()

View file

@ -24,6 +24,7 @@ class RequiredPermission(BaseModel):
class PermissionContext(BaseModel): class PermissionContext(BaseModel):
permission: ToolPermission permission: ToolPermission
required_permissions: list[RequiredPermission] = Field(default_factory=list) required_permissions: list[RequiredPermission] = Field(default_factory=list)
reason: str | None = None
class ApprovedRule(BaseModel): class ApprovedRule(BaseModel):