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:
parent
54b9a17457
commit
9c1c32e058
28 changed files with 587 additions and 179 deletions
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue