mirror of
https://github.com/mistralai/mistral-vibe
synced 2026-04-25 17:14:55 +02:00
v2.7.2 (#556)
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:
6
.github/workflows/build-and-upload.yml
vendored
6
.github/workflows/build-and-upload.yml
vendored
@@ -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
2
.vscode/launch.json
vendored
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.2",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ACP Server",
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [2.7.2] - 2026-04-01
|
||||
|
||||
### Added
|
||||
|
||||
- Alt+Left / Alt+Right keyboard shortcuts for word-wise cursor movement in chat input
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored narrator into a dedicated narrator manager
|
||||
|
||||
### Fixed
|
||||
|
||||
- Broken build on Linux
|
||||
- Errored MCP servers are now excluded from the banner count
|
||||
- Improved bash denylist matching and error messages
|
||||
- Command messages are now skipped during rewind navigation
|
||||
|
||||
|
||||
## [2.7.1] - 2026-03-31
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
4
uv.lock
generated
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
15
vibe/cli/narrator_manager/__init__.py
Normal file
15
vibe/cli/narrator_manager/__init__.py
Normal 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",
|
||||
]
|
||||
198
vibe/cli/narrator_manager/narrator_manager.py
Normal file
198
vibe/cli/narrator_manager/narrator_manager.py
Normal 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)
|
||||
45
vibe/cli/narrator_manager/narrator_manager_port.py
Normal file
45
vibe/cli/narrator_manager/narrator_manager_port.py
Normal 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: ...
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user