v2.6.0 (#524)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai> Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Quentin <torroba.q@gmail.com> Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com> Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
5103019b01
commit
eb580209d4
180 changed files with 11136 additions and 1030 deletions
|
|
@ -8,7 +8,7 @@ from rich import print as rprint
|
|||
import tomli_w
|
||||
|
||||
from vibe import __version__
|
||||
from vibe.cli.textual_ui.app import run_textual_ui
|
||||
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
|
||||
from vibe.core.agent_loop import AgentLoop
|
||||
from vibe.core.agents.models import BuiltinAgentName
|
||||
from vibe.core.config import (
|
||||
|
|
@ -22,6 +22,7 @@ from vibe.core.logger import logger
|
|||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.programmatic import run_programmatic
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
from vibe.core.tracing import setup_tracing
|
||||
from vibe.core.types import EntrypointMetadata, LLMMessage, OutputFormat, Role
|
||||
from vibe.core.utils import ConversationLimitException
|
||||
from vibe.setup.onboarding import run_onboarding
|
||||
|
|
@ -104,6 +105,8 @@ def load_session(
|
|||
f"{config.session_logging.save_dir}[/]"
|
||||
)
|
||||
sys.exit(1)
|
||||
elif args.resume is True:
|
||||
return None
|
||||
else:
|
||||
session_to_load = SessionLoader.find_session_by_id(
|
||||
args.resume, config.session_logging
|
||||
|
|
@ -150,6 +153,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
try:
|
||||
initial_agent_name = get_initial_agent_name(args)
|
||||
config = load_config_or_exit()
|
||||
setup_tracing(config)
|
||||
|
||||
if args.enabled_tools:
|
||||
config.enabled_tools = args.enabled_tools
|
||||
|
|
@ -206,8 +210,11 @@ def run_cli(args: argparse.Namespace) -> None:
|
|||
|
||||
run_textual_ui(
|
||||
agent_loop=agent_loop,
|
||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||
teleport_on_start=args.teleport,
|
||||
startup=StartupOptions(
|
||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||
teleport_on_start=args.teleport,
|
||||
show_resume_picker=args.resume is True,
|
||||
),
|
||||
)
|
||||
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
|
|
|
|||
|
|
@ -22,10 +22,15 @@ class CommandRegistry:
|
|||
handler="_show_help",
|
||||
),
|
||||
"config": Command(
|
||||
aliases=frozenset(["/config", "/model"]),
|
||||
aliases=frozenset(["/config"]),
|
||||
description="Edit config settings",
|
||||
handler="_show_config",
|
||||
),
|
||||
"model": Command(
|
||||
aliases=frozenset(["/model"]),
|
||||
description="Select active model",
|
||||
handler="_show_model",
|
||||
),
|
||||
"reload": Command(
|
||||
aliases=frozenset(["/reload"]),
|
||||
description="Reload configuration from disk",
|
||||
|
|
@ -79,8 +84,8 @@ class CommandRegistry:
|
|||
),
|
||||
"voice": Command(
|
||||
aliases=frozenset(["/voice"]),
|
||||
description="Toggle voice mode on/off",
|
||||
handler="_toggle_voice_mode",
|
||||
description="Configure voice settings",
|
||||
handler="_show_voice_settings",
|
||||
),
|
||||
"leanstall": Command(
|
||||
aliases=frozenset(["/leanstall"]),
|
||||
|
|
|
|||
|
|
@ -97,8 +97,11 @@ def parse_arguments() -> argparse.Namespace:
|
|||
)
|
||||
continuation_group.add_argument(
|
||||
"--resume",
|
||||
nargs="?",
|
||||
const=True,
|
||||
default=None,
|
||||
metavar="SESSION_ID",
|
||||
help="Resume a specific session by its ID (supports partial matching)",
|
||||
help="Resume a session. Without SESSION_ID, shows an interactive picker.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ from vibe.cli.plan_offer.ports.whoami_gateway import (
|
|||
WhoAmIPlanType,
|
||||
WhoAmIResponse,
|
||||
)
|
||||
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, Backend, ProviderConfig
|
||||
from vibe.core.config import DEFAULT_MISTRAL_API_ENV_KEY, ProviderConfig
|
||||
from vibe.core.types import Backend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import platform
|
|||
import subprocess
|
||||
from typing import Any, Literal
|
||||
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
class Terminal(Enum):
|
||||
VSCODE = "vscode"
|
||||
|
|
@ -189,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
|
|||
|
||||
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
|
||||
if keybindings_path.exists():
|
||||
content = keybindings_path.read_text()
|
||||
content = read_safe(keybindings_path)
|
||||
return _parse_keybindings(content)
|
||||
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return []
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
import gc
|
||||
import os
|
||||
|
|
@ -40,12 +42,15 @@ from vibe.cli.textual_ui.notifications import (
|
|||
NotificationPort,
|
||||
TextualNotificationAdapter,
|
||||
)
|
||||
from vibe.cli.textual_ui.session_exit import print_session_resume_message
|
||||
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
|
||||
from vibe.cli.textual_ui.widgets.banner.banner import Banner
|
||||
from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
from vibe.cli.textual_ui.widgets.compact import CompactMessage
|
||||
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
|
||||
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
|
||||
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
|
||||
from vibe.cli.textual_ui.widgets.load_more import HistoryLoadMoreRequested
|
||||
from vibe.cli.textual_ui.widgets.loading import LoadingWidget, paused_timer
|
||||
from vibe.cli.textual_ui.widgets.messages import (
|
||||
|
|
@ -58,6 +63,8 @@ from vibe.cli.textual_ui.widgets.messages import (
|
|||
WarningMessage,
|
||||
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.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
|
||||
|
|
@ -65,6 +72,7 @@ from vibe.cli.textual_ui.widgets.question_app import QuestionApp
|
|||
from vibe.cli.textual_ui.widgets.session_picker import SessionPickerApp
|
||||
from vibe.cli.textual_ui.widgets.teleport_message import TeleportMessage
|
||||
from vibe.cli.textual_ui.widgets.tools import ToolResultMessage
|
||||
from vibe.cli.textual_ui.widgets.voice_app import VoiceApp
|
||||
from vibe.cli.textual_ui.windowing import (
|
||||
HISTORY_RESUME_TAIL_MESSAGES,
|
||||
LOAD_MORE_BATCH_SIZE,
|
||||
|
|
@ -76,6 +84,13 @@ 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,
|
||||
|
|
@ -92,9 +107,11 @@ from vibe.cli.voice_manager import VoiceManager, VoiceManagerPort
|
|||
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 Backend, VibeConfig
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.paths import HISTORY_FILE
|
||||
from vibe.core.session.session_loader import SessionLoader
|
||||
|
|
@ -109,17 +126,20 @@ from vibe.core.teleport.types import (
|
|||
TeleportSendingGithubTokenEvent,
|
||||
TeleportStartingWorkflowEvent,
|
||||
)
|
||||
from vibe.core.tools.base import ToolPermission
|
||||
from vibe.core.tools.builtins.ask_user_question import (
|
||||
AskUserQuestionArgs,
|
||||
AskUserQuestionResult,
|
||||
Choice,
|
||||
Question,
|
||||
)
|
||||
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,
|
||||
Backend,
|
||||
LLMMessage,
|
||||
RateLimitError,
|
||||
Role,
|
||||
|
|
@ -129,6 +149,7 @@ from vibe.core.utils import (
|
|||
get_user_cancellation_message,
|
||||
is_dangerous_directory,
|
||||
)
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
class BottomApp(StrEnum):
|
||||
|
|
@ -142,9 +163,11 @@ class BottomApp(StrEnum):
|
|||
Approval = auto()
|
||||
Config = auto()
|
||||
Input = auto()
|
||||
ModelPicker = auto()
|
||||
ProxySetup = auto()
|
||||
Question = auto()
|
||||
SessionPicker = auto()
|
||||
Voice = auto()
|
||||
|
||||
|
||||
class ChatScroll(VerticalScroll):
|
||||
|
|
@ -154,9 +177,31 @@ class ChatScroll(VerticalScroll):
|
|||
def is_at_bottom(self) -> bool:
|
||||
return self.scroll_target_y >= (self.max_scroll_y - 3)
|
||||
|
||||
def _check_anchor(self) -> None:
|
||||
if self._anchored and self._anchor_released and self.is_at_bottom:
|
||||
self._anchor_released = False
|
||||
_reanchor_pending: bool = False
|
||||
_scrolling_down: bool = False
|
||||
|
||||
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
|
||||
super().watch_scroll_y(old_value, new_value)
|
||||
self._scrolling_down = new_value >= old_value
|
||||
|
||||
def release_anchor(self) -> None:
|
||||
super().release_anchor()
|
||||
# Textual's MRO dispatch calls Widget._on_mouse_scroll_down AFTER
|
||||
# our override, so any re-anchor we do gets immediately undone.
|
||||
# Defer the re-check until all handlers for this event have finished.
|
||||
if not self._reanchor_pending:
|
||||
self._reanchor_pending = True
|
||||
self.call_later(self._maybe_reanchor)
|
||||
|
||||
def _maybe_reanchor(self) -> None:
|
||||
self._reanchor_pending = False
|
||||
if (
|
||||
self._anchored
|
||||
and self._anchor_released
|
||||
and self.is_at_bottom
|
||||
and self._scrolling_down
|
||||
):
|
||||
self.anchor()
|
||||
|
||||
def update_node_styles(self, animate: bool = True) -> None:
|
||||
pass
|
||||
|
|
@ -202,6 +247,13 @@ async def prune_oldest_children(
|
|||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StartupOptions:
|
||||
initial_prompt: str | None = None
|
||||
teleport_on_start: bool = False
|
||||
show_resume_picker: bool = False
|
||||
|
||||
|
||||
class VibeApp(App): # noqa: PLR0904
|
||||
ENABLE_COMMAND_PALETTE = False
|
||||
CSS_PATH = "app.tcss"
|
||||
|
|
@ -225,8 +277,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
def __init__(
|
||||
self,
|
||||
agent_loop: AgentLoop,
|
||||
initial_prompt: str | None = None,
|
||||
teleport_on_start: bool = False,
|
||||
startup: StartupOptions | None = None,
|
||||
update_notifier: UpdateGateway | None = None,
|
||||
update_cache_repository: UpdateCacheRepository | None = None,
|
||||
current_version: str = CORE_VERSION,
|
||||
|
|
@ -277,8 +328,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._update_cache_repository = update_cache_repository
|
||||
self._current_version = current_version
|
||||
self._plan_offer_gateway = plan_offer_gateway
|
||||
self._initial_prompt = initial_prompt
|
||||
self._teleport_on_start = teleport_on_start and self.config.nuage_enabled
|
||||
opts = startup or StartupOptions()
|
||||
self._initial_prompt = opts.initial_prompt
|
||||
self._teleport_on_start = opts.teleport_on_start and self.config.nuage_enabled
|
||||
self._show_resume_picker = opts.show_resume_picker
|
||||
self._last_escape_time: float | None = None
|
||||
self._banner: Banner | None = None
|
||||
self._whats_new_message: WhatsNewMessage | None = None
|
||||
|
|
@ -287,6 +340,12 @@ 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
|
||||
|
||||
@property
|
||||
def config(self) -> VibeConfig:
|
||||
|
|
@ -299,7 +358,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
yield VerticalGroup(id="messages")
|
||||
|
||||
with Horizontal(id="loading-area"):
|
||||
yield NarratorStatus()
|
||||
yield Static(id="loading-area-content")
|
||||
yield FeedbackBar()
|
||||
|
||||
with Static(id="bottom-app-container"):
|
||||
yield ChatInputContainer(
|
||||
|
|
@ -326,10 +387,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._cached_messages_area = self.query_one("#messages")
|
||||
self._cached_chat = self.query_one("#chat", ChatScroll)
|
||||
self._cached_loading_area = self.query_one("#loading-area-content")
|
||||
self._feedback_bar = self.query_one(FeedbackBar)
|
||||
|
||||
self.event_handler = EventHandler(
|
||||
mount_callback=self._mount_and_scroll,
|
||||
get_tools_collapsed=lambda: self._tools_collapsed,
|
||||
on_profile_changed=self._on_profile_changed,
|
||||
)
|
||||
|
||||
self._chat_input_container = self.query_one(ChatInputContainer)
|
||||
|
|
@ -359,7 +422,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self.call_after_refresh(self._refresh_banner)
|
||||
|
||||
if self._initial_prompt or self._teleport_on_start:
|
||||
if self._show_resume_picker:
|
||||
self.run_worker(self._show_session_picker(), exclusive=False)
|
||||
elif self._initial_prompt or self._teleport_on_start:
|
||||
self.call_after_refresh(self._process_initial_prompt)
|
||||
|
||||
gc.collect()
|
||||
|
|
@ -424,9 +489,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
async def on_approval_app_approval_granted_always_tool(
|
||||
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
|
||||
) -> None:
|
||||
self._set_tool_permission_always(
|
||||
message.tool_name, save_permanently=message.save_permanently
|
||||
)
|
||||
self.agent_loop.approve_always(message.tool_name, message.required_permissions)
|
||||
|
||||
if self._pending_approval and not self._pending_approval.done():
|
||||
self._pending_approval.set_result((ApprovalResponse.YES, None))
|
||||
|
|
@ -453,22 +516,107 @@ class VibeApp(App): # noqa: PLR0904
|
|||
result = AskUserQuestionResult(answers=[], cancelled=True)
|
||||
self._pending_question.set_result(result)
|
||||
|
||||
def on_chat_text_area_feedback_key_pressed(
|
||||
self, message: ChatTextArea.FeedbackKeyPressed
|
||||
) -> None:
|
||||
self._feedback_bar.handle_feedback_key(message.rating)
|
||||
|
||||
def on_chat_text_area_non_feedback_key_pressed(
|
||||
self, message: ChatTextArea.NonFeedbackKeyPressed
|
||||
) -> None:
|
||||
self._feedback_bar.hide()
|
||||
|
||||
def on_feedback_bar_feedback_given(
|
||||
self, message: FeedbackBar.FeedbackGiven
|
||||
) -> None:
|
||||
self.agent_loop.telemetry_client.send_user_rating_feedback(
|
||||
rating=message.rating, model=self.config.active_model
|
||||
)
|
||||
|
||||
async def _remove_loading_widget(self) -> None:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
self._loading_widget = None
|
||||
|
||||
async def on_config_app_open_model_picker(
|
||||
self, _message: ConfigApp.OpenModelPicker
|
||||
) -> None:
|
||||
config_app = self.query_one(ConfigApp)
|
||||
changes = config_app._convert_changes_for_save()
|
||||
if changes:
|
||||
VibeConfig.save_updates(changes)
|
||||
await self._reload_config()
|
||||
await self._switch_to_input_app()
|
||||
await self._switch_to_model_picker_app()
|
||||
|
||||
async def on_config_app_config_closed(
|
||||
self, message: ConfigApp.ConfigClosed
|
||||
) -> None:
|
||||
if message.changes:
|
||||
VibeConfig.save_updates(message.changes)
|
||||
await self._handle_config_settings_closed(message.changes)
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_voice_app_config_closed(self, message: VoiceApp.ConfigClosed) -> None:
|
||||
await self._handle_voice_settings_closed(message.changes)
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def _handle_config_settings_closed(
|
||||
self, changes: dict[str, str | bool]
|
||||
) -> None:
|
||||
if changes:
|
||||
VibeConfig.save_updates(changes)
|
||||
await self._reload_config()
|
||||
else:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Configuration closed (no changes saved).")
|
||||
)
|
||||
|
||||
async def _handle_voice_settings_closed(
|
||||
self, changes: dict[str, str | bool]
|
||||
) -> None:
|
||||
if not changes:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Voice settings closed (no changes saved).")
|
||||
)
|
||||
return
|
||||
|
||||
if "voice_mode_enabled" in changes:
|
||||
current = self._voice_manager.is_enabled
|
||||
desired = changes["voice_mode_enabled"]
|
||||
if current != desired:
|
||||
self._voice_manager.toggle_voice_mode()
|
||||
self.agent_loop.telemetry_client.send_telemetry_event(
|
||||
"vibe.voice_mode_toggled", {"enabled": desired}
|
||||
)
|
||||
self.agent_loop.refresh_config()
|
||||
if desired:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage(
|
||||
"Voice mode enabled. Press ctrl+r to start recording."
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Voice mode disabled.")
|
||||
)
|
||||
|
||||
non_voice_changes = {
|
||||
k: v for k, v in changes.items() if k != "voice_mode_enabled"
|
||||
}
|
||||
if non_voice_changes:
|
||||
VibeConfig.save_updates(non_voice_changes)
|
||||
self.agent_loop.refresh_config()
|
||||
self._sync_turn_summary()
|
||||
|
||||
async def on_model_picker_app_model_selected(
|
||||
self, message: ModelPickerApp.ModelSelected
|
||||
) -> None:
|
||||
VibeConfig.save_updates({"active_model": message.alias})
|
||||
await self._reload_config()
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_model_picker_app_cancelled(
|
||||
self, _event: ModelPickerApp.Cancelled
|
||||
) -> None:
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_proxy_setup_app_proxy_setup_closed(
|
||||
|
|
@ -507,13 +655,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
for widget in children[:compact_index]:
|
||||
await widget.remove()
|
||||
|
||||
def _set_tool_permission_always(
|
||||
self, tool_name: str, save_permanently: bool = False
|
||||
) -> None:
|
||||
self.agent_loop.set_tool_permission(
|
||||
tool_name, ToolPermission.ALWAYS, save_permanently
|
||||
)
|
||||
|
||||
async def _handle_command(self, user_input: str) -> bool:
|
||||
if command := self.commands.find_command(user_input):
|
||||
if cmd_name := self.commands.get_command_name(user_input):
|
||||
|
|
@ -557,7 +698,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
|
||||
|
||||
try:
|
||||
skill_content = skill_info.skill_path.read_text(encoding="utf-8")
|
||||
skill_content = read_safe(skill_info.skill_path)
|
||||
except OSError as e:
|
||||
await self._mount_and_scroll(
|
||||
ErrorMessage(
|
||||
|
|
@ -612,6 +753,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
user_message = UserMessage(message)
|
||||
|
||||
await self._mount_and_scroll(user_message)
|
||||
if self.agent_loop.telemetry_client.is_active():
|
||||
self._feedback_bar.maybe_show()
|
||||
|
||||
if not self._agent_running:
|
||||
self._agent_task = asyncio.create_task(
|
||||
|
|
@ -682,7 +825,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return tool in self.agent_loop.tool_manager.available_tools
|
||||
|
||||
async def _approval_callback(
|
||||
self, tool: str, args: BaseModel, tool_call_id: str
|
||||
self,
|
||||
tool: str,
|
||||
args: BaseModel,
|
||||
tool_call_id: str,
|
||||
required_permissions: list[RequiredPermission] | None,
|
||||
) -> tuple[ApprovalResponse, str | None]:
|
||||
# Auto-approve only if parent is in auto-approve mode AND tool is enabled
|
||||
# This ensures subagents respect the main agent's tool restrictions
|
||||
|
|
@ -695,7 +842,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
try:
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
await self._switch_to_approval_app(tool, args, required_permissions)
|
||||
result = await self._pending_approval
|
||||
return result
|
||||
finally:
|
||||
|
|
@ -717,6 +864,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._pending_question = None
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def _handle_turn_error(self) -> None:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
|
||||
async def _handle_agent_loop_turn(self, prompt: str) -> None:
|
||||
self._agent_running = True
|
||||
|
||||
|
|
@ -730,7 +883,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)
|
||||
async for event in self.agent_loop.act(rendered_prompt):
|
||||
self._turn_summary.track(event)
|
||||
if self.event_handler:
|
||||
await self.event_handler.handle_event(
|
||||
event,
|
||||
|
|
@ -739,25 +895,29 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
await self._handle_turn_error()
|
||||
self._turn_summary.cancel_turn()
|
||||
raise
|
||||
except Exception as e:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
if self.event_handler:
|
||||
self.event_handler.stop_current_tool_call(success=False)
|
||||
await self._handle_turn_error()
|
||||
|
||||
message = str(e)
|
||||
if isinstance(e, RateLimitError):
|
||||
message = self._rate_limit_message()
|
||||
self._turn_summary.set_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._agent_running = False
|
||||
self._interrupt_requested = False
|
||||
self._agent_task = None
|
||||
|
|
@ -933,6 +1093,12 @@ class VibeApp(App): # noqa: PLR0904
|
|||
return
|
||||
await self._switch_to_config_app()
|
||||
|
||||
async def _show_model(self) -> None:
|
||||
"""Switch to the model picker in the bottom panel."""
|
||||
if self._current_bottom_app == BottomApp.ModelPicker:
|
||||
return
|
||||
await self._switch_to_model_picker_app()
|
||||
|
||||
async def _show_proxy_setup(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
|
|
@ -1045,6 +1211,7 @@ 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()
|
||||
|
||||
if self._banner:
|
||||
self._banner.set_state(
|
||||
|
|
@ -1229,16 +1396,13 @@ class VibeApp(App): # noqa: PLR0904
|
|||
lambda: self.config,
|
||||
audio_recorder=AudioRecorder(),
|
||||
transcribe_client=transcribe_client,
|
||||
telemetry_client=self.agent_loop.telemetry_client,
|
||||
)
|
||||
|
||||
async def _toggle_voice_mode(self) -> None:
|
||||
result = self._voice_manager.toggle_voice_mode()
|
||||
self.agent_loop.refresh_config()
|
||||
if result.enabled:
|
||||
msg = "Voice mode enabled. Press ctrl+r to start recording."
|
||||
else:
|
||||
msg = "Voice mode disabled."
|
||||
await self._mount_and_scroll(UserCommandMessage(msg))
|
||||
async def _show_voice_settings(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.Voice:
|
||||
return
|
||||
await self._switch_to_voice_app()
|
||||
|
||||
async def _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
|
||||
bottom_container = self.query_one("#bottom-app-container")
|
||||
|
|
@ -1249,6 +1413,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._chat_input_container.display = False
|
||||
self._chat_input_container.disabled = True
|
||||
|
||||
self._feedback_bar.hide()
|
||||
|
||||
self._current_bottom_app = BottomApp[type(widget).__name__.removesuffix("App")]
|
||||
await bottom_container.mount(widget)
|
||||
|
||||
|
|
@ -1263,6 +1429,23 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._mount_and_scroll(UserCommandMessage("Configuration opened..."))
|
||||
await self._switch_from_input(ConfigApp(self.config))
|
||||
|
||||
async def _switch_to_voice_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.Voice:
|
||||
return
|
||||
|
||||
await self._mount_and_scroll(UserCommandMessage("Voice settings opened..."))
|
||||
await self._switch_from_input(VoiceApp(self.config))
|
||||
|
||||
async def _switch_to_model_picker_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ModelPicker:
|
||||
return
|
||||
|
||||
model_aliases = [m.alias for m in self.config.models]
|
||||
current_model = str(self.config.active_model)
|
||||
await self._switch_from_input(
|
||||
ModelPickerApp(model_aliases=model_aliases, current_model=current_model)
|
||||
)
|
||||
|
||||
async def _switch_to_proxy_setup_app(self) -> None:
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
return
|
||||
|
|
@ -1271,10 +1454,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
await self._switch_from_input(ProxySetupApp())
|
||||
|
||||
async def _switch_to_approval_app(
|
||||
self, tool_name: str, tool_args: BaseModel
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: BaseModel,
|
||||
required_permissions: list[RequiredPermission] | None = None,
|
||||
) -> None:
|
||||
approval_app = ApprovalApp(
|
||||
tool_name=tool_name, tool_args=tool_args, config=self.config
|
||||
tool_name=tool_name,
|
||||
tool_args=tool_args,
|
||||
config=self.config,
|
||||
required_permissions=required_permissions,
|
||||
)
|
||||
await self._switch_from_input(approval_app, scroll=True)
|
||||
|
||||
|
|
@ -1306,6 +1495,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(ChatInputContainer).focus_input()
|
||||
case BottomApp.Config:
|
||||
self.query_one(ConfigApp).focus()
|
||||
case BottomApp.ModelPicker:
|
||||
self.query_one(ModelPickerApp).focus()
|
||||
case BottomApp.ProxySetup:
|
||||
self.query_one(ProxySetupApp).focus()
|
||||
case BottomApp.Approval:
|
||||
|
|
@ -1314,6 +1505,8 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.query_one(QuestionApp).focus()
|
||||
case BottomApp.SessionPicker:
|
||||
self.query_one(SessionPickerApp).focus()
|
||||
case BottomApp.Voice:
|
||||
self.query_one(VoiceApp).focus()
|
||||
case app:
|
||||
assert_never(app)
|
||||
except Exception:
|
||||
|
|
@ -1327,6 +1520,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_voice_app_escape(self) -> None:
|
||||
try:
|
||||
voice_app = self.query_one(VoiceApp)
|
||||
voice_app.action_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_approval_app_escape(self) -> None:
|
||||
try:
|
||||
approval_app = self.query_one(ApprovalApp)
|
||||
|
|
@ -1345,6 +1546,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_cancelled_action("cancel_question")
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_model_picker_app_escape(self) -> None:
|
||||
try:
|
||||
model_picker = self.query_one(ModelPickerApp)
|
||||
model_picker.post_message(ModelPickerApp.Cancelled())
|
||||
except Exception:
|
||||
pass
|
||||
self._last_escape_time = None
|
||||
|
||||
def _handle_session_picker_app_escape(self) -> None:
|
||||
try:
|
||||
session_picker = self.query_one(SessionPickerApp)
|
||||
|
|
@ -1376,6 +1585,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_config_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.Voice:
|
||||
self._handle_voice_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.ProxySetup:
|
||||
try:
|
||||
proxy_setup_app = self.query_one(ProxySetupApp)
|
||||
|
|
@ -1393,6 +1606,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._handle_question_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.ModelPicker:
|
||||
self._handle_model_picker_app_escape()
|
||||
return
|
||||
|
||||
if self._current_bottom_app == BottomApp.SessionPicker:
|
||||
self._handle_session_picker_app_escape()
|
||||
return
|
||||
|
|
@ -1405,6 +1622,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()
|
||||
return
|
||||
|
||||
if self._agent_running:
|
||||
self._handle_agent_running_escape()
|
||||
|
||||
|
|
@ -1469,6 +1691,10 @@ class VibeApp(App): # noqa: PLR0904
|
|||
def _refresh_profile_widgets(self) -> None:
|
||||
self._update_profile_widgets(self.agent_loop.agent_profile)
|
||||
|
||||
def _on_profile_changed(self) -> None:
|
||||
self._refresh_profile_widgets()
|
||||
self._refresh_banner()
|
||||
|
||||
def _refresh_banner(self) -> None:
|
||||
if self._banner:
|
||||
self._banner.set_state(
|
||||
|
|
@ -1730,31 +1956,99 @@ 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 _print_session_resume_message(session_id: str | None) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
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)
|
||||
|
||||
print()
|
||||
print("To continue this session, run: vibe --continue")
|
||||
print(f"Or: vibe --resume {session_id}")
|
||||
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,
|
||||
initial_prompt: str | None = None,
|
||||
teleport_on_start: bool = False,
|
||||
agent_loop: AgentLoop, startup: StartupOptions | None = None
|
||||
) -> None:
|
||||
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
|
||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
||||
plan_offer_gateway = HttpWhoAmIGateway()
|
||||
app = VibeApp(
|
||||
agent_loop=agent_loop,
|
||||
initial_prompt=initial_prompt,
|
||||
teleport_on_start=teleport_on_start,
|
||||
startup=startup,
|
||||
update_notifier=update_notifier,
|
||||
update_cache_repository=update_cache_repository,
|
||||
plan_offer_gateway=plan_offer_gateway,
|
||||
)
|
||||
session_id = app.run()
|
||||
_print_session_resume_message(session_id)
|
||||
print_session_resume_message(session_id, agent_loop.stats)
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@ Markdown {
|
|||
color: ansi_default;
|
||||
|
||||
.code_inline {
|
||||
color: ansi_yellow;
|
||||
color: ansi_green;
|
||||
background: transparent;
|
||||
text-style: bold;
|
||||
}
|
||||
|
|
@ -625,7 +625,8 @@ StatusMessage {
|
|||
.loading-hint {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: $foreground;
|
||||
color: ansi_bright_black;
|
||||
|
||||
}
|
||||
|
||||
.history-load-more-message {
|
||||
|
|
@ -655,7 +656,7 @@ StatusMessage {
|
|||
|
||||
}
|
||||
|
||||
#config-app {
|
||||
#config-app, #voice-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
|
|
@ -664,7 +665,7 @@ StatusMessage {
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
#config-content {
|
||||
#config-content, #voice-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
|
@ -675,41 +676,14 @@ StatusMessage {
|
|||
color: ansi_blue;
|
||||
}
|
||||
|
||||
.settings-option {
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
#config-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.settings-cursor-selected {
|
||||
color: ansi_blue;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-label-selected {
|
||||
color: ansi_default;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-value-toggle-on-selected {
|
||||
color: ansi_green;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-value-toggle-on-unselected {
|
||||
color: ansi_green;
|
||||
}
|
||||
|
||||
.settings-value-toggle-off {
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
.settings-value-cycle-selected {
|
||||
color: ansi_blue;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.settings-value-cycle-unselected {
|
||||
color: ansi_blue;
|
||||
#config-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.settings-help {
|
||||
|
|
@ -953,6 +927,14 @@ ContextProgress {
|
|||
color: ansi_bright_black;
|
||||
}
|
||||
|
||||
NarratorStatus {
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0 0 0 1;
|
||||
}
|
||||
|
||||
#banner-container {
|
||||
align: left middle;
|
||||
padding: 0 1 0 0;
|
||||
|
|
@ -1083,3 +1065,54 @@ ContextProgress {
|
|||
color: ansi_bright_black;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
#modelpicker-app {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: solid ansi_bright_black;
|
||||
padding: 0 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#modelpicker-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.modelpicker-title {
|
||||
height: auto;
|
||||
text-style: bold;
|
||||
color: ansi_blue;
|
||||
}
|
||||
|
||||
#modelpicker-options {
|
||||
width: 100%;
|
||||
max-height: 50vh;
|
||||
text-wrap: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#modelpicker-options:focus {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modelpicker-help {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: ansi_bright_black;
|
||||
margin-top: 1;
|
||||
}
|
||||
|
||||
FeedbackBar {
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-left: 1;
|
||||
}
|
||||
|
||||
#feedback-text {
|
||||
width: auto;
|
||||
height: auto;
|
||||
color: ansi_default;
|
||||
}
|
||||
|
|
|
|||
11
vibe/cli/textual_ui/constants.py
Normal file
11
vibe/cli/textual_ui/constants.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class MistralColors(StrEnum):
|
||||
RED = "#E10500"
|
||||
ORANGE_DARK = "#FA500F"
|
||||
ORANGE = "#FF8205"
|
||||
ORANGE_LIGHT = "#FFAF00"
|
||||
YELLOW = "#FFD800"
|
||||
|
|
@ -6,6 +6,8 @@ import shlex
|
|||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
class ExternalEditor:
|
||||
"""Handles opening an external editor to edit prompt content."""
|
||||
|
|
@ -24,7 +26,7 @@ class ExternalEditor:
|
|||
parts = shlex.split(editor)
|
||||
subprocess.run([*parts, filepath], check=True)
|
||||
|
||||
content = Path(filepath).read_text().rstrip()
|
||||
content = read_safe(Path(filepath)).rstrip()
|
||||
return content if content != initial_content else None
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
|||
from vibe.cli.textual_ui.widgets.tools import ToolCallMessage, ToolResultMessage
|
||||
from vibe.core.tools.ui import ToolUIDataAdapter
|
||||
from vibe.core.types import (
|
||||
AgentProfileChangedEvent,
|
||||
AssistantEvent,
|
||||
BaseEvent,
|
||||
CompactEndEvent,
|
||||
|
|
@ -27,10 +28,14 @@ if TYPE_CHECKING:
|
|||
|
||||
class EventHandler:
|
||||
def __init__(
|
||||
self, mount_callback: Callable, get_tools_collapsed: Callable[[], bool]
|
||||
self,
|
||||
mount_callback: Callable,
|
||||
get_tools_collapsed: Callable[[], bool],
|
||||
on_profile_changed: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
self.mount_callback = mount_callback
|
||||
self.get_tools_collapsed = get_tools_collapsed
|
||||
self.on_profile_changed = on_profile_changed
|
||||
self.tool_calls: dict[str, ToolCallMessage] = {}
|
||||
self.current_compact: CompactMessage | None = None
|
||||
self.current_streaming_message: AssistantMessage | None = None
|
||||
|
|
@ -62,6 +67,9 @@ class EventHandler:
|
|||
case CompactEndEvent():
|
||||
await self.finalize_streaming()
|
||||
await self._handle_compact_end(event)
|
||||
case AgentProfileChangedEvent():
|
||||
if self.on_profile_changed:
|
||||
self.on_profile_changed()
|
||||
case UserMessageEvent():
|
||||
await self.finalize_streaming()
|
||||
case _:
|
||||
|
|
|
|||
25
vibe/cli/textual_ui/session_exit.py
Normal file
25
vibe/cli/textual_ui/session_exit.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from rich import print as rprint
|
||||
|
||||
from vibe.core.types import AgentStats
|
||||
|
||||
|
||||
def format_session_usage(stats: AgentStats) -> str:
|
||||
return (
|
||||
"Total tokens used this session: "
|
||||
f"input={stats.session_prompt_tokens:,} "
|
||||
f"output={stats.session_completion_tokens:,} "
|
||||
f"(total={stats.session_total_llm_tokens:,})"
|
||||
)
|
||||
|
||||
|
||||
def print_session_resume_message(session_id: str | None, stats: AgentStats) -> None:
|
||||
if not session_id:
|
||||
return
|
||||
|
||||
print()
|
||||
print(format_session_usage(stats))
|
||||
print()
|
||||
rprint("To continue this session, run: [bold dark_orange]vibe --continue[/]")
|
||||
rprint(f"Or: [bold dark_orange]vibe --resume {session_id}[/]")
|
||||
|
|
@ -13,6 +13,7 @@ from textual.widgets import Static
|
|||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.tool_widgets import get_approval_widget
|
||||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.tools.permissions import RequiredPermission
|
||||
|
||||
|
||||
class ApprovalApp(Container):
|
||||
|
|
@ -38,12 +39,15 @@ class ApprovalApp(Container):
|
|||
|
||||
class ApprovalGrantedAlwaysTool(Message):
|
||||
def __init__(
|
||||
self, tool_name: str, tool_args: BaseModel, save_permanently: bool
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: BaseModel,
|
||||
required_permissions: list[RequiredPermission],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.tool_name = tool_name
|
||||
self.tool_args = tool_args
|
||||
self.save_permanently = save_permanently
|
||||
self.required_permissions = required_permissions
|
||||
|
||||
class ApprovalRejected(Message):
|
||||
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
|
||||
|
|
@ -52,12 +56,17 @@ class ApprovalApp(Container):
|
|||
self.tool_args = tool_args
|
||||
|
||||
def __init__(
|
||||
self, tool_name: str, tool_args: BaseModel, config: VibeConfig
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: BaseModel,
|
||||
config: VibeConfig,
|
||||
required_permissions: list[RequiredPermission] | None = None,
|
||||
) -> None:
|
||||
super().__init__(id="approval-app")
|
||||
self.tool_name = tool_name
|
||||
self.tool_args = tool_args
|
||||
self.config = config
|
||||
self.required_permissions = required_permissions or []
|
||||
self.selected_option = 0
|
||||
self.content_container: Vertical | None = None
|
||||
self.title_widget: Static | None = None
|
||||
|
|
@ -104,9 +113,15 @@ class ApprovalApp(Container):
|
|||
await self.tool_info_container.mount(approval_widget)
|
||||
|
||||
def _update_options(self) -> None:
|
||||
if self.required_permissions:
|
||||
labels = ", ".join(rp.label for rp in self.required_permissions)
|
||||
always_text = f"Yes and always allow for this session: {labels}"
|
||||
else:
|
||||
always_text = f"Yes and always allow {self.tool_name} for this session"
|
||||
|
||||
options = [
|
||||
("Yes", "yes"),
|
||||
(f"Yes and always allow {self.tool_name} for this session", "yes"),
|
||||
(always_text, "yes"),
|
||||
("No and tell the agent what to do instead", "no"),
|
||||
]
|
||||
|
||||
|
|
@ -178,7 +193,7 @@ class ApprovalApp(Container):
|
|||
self.ApprovalGrantedAlwaysTool(
|
||||
tool_name=self.tool_name,
|
||||
tool_args=self.tool_args,
|
||||
save_permanently=False,
|
||||
required_permissions=self.required_permissions,
|
||||
)
|
||||
)
|
||||
case 2:
|
||||
|
|
|
|||
|
|
@ -161,6 +161,16 @@ class ChatTextArea(TextArea):
|
|||
self.post_message(self.HistoryNext())
|
||||
return True
|
||||
|
||||
class FeedbackKeyPressed(Message):
|
||||
def __init__(self, rating: int) -> None:
|
||||
self.rating = rating
|
||||
super().__init__()
|
||||
|
||||
class NonFeedbackKeyPressed(Message):
|
||||
pass
|
||||
|
||||
feedback_active: bool = False
|
||||
|
||||
async def _handle_voice_key(self, event: events.Key) -> bool:
|
||||
if not self._voice_manager:
|
||||
return False
|
||||
|
|
@ -193,6 +203,15 @@ class ChatTextArea(TextArea):
|
|||
|
||||
self._mark_cursor_moved_if_needed()
|
||||
|
||||
if self.feedback_active:
|
||||
if event.character in {"1", "2", "3"}:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
self.post_message(self.FeedbackKeyPressed(int(event.character)))
|
||||
return
|
||||
if event.character is not None:
|
||||
self.post_message(self.NonFeedbackKeyPressed())
|
||||
|
||||
manager = self._completion_manager
|
||||
if manager:
|
||||
match manager.on_key(
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar, TypedDict
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from textual import events
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.events import DescendantBlur
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
|
|
@ -15,22 +17,13 @@ if TYPE_CHECKING:
|
|||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
class SettingDefinition(TypedDict):
|
||||
key: str
|
||||
label: str
|
||||
type: str
|
||||
options: list[str]
|
||||
|
||||
|
||||
class ConfigApp(Container):
|
||||
can_focus = True
|
||||
can_focus_children = False
|
||||
"""Settings panel with navigatable option picker."""
|
||||
|
||||
can_focus_children = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("up", "move_up", "Up", show=False),
|
||||
Binding("down", "move_down", "Down", show=False),
|
||||
Binding("space", "toggle_setting", "Toggle", show=False),
|
||||
Binding("enter", "cycle", "Next", show=False),
|
||||
Binding("escape", "close", "Close", show=False)
|
||||
]
|
||||
|
||||
class SettingChanged(Message):
|
||||
|
|
@ -44,122 +37,92 @@ class ConfigApp(Container):
|
|||
super().__init__()
|
||||
self.changes = changes
|
||||
|
||||
class OpenModelPicker(Message):
|
||||
pass
|
||||
|
||||
def __init__(self, config: VibeConfig) -> None:
|
||||
super().__init__(id="config-app")
|
||||
self.config = config
|
||||
self.selected_index = 0
|
||||
self.changes: dict[str, str] = {}
|
||||
|
||||
self.settings: list[SettingDefinition] = [
|
||||
{
|
||||
"key": "active_model",
|
||||
"label": "Model",
|
||||
"type": "cycle",
|
||||
"options": [m.alias for m in self.config.models],
|
||||
},
|
||||
{
|
||||
"key": "autocopy_to_clipboard",
|
||||
"label": "Auto-copy",
|
||||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
{
|
||||
"key": "file_watcher_for_autocomplete",
|
||||
"label": "Autocomplete watcher (may delay first autocompletion)",
|
||||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
self._toggle_settings: list[tuple[str, str]] = [
|
||||
("autocopy_to_clipboard", "Auto-copy"),
|
||||
(
|
||||
"file_watcher_for_autocomplete",
|
||||
"Autocomplete watcher (may delay first autocompletion)",
|
||||
),
|
||||
]
|
||||
|
||||
self.title_widget: Static | None = None
|
||||
self.setting_widgets: list[Static] = []
|
||||
self.help_widget: Static | None = None
|
||||
def _get_current_model(self) -> str:
|
||||
return str(getattr(self.config, "active_model", ""))
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="config-content"):
|
||||
self.title_widget = NoMarkupStatic("Settings", classes="settings-title")
|
||||
yield self.title_widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
for _ in self.settings:
|
||||
widget = NoMarkupStatic("", classes="settings-option")
|
||||
self.setting_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
self.help_widget = NoMarkupStatic(
|
||||
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
|
||||
)
|
||||
yield self.help_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._update_display()
|
||||
self.focus()
|
||||
|
||||
def _get_display_value(self, setting: SettingDefinition) -> str:
|
||||
key = setting["key"]
|
||||
def _get_toggle_value(self, key: str) -> str:
|
||||
if key in self.changes:
|
||||
return self.changes[key]
|
||||
raw_value = getattr(self.config, key, "")
|
||||
if isinstance(raw_value, bool):
|
||||
return "On" if raw_value else "Off"
|
||||
return str(raw_value)
|
||||
raw = getattr(self.config, key, False)
|
||||
if isinstance(raw, bool):
|
||||
return "On" if raw else "Off"
|
||||
return str(raw)
|
||||
|
||||
def _update_display(self) -> None:
|
||||
for i, (setting, widget) in enumerate(
|
||||
zip(self.settings, self.setting_widgets, strict=True)
|
||||
):
|
||||
is_selected = i == self.selected_index
|
||||
cursor = "› " if is_selected else " "
|
||||
def _model_prompt(self) -> Text:
|
||||
text = Text(no_wrap=True)
|
||||
text.append("Model: ")
|
||||
text.append(self._get_current_model(), style="bold")
|
||||
return text
|
||||
|
||||
label: str = setting["label"]
|
||||
value: str = self._get_display_value(setting)
|
||||
def _toggle_prompt(self, key: str, label: str) -> Text:
|
||||
value = self._get_toggle_value(key)
|
||||
text = Text(no_wrap=True)
|
||||
text.append(f"{label}: ")
|
||||
if value == "On":
|
||||
text.append("On", style="green bold")
|
||||
else:
|
||||
text.append("Off", style="dim")
|
||||
return text
|
||||
|
||||
text = f"{cursor}{label}: {value}"
|
||||
def compose(self) -> ComposeResult:
|
||||
options: list[Option] = [Option(self._model_prompt(), id="action:active_model")]
|
||||
for key, label in self._toggle_settings:
|
||||
options.append(Option(self._toggle_prompt(key, label), id=f"toggle:{key}"))
|
||||
|
||||
widget.update(text)
|
||||
with Vertical(id="config-content"):
|
||||
yield NoMarkupStatic("Settings", classes="settings-title")
|
||||
yield NoMarkupStatic("")
|
||||
yield OptionList(*options, id="config-options")
|
||||
yield NoMarkupStatic("")
|
||||
yield NoMarkupStatic(
|
||||
"↑↓ Navigate Enter Select/Toggle Esc Exit", classes="settings-help"
|
||||
)
|
||||
|
||||
widget.remove_class("settings-cursor-selected")
|
||||
widget.remove_class("settings-value-cycle-selected")
|
||||
widget.remove_class("settings-value-cycle-unselected")
|
||||
def on_mount(self) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
if is_selected:
|
||||
widget.add_class("settings-value-cycle-selected")
|
||||
else:
|
||||
widget.add_class("settings-value-cycle-unselected")
|
||||
def on_descendant_blur(self, _event: DescendantBlur) -> None:
|
||||
self.query_one(OptionList).focus()
|
||||
|
||||
def action_move_up(self) -> None:
|
||||
self.selected_index = (self.selected_index - 1) % len(self.settings)
|
||||
self._update_display()
|
||||
def _refresh_options(self) -> None:
|
||||
option_list = self.query_one(OptionList)
|
||||
option_list.replace_option_prompt("action:active_model", self._model_prompt())
|
||||
for key, label in self._toggle_settings:
|
||||
option_list.replace_option_prompt(
|
||||
f"toggle:{key}", self._toggle_prompt(key, label)
|
||||
)
|
||||
|
||||
def action_move_down(self) -> None:
|
||||
self.selected_index = (self.selected_index + 1) % len(self.settings)
|
||||
self._update_display()
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
option_id = event.option.id
|
||||
if not option_id:
|
||||
return
|
||||
|
||||
def action_toggle_setting(self) -> None:
|
||||
setting = self.settings[self.selected_index]
|
||||
key: str = setting["key"]
|
||||
current: str = self._get_display_value(setting)
|
||||
if option_id == "action:active_model":
|
||||
self.post_message(self.OpenModelPicker())
|
||||
return
|
||||
|
||||
options: list[str] = setting["options"]
|
||||
new_value = ""
|
||||
try:
|
||||
current_idx = options.index(current)
|
||||
next_idx = (current_idx + 1) % len(options)
|
||||
new_value = options[next_idx]
|
||||
except (ValueError, IndexError):
|
||||
new_value = options[0] if options else current
|
||||
|
||||
self.changes[key] = new_value
|
||||
|
||||
self.post_message(self.SettingChanged(key=key, value=new_value))
|
||||
|
||||
self._update_display()
|
||||
|
||||
def action_cycle(self) -> None:
|
||||
self.action_toggle_setting()
|
||||
if option_id.startswith("toggle:"):
|
||||
key = option_id.removeprefix("toggle:")
|
||||
current = self._get_toggle_value(key)
|
||||
new_value = "Off" if current == "On" else "On"
|
||||
self.changes[key] = new_value
|
||||
self.post_message(self.SettingChanged(key=key, value=new_value))
|
||||
self._refresh_options()
|
||||
|
||||
def _convert_changes_for_save(self) -> dict[str, str | bool]:
|
||||
result: dict[str, str | bool] = {}
|
||||
|
|
@ -172,6 +135,3 @@ class ConfigApp(Container):
|
|||
|
||||
def action_close(self) -> None:
|
||||
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
self.call_after_refresh(self.focus)
|
||||
|
|
|
|||
69
vibe/cli/textual_ui/widgets/feedback_bar.py
Normal file
69
vibe/cli/textual_ui/widgets/feedback_bar.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
|
||||
FEEDBACK_PROBABILITY = 0.02
|
||||
THANK_YOU_DURATION = 2.0
|
||||
|
||||
|
||||
class FeedbackBar(Widget):
|
||||
class FeedbackGiven(Message):
|
||||
def __init__(self, rating: int) -> None:
|
||||
super().__init__()
|
||||
self.rating = rating
|
||||
|
||||
@staticmethod
|
||||
def _prompt_text() -> Text:
|
||||
text = Text()
|
||||
text.append("How is Vibe doing so far? ")
|
||||
text.append("1", style="blue")
|
||||
text.append(": good ")
|
||||
text.append("2", style="blue")
|
||||
text.append(": fine ")
|
||||
text.append("3", style="blue")
|
||||
text.append(": bad")
|
||||
return text
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static(self._prompt_text(), id="feedback-text")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.display = False
|
||||
|
||||
def maybe_show(self) -> None:
|
||||
if self.display:
|
||||
return
|
||||
if random.random() <= FEEDBACK_PROBABILITY:
|
||||
self._set_active(True)
|
||||
|
||||
def hide(self) -> None:
|
||||
if self.display:
|
||||
self._set_active(False)
|
||||
|
||||
def handle_feedback_key(self, rating: int) -> None:
|
||||
try:
|
||||
self.app.query_one(ChatTextArea).feedback_active = False
|
||||
except Exception:
|
||||
pass
|
||||
self.query_one("#feedback-text", Static).update(
|
||||
Text("Thank you for your feedback!")
|
||||
)
|
||||
self.post_message(self.FeedbackGiven(rating))
|
||||
self.set_timer(THANK_YOU_DURATION, lambda: self._set_active(False))
|
||||
|
||||
def _set_active(self, active: bool) -> None:
|
||||
if active:
|
||||
self.query_one("#feedback-text", Static).update(self._prompt_text())
|
||||
self.display = active
|
||||
try:
|
||||
self.app.query_one(ChatTextArea).feedback_active = active
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -11,6 +11,7 @@ from textual.app import ComposeResult
|
|||
from textual.containers import Horizontal
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.constants import MistralColors
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
|
||||
|
|
@ -28,7 +29,13 @@ def _format_elapsed(seconds: int) -> str:
|
|||
|
||||
|
||||
class LoadingWidget(SpinnerMixin, Static):
|
||||
TARGET_COLORS = ("#FFD800", "#FFAF00", "#FF8205", "#FA500F", "#E10500")
|
||||
TARGET_COLORS = (
|
||||
MistralColors.YELLOW,
|
||||
MistralColors.ORANGE_LIGHT,
|
||||
MistralColors.ORANGE,
|
||||
MistralColors.ORANGE_DARK,
|
||||
MistralColors.RED,
|
||||
)
|
||||
SPINNER_TYPE = SpinnerType.SNAKE
|
||||
|
||||
EASTER_EGGS: ClassVar[list[str]] = [
|
||||
|
|
|
|||
75
vibe/cli/textual_ui/widgets/model_picker.py
Normal file
75
vibe/cli/textual_ui/widgets/model_picker.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
|
||||
def _build_option_text(alias: str, is_current: bool) -> Text:
|
||||
text = Text(no_wrap=True)
|
||||
marker = "› " if is_current else " "
|
||||
style = "bold" if is_current else ""
|
||||
text.append(marker, style="green" if is_current else "")
|
||||
text.append(alias, style=style)
|
||||
return text
|
||||
|
||||
|
||||
class ModelPickerApp(Container):
|
||||
"""Model picker bottom app for selecting the active model."""
|
||||
|
||||
can_focus_children = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("escape", "cancel", "Cancel", show=False)
|
||||
]
|
||||
|
||||
class ModelSelected(Message):
|
||||
def __init__(self, alias: str) -> None:
|
||||
self.alias = alias
|
||||
super().__init__()
|
||||
|
||||
class Cancelled(Message):
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self, model_aliases: list[str], current_model: str, **kwargs: Any
|
||||
) -> None:
|
||||
super().__init__(id="modelpicker-app", **kwargs)
|
||||
self._model_aliases = model_aliases
|
||||
self._current_model = current_model
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
options = [
|
||||
Option(_build_option_text(alias, alias == self._current_model), id=alias)
|
||||
for alias in self._model_aliases
|
||||
]
|
||||
with Vertical(id="modelpicker-content"):
|
||||
yield NoMarkupStatic("Select Model", classes="modelpicker-title")
|
||||
yield OptionList(*options, id="modelpicker-options")
|
||||
yield NoMarkupStatic(
|
||||
"↑↓ Navigate Enter Select Esc Cancel", classes="modelpicker-help"
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
option_list = self.query_one(OptionList)
|
||||
# Pre-select the current model
|
||||
for i, alias in enumerate(self._model_aliases):
|
||||
if alias == self._current_model:
|
||||
option_list.highlighted = i
|
||||
break
|
||||
option_list.focus()
|
||||
|
||||
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
||||
if event.option.id:
|
||||
self.post_message(self.ModelSelected(event.option.id))
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.post_message(self.Cancelled())
|
||||
56
vibe/cli/textual_ui/widgets/narrator_status.py
Normal file
56
vibe/cli/textual_ui/widgets/narrator_status.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
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
|
||||
|
||||
SHRINK_FRAMES = "█▇▆▅▄▃▂▁"
|
||||
BAR_FRAMES = ["▂▅▇", "▃▆▅", "▅▃▇", "▇▂▅", "▅▇▃", "▃▅▆"]
|
||||
ANIMATION_INTERVAL = 0.15
|
||||
|
||||
|
||||
class NarratorState(StrEnum):
|
||||
IDLE = auto()
|
||||
SUMMARIZING = auto()
|
||||
SPEAKING = auto()
|
||||
|
||||
|
||||
class NarratorStatus(Static):
|
||||
state = reactive(NarratorState.IDLE)
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__("", **kwargs)
|
||||
self._timer: Timer | None = None
|
||||
self._frame: int = 0
|
||||
|
||||
def watch_state(self, new_state: NarratorState) -> None:
|
||||
self._stop_timer()
|
||||
match new_state:
|
||||
case NarratorState.IDLE:
|
||||
self.update("")
|
||||
case NarratorState.SUMMARIZING | NarratorState.SPEAKING:
|
||||
self._frame = 0
|
||||
self._tick()
|
||||
self._timer = self.set_interval(ANIMATION_INTERVAL, self._tick)
|
||||
|
||||
def _tick(self) -> None:
|
||||
match self.state:
|
||||
case NarratorState.SUMMARIZING:
|
||||
char = SHRINK_FRAMES[self._frame % len(SHRINK_FRAMES)]
|
||||
self.update(
|
||||
f"[bold orange]{char}[/bold orange] summarizing [dim]esc to stop[/dim]"
|
||||
)
|
||||
case NarratorState.SPEAKING:
|
||||
bars = BAR_FRAMES[self._frame % len(BAR_FRAMES)]
|
||||
self.update(
|
||||
f"[bold orange]{bars}[/bold orange] speaking [dim]esc to stop[/dim]"
|
||||
)
|
||||
self._frame += 1
|
||||
|
||||
def _stop_timer(self) -> None:
|
||||
if self._timer is not None:
|
||||
self._timer.stop()
|
||||
self._timer = None
|
||||
173
vibe/cli/textual_ui/widgets/voice_app.py
Normal file
173
vibe/cli/textual_ui/widgets/voice_app.py
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar, TypedDict
|
||||
|
||||
from textual import events
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding, BindingType
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.core.config import VibeConfig
|
||||
|
||||
|
||||
class SettingDefinition(TypedDict):
|
||||
key: str
|
||||
label: str
|
||||
type: str
|
||||
options: list[str]
|
||||
|
||||
|
||||
class VoiceApp(Container):
|
||||
can_focus = True
|
||||
can_focus_children = False
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("up", "move_up", "Up", show=False),
|
||||
Binding("down", "move_down", "Down", show=False),
|
||||
Binding("space", "toggle_setting", "Toggle", show=False),
|
||||
Binding("enter", "cycle", "Next", show=False),
|
||||
]
|
||||
|
||||
class SettingChanged(Message):
|
||||
def __init__(self, key: str, value: str) -> None:
|
||||
super().__init__()
|
||||
self.key = key
|
||||
self.value = value
|
||||
|
||||
class ConfigClosed(Message):
|
||||
def __init__(self, changes: dict[str, str | bool]) -> None:
|
||||
super().__init__()
|
||||
self.changes = changes
|
||||
|
||||
def __init__(self, config: VibeConfig) -> None:
|
||||
super().__init__(id="voice-app")
|
||||
self.config = config
|
||||
self.selected_index = 0
|
||||
self.changes: dict[str, str] = {}
|
||||
|
||||
self.settings: list[SettingDefinition] = [
|
||||
{
|
||||
"key": "voice_mode_enabled",
|
||||
"label": "Voice mode",
|
||||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
{
|
||||
"key": "narrator_enabled",
|
||||
"label": "Narrator (experimental)",
|
||||
"type": "cycle",
|
||||
"options": ["On", "Off"],
|
||||
},
|
||||
]
|
||||
|
||||
self.title_widget: Static | None = None
|
||||
self.setting_widgets: list[Static] = []
|
||||
self.help_widget: Static | None = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="voice-content"):
|
||||
self.title_widget = NoMarkupStatic(
|
||||
"Voice Settings", classes="settings-title"
|
||||
)
|
||||
yield self.title_widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
for _ in self.settings:
|
||||
widget = NoMarkupStatic("", classes="settings-option")
|
||||
self.setting_widgets.append(widget)
|
||||
yield widget
|
||||
|
||||
yield NoMarkupStatic("")
|
||||
|
||||
self.help_widget = NoMarkupStatic(
|
||||
"↑↓ navigate Space/Enter toggle ESC exit", classes="settings-help"
|
||||
)
|
||||
yield self.help_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._update_display()
|
||||
self.focus()
|
||||
|
||||
def _get_display_value(self, setting: SettingDefinition) -> str:
|
||||
key = setting["key"]
|
||||
if key in self.changes:
|
||||
return self.changes[key]
|
||||
raw_value = getattr(self.config, key, "")
|
||||
if isinstance(raw_value, bool):
|
||||
return "On" if raw_value else "Off"
|
||||
return str(raw_value)
|
||||
|
||||
def _update_display(self) -> None:
|
||||
for i, (setting, widget) in enumerate(
|
||||
zip(self.settings, self.setting_widgets, strict=True)
|
||||
):
|
||||
is_selected = i == self.selected_index
|
||||
cursor = "› " if is_selected else " "
|
||||
|
||||
label: str = setting["label"]
|
||||
value: str = self._get_display_value(setting)
|
||||
|
||||
text = f"{cursor}{label}: {value}"
|
||||
|
||||
widget.update(text)
|
||||
|
||||
widget.remove_class("settings-cursor-selected")
|
||||
widget.remove_class("settings-value-cycle-selected")
|
||||
widget.remove_class("settings-value-cycle-unselected")
|
||||
|
||||
if is_selected:
|
||||
widget.add_class("settings-value-cycle-selected")
|
||||
else:
|
||||
widget.add_class("settings-value-cycle-unselected")
|
||||
|
||||
def action_move_up(self) -> None:
|
||||
self.selected_index = (self.selected_index - 1) % len(self.settings)
|
||||
self._update_display()
|
||||
|
||||
def action_move_down(self) -> None:
|
||||
self.selected_index = (self.selected_index + 1) % len(self.settings)
|
||||
self._update_display()
|
||||
|
||||
def action_toggle_setting(self) -> None:
|
||||
setting = self.settings[self.selected_index]
|
||||
key: str = setting["key"]
|
||||
current: str = self._get_display_value(setting)
|
||||
|
||||
options: list[str] = setting["options"]
|
||||
new_value = ""
|
||||
try:
|
||||
current_idx = options.index(current)
|
||||
next_idx = (current_idx + 1) % len(options)
|
||||
new_value = options[next_idx]
|
||||
except (ValueError, IndexError):
|
||||
new_value = options[0] if options else current
|
||||
|
||||
self.changes[key] = new_value
|
||||
|
||||
self.post_message(self.SettingChanged(key=key, value=new_value))
|
||||
|
||||
self._update_display()
|
||||
|
||||
def action_cycle(self) -> None:
|
||||
self.action_toggle_setting()
|
||||
|
||||
def _convert_changes_for_save(self) -> dict[str, str | bool]:
|
||||
result: dict[str, str | bool] = {}
|
||||
for key, value in self.changes.items():
|
||||
if value in {"On", "Off"}:
|
||||
result[key] = value == "On"
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def action_close(self) -> None:
|
||||
self.post_message(self.ConfigClosed(changes=self._convert_changes_for_save()))
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
self.call_after_refresh(self.focus)
|
||||
|
|
@ -14,7 +14,7 @@ def patch_vscode_space(event: events.Key) -> None:
|
|||
silently drop the keystroke because there is no printable character.
|
||||
Assigning ``event.character = " "`` restores normal behaviour.
|
||||
"""
|
||||
if event.key == "space" and event.character is None:
|
||||
if event.key in {"space", "shift+space"} and event.character is None:
|
||||
event.character = " "
|
||||
|
||||
|
||||
|
|
|
|||
20
vibe/cli/turn_summary/__init__.py
Normal file
20
vibe/cli/turn_summary/__init__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.turn_summary.noop import NoopTurnSummary
|
||||
from vibe.cli.turn_summary.port import (
|
||||
TurnSummaryData,
|
||||
TurnSummaryPort,
|
||||
TurnSummaryResult,
|
||||
)
|
||||
from vibe.cli.turn_summary.tracker import TurnSummaryTracker
|
||||
from vibe.cli.turn_summary.utils import NARRATOR_MODEL, create_narrator_backend
|
||||
|
||||
__all__ = [
|
||||
"NARRATOR_MODEL",
|
||||
"NoopTurnSummary",
|
||||
"TurnSummaryData",
|
||||
"TurnSummaryPort",
|
||||
"TurnSummaryResult",
|
||||
"TurnSummaryTracker",
|
||||
"create_narrator_backend",
|
||||
]
|
||||
30
vibe/cli/turn_summary/noop.py
Normal file
30
vibe/cli/turn_summary/noop.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from vibe.cli.turn_summary.port import TurnSummaryPort
|
||||
from vibe.core.types import BaseEvent
|
||||
|
||||
|
||||
class NoopTurnSummary(TurnSummaryPort):
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
return 0
|
||||
|
||||
def start_turn(self, user_message: str) -> None:
|
||||
pass
|
||||
|
||||
def track(self, event: BaseEvent) -> None:
|
||||
pass
|
||||
|
||||
def set_error(self, message: str) -> None:
|
||||
pass
|
||||
|
||||
def cancel_turn(self) -> None:
|
||||
pass
|
||||
|
||||
def end_turn(self) -> Callable[[], bool] | None:
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
pass
|
||||
43
vibe/cli/turn_summary/port.py
Normal file
43
vibe/cli/turn_summary/port.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from vibe.core.types import BaseEvent
|
||||
|
||||
|
||||
class TurnSummaryData(BaseModel):
|
||||
user_message: str
|
||||
assistant_fragments: list[str] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class TurnSummaryResult(BaseModel):
|
||||
generation: int
|
||||
summary: str | None
|
||||
|
||||
|
||||
class TurnSummaryPort(ABC):
|
||||
@property
|
||||
@abstractmethod
|
||||
def generation(self) -> int: ...
|
||||
|
||||
@abstractmethod
|
||||
def start_turn(self, user_message: str) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def track(self, event: BaseEvent) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def set_error(self, message: str) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def cancel_turn(self) -> None: ...
|
||||
|
||||
@abstractmethod
|
||||
def end_turn(self) -> Callable[[], bool] | None: ...
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None: ...
|
||||
109
vibe/cli/turn_summary/tracker.py
Normal file
109
vibe/cli/turn_summary/tracker.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from vibe.cli.turn_summary.port import (
|
||||
TurnSummaryData,
|
||||
TurnSummaryPort,
|
||||
TurnSummaryResult,
|
||||
)
|
||||
from vibe.core.config import ModelConfig
|
||||
from vibe.core.llm.types import BackendLike
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.prompts import UtilityPrompt
|
||||
from vibe.core.types import AssistantEvent, BaseEvent, LLMMessage, Role
|
||||
|
||||
|
||||
class TurnSummaryTracker(TurnSummaryPort):
|
||||
def __init__(
|
||||
self,
|
||||
backend: BackendLike,
|
||||
model: ModelConfig,
|
||||
on_summary: Callable[[TurnSummaryResult], None],
|
||||
max_tokens: int = 512,
|
||||
) -> None:
|
||||
self._backend = backend
|
||||
self._model = model
|
||||
self._on_summary = on_summary
|
||||
self._max_tokens = max_tokens
|
||||
self._tasks: set[asyncio.Task[Any]] = set()
|
||||
self._data: TurnSummaryData | None = None
|
||||
self._generation: int = 0
|
||||
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
return self._generation
|
||||
|
||||
def start_turn(self, user_message: str) -> None:
|
||||
self._generation += 1
|
||||
self._data = TurnSummaryData(user_message=user_message)
|
||||
|
||||
def track(self, event: BaseEvent) -> None:
|
||||
if self._data is None:
|
||||
return
|
||||
match event:
|
||||
case AssistantEvent(content=c) if c:
|
||||
self._data.assistant_fragments.append(c)
|
||||
|
||||
def set_error(self, message: str) -> None:
|
||||
if self._data is not None:
|
||||
self._data.error = message
|
||||
|
||||
def cancel_turn(self) -> None:
|
||||
self._data = None
|
||||
|
||||
def end_turn(self) -> Callable[[], bool] | None:
|
||||
if self._data is None:
|
||||
return None
|
||||
gen = self._generation
|
||||
task = asyncio.create_task(self._generate_summary(self._data, gen))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(self._tasks.discard)
|
||||
self._data = None
|
||||
return task.cancel
|
||||
|
||||
async def close(self) -> None:
|
||||
for task in self._tasks:
|
||||
task.cancel()
|
||||
await asyncio.gather(*self._tasks, return_exceptions=True)
|
||||
self._tasks.clear()
|
||||
|
||||
async def _generate_summary(self, data: TurnSummaryData, gen: int) -> None:
|
||||
try:
|
||||
prompt_text = UtilityPrompt.TURN_SUMMARY.read()
|
||||
|
||||
sections: list[str] = []
|
||||
sections.append(f"## User Request\n{data.user_message}")
|
||||
|
||||
full_text = "".join(data.assistant_fragments)
|
||||
if full_text:
|
||||
sections.append(f"## Assistant Response\n{full_text}")
|
||||
|
||||
if data.error:
|
||||
sections.append(f"## Error\n{data.error}")
|
||||
|
||||
extraction_text = "\n\n".join(sections)
|
||||
|
||||
summary_messages = [
|
||||
LLMMessage(role=Role.system, content=prompt_text),
|
||||
LLMMessage(role=Role.user, content=extraction_text),
|
||||
]
|
||||
|
||||
result = await self._backend.complete(
|
||||
model=self._model,
|
||||
messages=summary_messages,
|
||||
temperature=0.0,
|
||||
tools=None,
|
||||
tool_choice=None,
|
||||
max_tokens=self._max_tokens,
|
||||
extra_headers={},
|
||||
metadata={},
|
||||
)
|
||||
|
||||
summary = result.message.content or ""
|
||||
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))
|
||||
30
vibe/cli/turn_summary/utils.py
Normal file
30
vibe/cli/turn_summary/utils.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from vibe.core.config import ModelConfig, VibeConfig
|
||||
from vibe.core.llm.backend.factory import BACKEND_FACTORY
|
||||
from vibe.core.llm.types import BackendLike
|
||||
|
||||
NARRATOR_MODEL = ModelConfig(
|
||||
name="mistral-vibe-cli-fast",
|
||||
provider="mistral",
|
||||
alias="mistral-small",
|
||||
input_price=0.1,
|
||||
output_price=0.3,
|
||||
)
|
||||
|
||||
|
||||
def create_narrator_backend(
|
||||
config: VibeConfig,
|
||||
) -> tuple[BackendLike, ModelConfig] | None:
|
||||
try:
|
||||
provider = config.get_provider_for_model(NARRATOR_MODEL)
|
||||
except ValueError:
|
||||
return None
|
||||
if provider.api_key_env_var and not os.getenv(provider.api_key_env_var):
|
||||
return None
|
||||
backend = BACKEND_FACTORY[provider.backend](
|
||||
provider=provider, timeout=config.api_timeout
|
||||
)
|
||||
return backend, NARRATOR_MODEL
|
||||
|
|
@ -7,6 +7,7 @@ from vibe.cli.update_notifier.ports.update_cache_repository import (
|
|||
UpdateCache,
|
||||
UpdateCacheRepository,
|
||||
)
|
||||
from vibe.core.utils.io import read_safe
|
||||
|
||||
|
||||
async def should_show_whats_new(
|
||||
|
|
@ -23,7 +24,7 @@ def load_whats_new_content() -> str | None:
|
|||
if not whats_new_file.exists():
|
||||
return None
|
||||
try:
|
||||
content = whats_new_file.read_text(encoding="utf-8").strip()
|
||||
content = read_safe(whats_new_file).strip()
|
||||
return content if content else None
|
||||
except OSError:
|
||||
return None
|
||||
|
|
|
|||
30
vibe/cli/voice_manager/telemetry.py
Normal file
30
vibe/cli/voice_manager/telemetry.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
|
||||
|
||||
@dataclass
|
||||
class TranscriptionTrackingState:
|
||||
recording_id: str = ""
|
||||
start_time: float = field(default_factory=time.monotonic)
|
||||
accumulated_transcript_length: int = 0
|
||||
last_recording_duration_ms: float | None = None
|
||||
|
||||
def reset(self) -> None:
|
||||
self.recording_id = ""
|
||||
self.start_time = time.monotonic()
|
||||
self.accumulated_transcript_length = 0
|
||||
self.last_recording_duration_ms = None
|
||||
|
||||
def set_recording_id(self, recording_id: str) -> None:
|
||||
self.recording_id = recording_id
|
||||
|
||||
def record_text(self, text: str) -> None:
|
||||
self.accumulated_transcript_length += len(text)
|
||||
|
||||
def elapsed_ms(self) -> float:
|
||||
return (time.monotonic() - self.start_time) * 1000
|
||||
|
||||
def set_recording_duration(self, duration_s: float) -> None:
|
||||
self.last_recording_duration_ms = duration_s * 1000
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from asyncio import CancelledError, Task, create_task, wait_for
|
||||
from collections.abc import Callable
|
||||
from asyncio import CancelledError, create_task, wait_for
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from vibe.cli.voice_manager.telemetry import TranscriptionTrackingState
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
RecordingStartError,
|
||||
TranscribeState,
|
||||
VoiceManagerListener,
|
||||
VoiceToggleResult,
|
||||
)
|
||||
from vibe.core.audio_recorder import AudioRecorderPort
|
||||
from vibe.core.audio_recorder.audio_recorder_port import (
|
||||
AlreadyRecordingError,
|
||||
AudioBackendUnavailableError,
|
||||
|
|
@ -19,13 +18,21 @@ from vibe.core.audio_recorder.audio_recorder_port import (
|
|||
from vibe.core.config import VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
from vibe.core.transcribe.transcribe_client_port import (
|
||||
TranscribeClientPort,
|
||||
TranscribeDone,
|
||||
TranscribeError,
|
||||
TranscribeSessionCreated,
|
||||
TranscribeTextDelta,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncio import Task
|
||||
from collections.abc import Callable
|
||||
|
||||
from vibe.cli.voice_manager.voice_manager_port import VoiceManagerListener
|
||||
from vibe.core.audio_recorder import AudioRecorderPort
|
||||
from vibe.core.telemetry.send import TelemetryClient
|
||||
from vibe.core.transcribe.transcribe_client_port import TranscribeClientPort
|
||||
|
||||
TRANSCRIPTION_DRAIN_TIMEOUT = 10.0
|
||||
|
||||
|
||||
|
|
@ -35,13 +42,16 @@ class VoiceManager:
|
|||
config_getter: Callable[[], VibeConfig],
|
||||
audio_recorder: AudioRecorderPort,
|
||||
transcribe_client: TranscribeClientPort | None,
|
||||
telemetry_client: TelemetryClient | None = None,
|
||||
) -> None:
|
||||
self._config_getter = config_getter
|
||||
self._audio_recorder = audio_recorder
|
||||
self._transcribe_client = transcribe_client
|
||||
self._telemetry_client = telemetry_client
|
||||
self._transcribe_state = TranscribeState.IDLE
|
||||
self._transcribe_task: Task[None] | None = None
|
||||
self._listeners: list[VoiceManagerListener] = []
|
||||
self._tracking = TranscriptionTrackingState()
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
|
|
@ -91,6 +101,7 @@ class VoiceManager:
|
|||
except NoAudioInputDeviceError:
|
||||
raise RecordingStartError("No audio input device found")
|
||||
|
||||
self._tracking.reset()
|
||||
self._set_state(TranscribeState.RECORDING)
|
||||
self._transcribe_task = create_task(self._run_transcription())
|
||||
|
||||
|
|
@ -101,7 +112,8 @@ class VoiceManager:
|
|||
|
||||
if should_flush_queue:
|
||||
self._set_state(TranscribeState.FLUSHING)
|
||||
self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
|
||||
recording = self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
|
||||
self._tracking.set_recording_duration(recording.duration)
|
||||
|
||||
if self._transcribe_task is not None:
|
||||
try:
|
||||
|
|
@ -111,6 +123,7 @@ class VoiceManager:
|
|||
except TimeoutError:
|
||||
logger.warning("Transcription task timed out, cancelling")
|
||||
self._transcribe_task.cancel()
|
||||
self._on_audio_transcription_error("Transcription timed out")
|
||||
except CancelledError:
|
||||
pass
|
||||
self._transcribe_task = None
|
||||
|
|
@ -129,6 +142,7 @@ class VoiceManager:
|
|||
self._transcribe_task = None
|
||||
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
self._on_audio_transcription_cancel()
|
||||
|
||||
def add_listener(self, listener: VoiceManagerListener) -> None:
|
||||
if listener not in self._listeners:
|
||||
|
|
@ -150,6 +164,7 @@ class VoiceManager:
|
|||
async for event in self._transcribe_client.transcribe(audio_stream):
|
||||
match event:
|
||||
case TranscribeTextDelta(text=text):
|
||||
self._tracking.record_text(text)
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener.on_transcribe_text(text)
|
||||
|
|
@ -158,22 +173,80 @@ class VoiceManager:
|
|||
"Listener raised during transcribe text",
|
||||
exc_info=True,
|
||||
)
|
||||
case TranscribeDone():
|
||||
pass
|
||||
case TranscribeError(message=msg):
|
||||
raise RuntimeError(msg)
|
||||
case TranscribeSessionCreated():
|
||||
case TranscribeSessionCreated(request_id=request_id):
|
||||
self._tracking.set_recording_id(request_id)
|
||||
self._on_audio_transcription_start()
|
||||
case TranscribeDone():
|
||||
pass
|
||||
|
||||
if self._transcribe_state != TranscribeState.IDLE:
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
|
||||
self._on_audio_transcription_done()
|
||||
except CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.error("Transcription failed", exc_info=exc)
|
||||
self._audio_recorder.cancel()
|
||||
|
||||
if self._transcribe_state != TranscribeState.IDLE:
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
|
||||
self._on_audio_transcription_error(str(exc))
|
||||
|
||||
def _on_audio_transcription_start(self) -> None:
|
||||
if not self._telemetry_client:
|
||||
return
|
||||
self._telemetry_client.send_telemetry_event(
|
||||
"vibe.audio.transcription.start",
|
||||
{"recording_id": self._tracking.recording_id},
|
||||
)
|
||||
|
||||
def _on_audio_transcription_cancel(self) -> None:
|
||||
if not self._telemetry_client:
|
||||
return
|
||||
self._telemetry_client.send_telemetry_event(
|
||||
"vibe.audio.transcription.cancel_recording",
|
||||
{
|
||||
"recording_id": self._tracking.recording_id,
|
||||
"recording_duration_ms": self._tracking.elapsed_ms(),
|
||||
},
|
||||
)
|
||||
|
||||
def _on_audio_transcription_done(self) -> None:
|
||||
if not self._telemetry_client:
|
||||
return
|
||||
transcription_duration_ms = self._tracking.elapsed_ms()
|
||||
recording_duration_ms = (
|
||||
self._tracking.last_recording_duration_ms
|
||||
if self._tracking.last_recording_duration_ms is not None
|
||||
else transcription_duration_ms
|
||||
)
|
||||
self._telemetry_client.send_telemetry_event(
|
||||
"vibe.audio.transcription.done",
|
||||
{
|
||||
"recording_id": self._tracking.recording_id,
|
||||
"transcript_length": self._tracking.accumulated_transcript_length,
|
||||
"transcription_duration_ms": transcription_duration_ms,
|
||||
"recording_duration_ms": recording_duration_ms,
|
||||
},
|
||||
)
|
||||
|
||||
def _on_audio_transcription_error(self, error_message: str) -> None:
|
||||
if not self._telemetry_client:
|
||||
return
|
||||
self._telemetry_client.send_telemetry_event(
|
||||
"vibe.audio.transcription.error",
|
||||
{
|
||||
"recording_id": self._tracking.recording_id,
|
||||
"error_message": error_message,
|
||||
"transcription_duration_ms": self._tracking.elapsed_ms(),
|
||||
"recording_duration_ms": self._tracking.last_recording_duration_ms,
|
||||
},
|
||||
)
|
||||
|
||||
def _set_state(self, state: TranscribeState) -> None:
|
||||
if self._transcribe_state == state:
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue