v2.5.0 (#495)
Co-authored-by: Quentin Torroba <quentin.torroba@mistral.ai> Co-authored-by: Clement Sirieix <clem.sirieix@gmail.com> Co-authored-by: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai> Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai> Co-authored-by: Vincent Guilloux <vincent.guilloux@mistral.ai> Co-authored-by: Michel Thomazo <michel.thomazo@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
9421fbc08e
commit
5103019b01
104 changed files with 7277 additions and 691 deletions
|
|
@ -77,6 +77,21 @@ class CommandRegistry:
|
|||
description="Browse and resume past sessions",
|
||||
handler="_show_session_picker",
|
||||
),
|
||||
"voice": Command(
|
||||
aliases=frozenset(["/voice"]),
|
||||
description="Toggle voice mode on/off",
|
||||
handler="_toggle_voice_mode",
|
||||
),
|
||||
"leanstall": Command(
|
||||
aliases=frozenset(["/leanstall"]),
|
||||
description="Install the Lean 4 agent (leanstral)",
|
||||
handler="_install_lean",
|
||||
),
|
||||
"unleanstall": Command(
|
||||
aliases=frozenset(["/unleanstall"]),
|
||||
description="Uninstall the Lean 4 agent",
|
||||
handler="_uninstall_lean",
|
||||
),
|
||||
}
|
||||
|
||||
for command in excluded_commands:
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class HistoryManager:
|
|||
self._save_history()
|
||||
self.reset_navigation()
|
||||
|
||||
def get_previous(self, current_input: str, prefix: str = "") -> str | None:
|
||||
def get_previous(self, current_input: str) -> str | None:
|
||||
if not self._entries:
|
||||
return None
|
||||
|
||||
|
|
@ -66,21 +66,19 @@ class HistoryManager:
|
|||
self._temp_input = current_input
|
||||
self._current_index = len(self._entries)
|
||||
|
||||
for i in range(self._current_index - 1, -1, -1):
|
||||
if self._entries[i].startswith(prefix):
|
||||
self._current_index = i
|
||||
return self._entries[i]
|
||||
if self._current_index <= 0:
|
||||
return None
|
||||
|
||||
return None
|
||||
self._current_index -= 1
|
||||
return self._entries[self._current_index]
|
||||
|
||||
def get_next(self, prefix: str = "") -> str | None:
|
||||
def get_next(self) -> str | None:
|
||||
if self._current_index == -1:
|
||||
return None
|
||||
|
||||
for i in range(self._current_index + 1, len(self._entries)):
|
||||
if self._entries[i].startswith(prefix):
|
||||
self._current_index = i
|
||||
return self._entries[i]
|
||||
if self._current_index < len(self._entries) - 1:
|
||||
self._current_index += 1
|
||||
return self._entries[self._current_index]
|
||||
|
||||
result = self._temp_input
|
||||
self.reset_navigation()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
from os import getenv
|
||||
|
||||
|
|
@ -19,6 +20,11 @@ UPGRADE_URL = CONSOLE_CLI_URL
|
|||
SWITCH_TO_PRO_KEY_URL = CONSOLE_CLI_URL
|
||||
|
||||
|
||||
class MistralCodePlanName(StrEnum):
|
||||
FREE = "F"
|
||||
ENTERPRISE = "E"
|
||||
|
||||
|
||||
class PlanInfo:
|
||||
plan_type: WhoAmIPlanType
|
||||
plan_name: str
|
||||
|
|
@ -51,6 +57,18 @@ class PlanInfo:
|
|||
def is_chat_pro_plan(self) -> bool:
|
||||
return self.plan_type == WhoAmIPlanType.CHAT
|
||||
|
||||
def is_free_mistral_code_plan(self) -> bool:
|
||||
return (
|
||||
self.plan_type == WhoAmIPlanType.MISTRAL_CODE
|
||||
and self.plan_name.upper() == MistralCodePlanName.FREE
|
||||
)
|
||||
|
||||
def is_mistral_code_enterprise_plan(self) -> bool:
|
||||
return (
|
||||
self.plan_type == WhoAmIPlanType.MISTRAL_CODE
|
||||
and self.plan_name.upper() == MistralCodePlanName.ENTERPRISE
|
||||
)
|
||||
|
||||
|
||||
async def decide_plan_offer(api_key: str | None, gateway: WhoAmIGateway) -> PlanInfo:
|
||||
if not api_key:
|
||||
|
|
@ -79,11 +97,14 @@ def plan_offer_cta(payload: PlanInfo | None) -> str | None:
|
|||
return
|
||||
if payload.prompt_switching_to_pro_plan:
|
||||
return f"### Switch to your [Le Chat Pro API key]({SWITCH_TO_PRO_KEY_URL})"
|
||||
if payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}:
|
||||
if (
|
||||
payload.plan_type in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
|
||||
or payload.is_free_mistral_code_plan()
|
||||
):
|
||||
return f"### Unlock more with Vibe - [Upgrade to Le Chat Pro]({UPGRADE_URL})"
|
||||
|
||||
|
||||
def plan_title(payload: PlanInfo | None) -> str | None:
|
||||
def plan_title(payload: PlanInfo | None) -> str | None: # noqa: PLR0911
|
||||
if not payload:
|
||||
return None
|
||||
if payload.is_chat_pro_plan():
|
||||
|
|
@ -92,4 +113,8 @@ def plan_title(payload: PlanInfo | None) -> str | None:
|
|||
return "[API] Experiment plan"
|
||||
if payload.is_paid_api_plan():
|
||||
return "[API] Scale plan"
|
||||
if payload.is_free_mistral_code_plan():
|
||||
return "Mistral Code Free"
|
||||
if payload.is_mistral_code_enterprise_plan():
|
||||
return "Mistral Code Enterprise"
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from pydantic import TypeAdapter, ValidationError
|
|||
class WhoAmIPlanType(StrEnum):
|
||||
API = "API"
|
||||
CHAT = "CHAT"
|
||||
MISTRAL_CODE = "MISTRAL_CODE"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
UNAUTHORIZED = "UNAUTHORIZED"
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
from enum import StrEnum, auto
|
||||
import gc
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
|
|
@ -87,8 +88,11 @@ from vibe.cli.update_notifier import (
|
|||
should_show_whats_new,
|
||||
)
|
||||
from vibe.cli.update_notifier.update import do_update
|
||||
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_recorder import AudioRecorder
|
||||
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
|
||||
from vibe.core.config import Backend, VibeConfig
|
||||
from vibe.core.logger import logger
|
||||
|
|
@ -112,6 +116,7 @@ from vibe.core.tools.builtins.ask_user_question import (
|
|||
Choice,
|
||||
Question,
|
||||
)
|
||||
from vibe.core.transcribe import make_transcribe_client
|
||||
from vibe.core.types import (
|
||||
AgentStats,
|
||||
ApprovalResponse,
|
||||
|
|
@ -147,7 +152,11 @@ class ChatScroll(VerticalScroll):
|
|||
|
||||
@property
|
||||
def is_at_bottom(self) -> bool:
|
||||
return self.scroll_offset.y >= (self.max_scroll_y - 3)
|
||||
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
|
||||
|
||||
def update_node_styles(self, animate: bool = True) -> None:
|
||||
pass
|
||||
|
|
@ -196,6 +205,7 @@ async def prune_oldest_children(
|
|||
class VibeApp(App): # noqa: PLR0904
|
||||
ENABLE_COMMAND_PALETTE = False
|
||||
CSS_PATH = "app.tcss"
|
||||
PAUSE_GC_ON_SCROLL: ClassVar[bool] = True
|
||||
|
||||
BINDINGS: ClassVar[list[BindingType]] = [
|
||||
Binding("ctrl+c", "clear_quit", "Quit", show=False),
|
||||
|
|
@ -222,10 +232,14 @@ class VibeApp(App): # noqa: PLR0904
|
|||
current_version: str = CORE_VERSION,
|
||||
plan_offer_gateway: WhoAmIGateway | None = None,
|
||||
terminal_notifier: NotificationPort | None = None,
|
||||
voice_manager: VoiceManagerPort | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.agent_loop = agent_loop
|
||||
self._voice_manager: VoiceManagerPort = (
|
||||
voice_manager or self._make_default_voice_manager()
|
||||
)
|
||||
self._terminal_notifier = terminal_notifier or TextualNotificationAdapter(
|
||||
self,
|
||||
get_enabled=lambda: self.config.enable_notifications,
|
||||
|
|
@ -238,6 +252,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._loading_widget: LoadingWidget | None = None
|
||||
self._pending_approval: asyncio.Future | None = None
|
||||
self._pending_question: asyncio.Future | None = None
|
||||
self._user_interaction_lock = asyncio.Lock()
|
||||
|
||||
self.event_handler: EventHandler | None = None
|
||||
|
||||
|
|
@ -296,6 +311,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
skill_entries_getter=self._get_skill_entries,
|
||||
file_watcher_for_autocomplete_getter=self._is_file_watcher_enabled,
|
||||
nuage_enabled=self.config.nuage_enabled,
|
||||
voice_manager=self._voice_manager,
|
||||
)
|
||||
|
||||
with Horizontal(id="bottom-bar"):
|
||||
|
|
@ -346,6 +362,9 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._initial_prompt or self._teleport_on_start:
|
||||
self.call_after_refresh(self._process_initial_prompt)
|
||||
|
||||
gc.collect()
|
||||
gc.freeze()
|
||||
|
||||
def _process_initial_prompt(self) -> None:
|
||||
if self._teleport_on_start:
|
||||
self.run_worker(
|
||||
|
|
@ -402,8 +421,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._pending_approval and not self._pending_approval.done():
|
||||
self._pending_approval.set_result((ApprovalResponse.YES, None))
|
||||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_approval_app_approval_granted_always_tool(
|
||||
self, message: ApprovalApp.ApprovalGrantedAlwaysTool
|
||||
) -> None:
|
||||
|
|
@ -414,8 +431,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._pending_approval and not self._pending_approval.done():
|
||||
self._pending_approval.set_result((ApprovalResponse.YES, None))
|
||||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_approval_app_approval_rejected(
|
||||
self, message: ApprovalApp.ApprovalRejected
|
||||
) -> None:
|
||||
|
|
@ -425,8 +440,6 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
self._pending_approval.set_result((ApprovalResponse.NO, feedback))
|
||||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._remove_loading_widget()
|
||||
|
||||
|
|
@ -435,16 +448,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
result = AskUserQuestionResult(answers=message.answers, cancelled=False)
|
||||
self._pending_question.set_result(result)
|
||||
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def on_question_app_cancelled(self, message: QuestionApp.Cancelled) -> None:
|
||||
if self._pending_question and not self._pending_question.done():
|
||||
result = AskUserQuestionResult(answers=[], cancelled=True)
|
||||
self._pending_question.set_result(result)
|
||||
|
||||
await self._switch_to_input_app()
|
||||
await self._interrupt_agent_loop()
|
||||
|
||||
async def _remove_loading_widget(self) -> None:
|
||||
if self._loading_widget and self._loading_widget.parent:
|
||||
await self._loading_widget.remove()
|
||||
|
|
@ -682,26 +690,32 @@ class VibeApp(App): # noqa: PLR0904
|
|||
if self._is_tool_enabled_in_main_agent(tool):
|
||||
return (ApprovalResponse.YES, None)
|
||||
|
||||
self._pending_approval = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
result = await self._pending_approval
|
||||
|
||||
self._pending_approval = None
|
||||
return result
|
||||
async with self._user_interaction_lock:
|
||||
self._pending_approval = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
try:
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_approval_app(tool, args)
|
||||
result = await self._pending_approval
|
||||
return result
|
||||
finally:
|
||||
self._pending_approval = None
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def _user_input_callback(self, args: BaseModel) -> BaseModel:
|
||||
question_args = cast(AskUserQuestionArgs, args)
|
||||
|
||||
self._pending_question = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_question_app(question_args)
|
||||
result = await self._pending_question
|
||||
|
||||
self._pending_question = None
|
||||
return result
|
||||
async with self._user_interaction_lock:
|
||||
self._pending_question = asyncio.Future()
|
||||
self._terminal_notifier.notify(NotificationContext.ACTION_REQUIRED)
|
||||
try:
|
||||
with paused_timer(self._loading_widget):
|
||||
await self._switch_to_question_app(question_args)
|
||||
result = await self._pending_question
|
||||
return result
|
||||
finally:
|
||||
self._pending_question = None
|
||||
await self._switch_to_input_app()
|
||||
|
||||
async def _handle_agent_loop_turn(self, prompt: str) -> None:
|
||||
self._agent_running = True
|
||||
|
|
@ -756,10 +770,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._terminal_notifier.notify(NotificationContext.COMPLETE)
|
||||
|
||||
def _rate_limit_message(self) -> str:
|
||||
upgrade_to_pro = self._plan_info and self._plan_info.plan_type in {
|
||||
WhoAmIPlanType.API,
|
||||
WhoAmIPlanType.UNAUTHORIZED,
|
||||
}
|
||||
upgrade_to_pro = self._plan_info and (
|
||||
self._plan_info.plan_type
|
||||
in {WhoAmIPlanType.API, WhoAmIPlanType.UNAUTHORIZED}
|
||||
or self._plan_info.is_free_mistral_code_plan()
|
||||
)
|
||||
if upgrade_to_pro:
|
||||
return "Rate limits exceeded. Please wait a moment before trying again, or upgrade to Pro for higher rate limits and uninterrupted access."
|
||||
return "Rate limits exceeded. Please wait a moment before trying again."
|
||||
|
|
@ -862,6 +877,16 @@ class VibeApp(App): # noqa: PLR0904
|
|||
|
||||
self._interrupt_requested = True
|
||||
|
||||
if self._pending_approval and not self._pending_approval.done():
|
||||
feedback = str(
|
||||
get_user_cancellation_message(CancellationReason.TOOL_INTERRUPTED)
|
||||
)
|
||||
self._pending_approval.set_result((ApprovalResponse.NO, feedback))
|
||||
if self._pending_question and not self._pending_question.done():
|
||||
self._pending_question.set_result(
|
||||
AskUserQuestionResult(answers=[], cancelled=True)
|
||||
)
|
||||
|
||||
if self._agent_task and not self._agent_task.done():
|
||||
self._agent_task.cancel()
|
||||
try:
|
||||
|
|
@ -1035,6 +1060,28 @@ class VibeApp(App): # noqa: PLR0904
|
|||
)
|
||||
)
|
||||
|
||||
async def _install_lean(self) -> None:
|
||||
current = list(self.agent_loop.base_config.installed_agents)
|
||||
if "lean" in current:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Lean agent is already installed.")
|
||||
)
|
||||
return
|
||||
VibeConfig.save_updates({"installed_agents": sorted([*current, "lean"])})
|
||||
await self._reload_config()
|
||||
|
||||
async def _uninstall_lean(self) -> None:
|
||||
current = list(self.agent_loop.base_config.installed_agents)
|
||||
if "lean" not in current:
|
||||
await self._mount_and_scroll(
|
||||
UserCommandMessage("Lean agent is not installed.")
|
||||
)
|
||||
return
|
||||
VibeConfig.save_updates({
|
||||
"installed_agents": [a for a in current if a != "lean"]
|
||||
})
|
||||
await self._reload_config()
|
||||
|
||||
async def _clear_history(self) -> None:
|
||||
try:
|
||||
self._reset_ui_state()
|
||||
|
|
@ -1166,6 +1213,33 @@ class VibeApp(App): # noqa: PLR0904
|
|||
ErrorMessage(result.message, collapsed=self._tools_collapsed)
|
||||
)
|
||||
|
||||
def _make_default_voice_manager(self) -> VoiceManager:
|
||||
try:
|
||||
model = self.config.get_active_transcribe_model()
|
||||
provider = self.config.get_transcribe_provider_for_model(model)
|
||||
transcribe_client = make_transcribe_client(provider, model)
|
||||
except (ValueError, KeyError) as exc:
|
||||
logger.error(
|
||||
"Failed to initialize transcription, check transcribe model configuration",
|
||||
exc_info=exc,
|
||||
)
|
||||
transcribe_client = None
|
||||
|
||||
return VoiceManager(
|
||||
lambda: self.config,
|
||||
audio_recorder=AudioRecorder(),
|
||||
transcribe_client=transcribe_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 _switch_from_input(self, widget: Widget, scroll: bool = False) -> None:
|
||||
bottom_container = self.query_one("#bottom-app-container")
|
||||
chat = self._cached_chat or self.query_one("#chat", ChatScroll)
|
||||
|
|
@ -1291,7 +1365,11 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self.agent_loop.telemetry_client.send_user_cancelled_action("interrupt_agent")
|
||||
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
|
||||
|
||||
def action_interrupt(self) -> None:
|
||||
def action_interrupt(self) -> None: # noqa: PLR0911
|
||||
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
|
||||
self._voice_manager.cancel_recording()
|
||||
return
|
||||
|
||||
current_time = time.monotonic()
|
||||
|
||||
if self._current_bottom_app == BottomApp.Config:
|
||||
|
|
@ -1424,6 +1502,7 @@ class VibeApp(App): # noqa: PLR0904
|
|||
self._chat_input_container
|
||||
and self._switch_agent_generation == my_gen
|
||||
):
|
||||
self.call_from_thread(self._refresh_banner)
|
||||
self.call_from_thread(
|
||||
setattr, self._chat_input_container, "switching_mode", False
|
||||
)
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ TextArea > .text-area--cursor {
|
|||
#completion-popup {
|
||||
width: 100%;
|
||||
padding: 1;
|
||||
padding-left: 3;
|
||||
color: ansi_default;
|
||||
}
|
||||
|
||||
|
|
@ -112,6 +113,11 @@ TextArea > .text-area--cursor {
|
|||
border: solid ansi_red;
|
||||
border-title-color: ansi_red;
|
||||
}
|
||||
|
||||
&.border-recording {
|
||||
border: solid $mistral_orange;
|
||||
border-title-color: $mistral_orange;
|
||||
}
|
||||
}
|
||||
|
||||
#input-body {
|
||||
|
|
@ -119,7 +125,8 @@ TextArea > .text-area--cursor {
|
|||
}
|
||||
|
||||
#prompt,
|
||||
#prompt-spinner {
|
||||
#prompt-spinner,
|
||||
#recording-indicator {
|
||||
width: auto;
|
||||
background: transparent;
|
||||
color: $mistral_orange;
|
||||
|
|
@ -137,6 +144,10 @@ TextArea > .text-area--cursor {
|
|||
border: none;
|
||||
padding: 0;
|
||||
scrollbar-visibility: hidden;
|
||||
|
||||
&.recording {
|
||||
color: ansi_bright_black;
|
||||
}
|
||||
}
|
||||
|
||||
ToastRack {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ class EventHandler:
|
|||
skip_reason=TaggedText.from_string(event.skip_reason).message
|
||||
if event.skip_reason
|
||||
else None,
|
||||
cancelled=event.cancelled,
|
||||
duration=event.duration,
|
||||
tool_call_id=event.tool_call_id,
|
||||
)
|
||||
|
|
|
|||
5
vibe/cli/textual_ui/recording/__init__.py
Normal file
5
vibe/cli/textual_ui/recording/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
|
||||
|
||||
__all__ = ["RecordingIndicator"]
|
||||
79
vibe/cli/textual_ui/recording/recording_indicator.py
Normal file
79
vibe/cli/textual_ui/recording/recording_indicator.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.timer import Timer
|
||||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
TranscribeState,
|
||||
VoiceManagerListener,
|
||||
VoiceManagerPort,
|
||||
)
|
||||
|
||||
PEAK_BLOCKS = "▁▂▃▄▅▆▇█"
|
||||
FILL_BLOCKS = "▏▎▍▌▋▊▉█"
|
||||
PEAK_POLL_INTERVAL = 0.05
|
||||
|
||||
|
||||
class RecordingIndicator(VoiceManagerListener, Static):
|
||||
def __init__(self, voice_manager: VoiceManagerPort) -> None:
|
||||
super().__init__(PEAK_BLOCKS[0], id="recording-indicator")
|
||||
self._voice_manager = voice_manager
|
||||
self._peak_timer: Timer | None = None
|
||||
self._flushing_animation_timer: Timer | None = None
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self._voice_manager.add_listener(self)
|
||||
if self._voice_manager.transcribe_state == TranscribeState.RECORDING:
|
||||
self._start_peak_polling()
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
self._voice_manager.remove_listener(self)
|
||||
self._stop_peak_polling()
|
||||
self._stop_flushing_animation_timer()
|
||||
|
||||
def on_transcribe_state_change(self, state: TranscribeState) -> None:
|
||||
match state:
|
||||
case TranscribeState.RECORDING:
|
||||
self._start_peak_polling()
|
||||
case TranscribeState.FLUSHING:
|
||||
self._stop_peak_polling()
|
||||
self._start_flushing_animation_timer()
|
||||
case TranscribeState.IDLE:
|
||||
self._stop_peak_polling()
|
||||
self._stop_flushing_animation_timer()
|
||||
|
||||
def _start_peak_polling(self) -> None:
|
||||
self._peak_timer = self.set_interval(
|
||||
PEAK_POLL_INTERVAL, self._poll_peak, pause=False
|
||||
)
|
||||
|
||||
def _poll_peak(self) -> None:
|
||||
if self._voice_manager.transcribe_state != TranscribeState.RECORDING:
|
||||
return
|
||||
index = min(
|
||||
int(self._voice_manager.peak * len(PEAK_BLOCKS)), len(PEAK_BLOCKS) - 1
|
||||
)
|
||||
self.update(PEAK_BLOCKS[index])
|
||||
|
||||
def _stop_peak_polling(self) -> None:
|
||||
if self._peak_timer:
|
||||
self._peak_timer.stop()
|
||||
self._peak_timer = None
|
||||
|
||||
def _start_flushing_animation_timer(self) -> None:
|
||||
self._processing_index = 0
|
||||
self.update(FILL_BLOCKS[0])
|
||||
self._flushing_animation_timer = self.set_interval(
|
||||
0.1, self._advance_flushing_animation
|
||||
)
|
||||
|
||||
def _advance_flushing_animation(self) -> None:
|
||||
if self._voice_manager.transcribe_state != TranscribeState.FLUSHING:
|
||||
return
|
||||
self._processing_index = (self._processing_index + 1) % len(FILL_BLOCKS)
|
||||
self.update(FILL_BLOCKS[self._processing_index])
|
||||
|
||||
def _stop_flushing_animation_timer(self) -> None:
|
||||
if self._flushing_animation_timer:
|
||||
self._flushing_animation_timer.stop()
|
||||
self._flushing_animation_timer = None
|
||||
|
|
@ -11,9 +11,16 @@ from textual.widget import Widget
|
|||
from textual.widgets import Static
|
||||
|
||||
from vibe.cli.history_manager import HistoryManager
|
||||
from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea, InputMode
|
||||
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||
from vibe.cli.textual_ui.widgets.spinner import SpinnerMixin, SpinnerType
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
TranscribeState,
|
||||
VoiceManagerListener,
|
||||
VoiceManagerPort,
|
||||
)
|
||||
from vibe.core.logger import logger
|
||||
|
||||
|
||||
class _PromptSpinner(SpinnerMixin, Static):
|
||||
|
|
@ -29,7 +36,7 @@ class _PromptSpinner(SpinnerMixin, Static):
|
|||
self.start_spinner_timer()
|
||||
|
||||
|
||||
class ChatInputBody(Widget):
|
||||
class ChatInputBody(VoiceManagerListener, Widget):
|
||||
class Submitted(Message):
|
||||
def __init__(self, value: str) -> None:
|
||||
self.value = value
|
||||
|
|
@ -39,6 +46,7 @@ class ChatInputBody(Widget):
|
|||
self,
|
||||
history_file: Path | None = None,
|
||||
nuage_enabled: bool = False,
|
||||
voice_manager: VoiceManagerPort | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -46,6 +54,8 @@ class ChatInputBody(Widget):
|
|||
self.prompt_widget: NoMarkupStatic | None = None
|
||||
self._nuage_enabled = nuage_enabled
|
||||
self._switching_mode = False
|
||||
self._voice_manager = voice_manager
|
||||
self._recording_indicator: RecordingIndicator | None = None
|
||||
|
||||
if history_file:
|
||||
self.history = HistoryManager(history_file)
|
||||
|
|
@ -60,13 +70,21 @@ class ChatInputBody(Widget):
|
|||
yield self.prompt_widget
|
||||
|
||||
self.input_widget = ChatTextArea(
|
||||
id="input", nuage_enabled=self._nuage_enabled
|
||||
id="input",
|
||||
nuage_enabled=self._nuage_enabled,
|
||||
voice_manager=self._voice_manager,
|
||||
)
|
||||
yield self.input_widget
|
||||
|
||||
def on_mount(self) -> None:
|
||||
if self.input_widget:
|
||||
self.input_widget.focus()
|
||||
if self._voice_manager:
|
||||
self._voice_manager.add_listener(self)
|
||||
|
||||
def on_unmount(self) -> None:
|
||||
if self._voice_manager:
|
||||
self._voice_manager.remove_listener(self)
|
||||
|
||||
def _parse_mode_and_text(self, text: str) -> tuple[InputMode, str]:
|
||||
if text.startswith("!"):
|
||||
|
|
@ -103,7 +121,6 @@ class ChatInputBody(Widget):
|
|||
cursor_pos = (0, col)
|
||||
|
||||
self.input_widget.move_cursor(cursor_pos)
|
||||
self.input_widget._last_cursor_col = col
|
||||
self.input_widget._cursor_pos_after_load = cursor_pos
|
||||
self.input_widget._cursor_moved_since_load = False
|
||||
|
||||
|
|
@ -111,7 +128,7 @@ class ChatInputBody(Widget):
|
|||
self._notify_completion_reset()
|
||||
|
||||
def on_chat_text_area_history_previous(
|
||||
self, event: ChatTextArea.HistoryPrevious
|
||||
self, _event: ChatTextArea.HistoryPrevious
|
||||
) -> None:
|
||||
if not self.history or not self.input_widget:
|
||||
return
|
||||
|
|
@ -119,52 +136,25 @@ class ChatInputBody(Widget):
|
|||
if self.history._current_index == -1:
|
||||
self.input_widget._original_text = self.input_widget.text
|
||||
|
||||
if (
|
||||
self.history._current_index != -1
|
||||
and self.input_widget._last_used_prefix is not None
|
||||
and self.input_widget._last_used_prefix != event.prefix
|
||||
):
|
||||
self.history.reset_navigation()
|
||||
|
||||
self.input_widget._last_used_prefix = event.prefix
|
||||
previous = self.history.get_previous(
|
||||
self.input_widget._original_text, prefix=event.prefix
|
||||
)
|
||||
previous = self.history.get_previous(self.input_widget._original_text)
|
||||
|
||||
if previous is not None:
|
||||
self._load_history_entry(previous)
|
||||
|
||||
def on_chat_text_area_history_next(self, event: ChatTextArea.HistoryNext) -> None:
|
||||
def on_chat_text_area_history_next(self, _event: ChatTextArea.HistoryNext) -> None:
|
||||
if not self.history or not self.input_widget:
|
||||
return
|
||||
|
||||
if self.history._current_index == -1:
|
||||
return
|
||||
|
||||
if (
|
||||
self.input_widget._last_used_prefix is not None
|
||||
and self.input_widget._last_used_prefix != event.prefix
|
||||
):
|
||||
self.history.reset_navigation()
|
||||
next_entry = self.history.get_next()
|
||||
if next_entry is not None:
|
||||
self._load_history_entry(next_entry)
|
||||
|
||||
self.input_widget._last_used_prefix = event.prefix
|
||||
|
||||
has_next = any(
|
||||
self.history._entries[i].startswith(event.prefix)
|
||||
for i in range(self.history._current_index + 1, len(self.history._entries))
|
||||
)
|
||||
|
||||
original_matches = self.input_widget._original_text.startswith(event.prefix)
|
||||
|
||||
if has_next or original_matches:
|
||||
next_entry = self.history.get_next(prefix=event.prefix)
|
||||
if next_entry is not None:
|
||||
cursor_col = (
|
||||
len(event.prefix) if self.history._current_index == -1 else None
|
||||
)
|
||||
self._load_history_entry(next_entry, cursor_col=cursor_col)
|
||||
|
||||
def on_chat_text_area_history_reset(self, event: ChatTextArea.HistoryReset) -> None:
|
||||
def on_chat_text_area_history_reset(
|
||||
self, _event: ChatTextArea.HistoryReset
|
||||
) -> None:
|
||||
if self.history:
|
||||
self.history.reset_navigation()
|
||||
if self.input_widget:
|
||||
|
|
@ -250,3 +240,68 @@ class ChatInputBody(Widget):
|
|||
|
||||
if cursor_offset is not None:
|
||||
self.input_widget.set_cursor_offset(max(0, min(cursor_offset, len(text))))
|
||||
|
||||
def on_transcribe_state_change(self, state: TranscribeState) -> None:
|
||||
if state == TranscribeState.RECORDING:
|
||||
self._start_recording_ui()
|
||||
elif state == TranscribeState.IDLE:
|
||||
self._stop_recording_ui()
|
||||
|
||||
def on_transcribe_text(self, text: str) -> None:
|
||||
if not self.input_widget:
|
||||
return
|
||||
self.input_widget.insert(text)
|
||||
|
||||
def _start_recording_ui(self) -> None:
|
||||
if not self._voice_manager:
|
||||
return
|
||||
|
||||
try:
|
||||
self.screen.get_widget_by_id("input-box").add_class("border-recording")
|
||||
|
||||
if self.input_widget:
|
||||
self.input_widget.cursor_blink = False
|
||||
self.input_widget.add_class("recording")
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = False
|
||||
self._recording_indicator = RecordingIndicator(self._voice_manager)
|
||||
self.query_one(Horizontal).mount(self._recording_indicator, before=0)
|
||||
except Exception as e:
|
||||
logger.error("Failed to start recording UI", exc_info=e)
|
||||
self._reset_recording_ui()
|
||||
|
||||
def _stop_recording_ui(self) -> None:
|
||||
try:
|
||||
self.screen.get_widget_by_id("input-box").remove_class("border-recording")
|
||||
|
||||
if self.input_widget:
|
||||
self.input_widget.cursor_blink = True
|
||||
self.input_widget.remove_class("recording")
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = True
|
||||
self._update_prompt()
|
||||
if self._recording_indicator:
|
||||
self._recording_indicator.remove()
|
||||
self._recording_indicator = None
|
||||
except Exception as e:
|
||||
logger.error("Failed to stop recording UI", exc_info=e)
|
||||
self._reset_recording_ui()
|
||||
|
||||
def _reset_recording_ui(self) -> None:
|
||||
try:
|
||||
self.screen.get_widget_by_id("input-box").remove_class("border-recording")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.input_widget:
|
||||
self.input_widget.cursor_blink = True
|
||||
self.input_widget.remove_class("recording")
|
||||
if self.prompt_widget:
|
||||
self.prompt_widget.display = True
|
||||
self._update_prompt()
|
||||
if self._recording_indicator:
|
||||
try:
|
||||
self._recording_indicator.remove()
|
||||
except Exception:
|
||||
pass
|
||||
self._recording_indicator = None
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class CompletionPopup(Static):
|
|||
label_style = "bold reverse" if idx == selected else "bold"
|
||||
description_style = "italic" if idx == selected else "dim"
|
||||
|
||||
text.append(label, style=label_style)
|
||||
text.append(self._display_label(label), style=label_style)
|
||||
if description:
|
||||
text.append(" ")
|
||||
text.append(description, style=description_style)
|
||||
|
|
@ -41,3 +41,8 @@ class CompletionPopup(Static):
|
|||
|
||||
def show(self) -> None:
|
||||
self.styles.display = "block"
|
||||
|
||||
def _display_label(self, label: str) -> str:
|
||||
if label.startswith("@"):
|
||||
return label[1:]
|
||||
return label
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
|
|||
)
|
||||
from vibe.cli.textual_ui.widgets.chat_input.completion_popup import CompletionPopup
|
||||
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
|
||||
from vibe.cli.voice_manager.voice_manager_port import VoiceManagerPort
|
||||
from vibe.core.agents import AgentSafety
|
||||
from vibe.core.autocompletion.completers import CommandCompleter, PathCompleter
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ class ChatInputContainer(Vertical):
|
|||
skill_entries_getter: Callable[[], list[tuple[str, str]]] | None = None,
|
||||
file_watcher_for_autocomplete_getter: Callable[[], bool] | None = None,
|
||||
nuage_enabled: bool = False,
|
||||
voice_manager: VoiceManagerPort | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
|
@ -56,6 +58,7 @@ class ChatInputContainer(Vertical):
|
|||
file_watcher_for_autocomplete_getter
|
||||
)
|
||||
self._nuage_enabled = nuage_enabled
|
||||
self._voice_manager = voice_manager
|
||||
|
||||
self._completion_manager = MultiCompletionManager([
|
||||
SlashCommandController(CommandCompleter(self._get_slash_entries), self),
|
||||
|
|
@ -90,6 +93,7 @@ class ChatInputContainer(Vertical):
|
|||
history_file=self._history_file,
|
||||
id="input-body",
|
||||
nuage_enabled=self._nuage_enabled,
|
||||
voice_manager=self._voice_manager,
|
||||
)
|
||||
|
||||
yield self._body
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ from vibe.cli.textual_ui.widgets.chat_input.completion_manager import (
|
|||
MultiCompletionManager,
|
||||
)
|
||||
from vibe.cli.textual_ui.widgets.vscode_compat import patch_vscode_space
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
RecordingStartError,
|
||||
TranscribeState,
|
||||
VoiceManagerPort,
|
||||
)
|
||||
|
||||
InputMode = Literal["!", "/", ">", "&"]
|
||||
|
||||
|
|
@ -37,14 +42,10 @@ class ChatTextArea(TextArea):
|
|||
super().__init__()
|
||||
|
||||
class HistoryPrevious(Message):
|
||||
def __init__(self, prefix: str) -> None:
|
||||
self.prefix = prefix
|
||||
super().__init__()
|
||||
pass
|
||||
|
||||
class HistoryNext(Message):
|
||||
def __init__(self, prefix: str) -> None:
|
||||
self.prefix = prefix
|
||||
super().__init__()
|
||||
pass
|
||||
|
||||
class HistoryReset(Message):
|
||||
"""Message sent when history navigation should be reset."""
|
||||
|
|
@ -56,20 +57,23 @@ class ChatTextArea(TextArea):
|
|||
self.mode = mode
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, nuage_enabled: bool = False, **kwargs: Any) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
nuage_enabled: bool = False,
|
||||
voice_manager: VoiceManagerPort | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._nuage_enabled = nuage_enabled
|
||||
self._input_mode: InputMode = self.DEFAULT_MODE
|
||||
self._history_prefix: str | None = None
|
||||
self._last_text = ""
|
||||
self._navigating_history = False
|
||||
self._last_cursor_col: int = 0
|
||||
self._last_used_prefix: str | None = None
|
||||
self._original_text: str = ""
|
||||
self._cursor_pos_after_load: tuple[int, int] | None = None
|
||||
self._cursor_moved_since_load: bool = False
|
||||
self._completion_manager: MultiCompletionManager | None = None
|
||||
self._app_has_focus: bool = True
|
||||
self._voice_manager = voice_manager
|
||||
|
||||
def on_blur(self, event: events.Blur) -> None:
|
||||
if self._app_has_focus:
|
||||
|
|
@ -100,7 +104,6 @@ class ChatTextArea(TextArea):
|
|||
|
||||
def on_text_area_changed(self, event: TextArea.Changed) -> None:
|
||||
if not self._navigating_history and self.text != self._last_text:
|
||||
self._reset_prefix()
|
||||
self._original_text = ""
|
||||
self._cursor_pos_after_load = None
|
||||
self._cursor_moved_since_load = False
|
||||
|
|
@ -114,11 +117,6 @@ class ChatTextArea(TextArea):
|
|||
self.get_full_text(), self._get_full_cursor_offset()
|
||||
)
|
||||
|
||||
def _reset_prefix(self, *, clear_last_used: bool = True) -> None:
|
||||
self._history_prefix = None
|
||||
if clear_last_used:
|
||||
self._last_used_prefix = None
|
||||
|
||||
def _mark_cursor_moved_if_needed(self) -> None:
|
||||
if (
|
||||
self._cursor_pos_after_load is not None
|
||||
|
|
@ -126,20 +124,8 @@ class ChatTextArea(TextArea):
|
|||
and self.cursor_location != self._cursor_pos_after_load
|
||||
):
|
||||
self._cursor_moved_since_load = True
|
||||
self._reset_prefix(clear_last_used=False)
|
||||
|
||||
def _get_prefix_up_to_cursor(self) -> str:
|
||||
cursor_row, cursor_col = self.cursor_location
|
||||
lines = self.text.split("\n")
|
||||
if cursor_row < len(lines):
|
||||
visible_prefix = lines[cursor_row][:cursor_col]
|
||||
if cursor_row == 0 and self._input_mode != self.DEFAULT_MODE:
|
||||
return self._input_mode + visible_prefix
|
||||
return visible_prefix
|
||||
return ""
|
||||
|
||||
def _handle_history_up(self) -> bool:
|
||||
_, cursor_col = self.cursor_location
|
||||
history_loaded_and_cursor_unmoved = (
|
||||
self._cursor_pos_after_load is not None
|
||||
and not self._cursor_moved_since_load
|
||||
|
|
@ -150,63 +136,61 @@ class ChatTextArea(TextArea):
|
|||
)
|
||||
|
||||
if should_intercept:
|
||||
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
|
||||
self._reset_prefix()
|
||||
self._last_cursor_col = 0
|
||||
|
||||
if self._history_prefix is None:
|
||||
self._history_prefix = self._get_prefix_up_to_cursor()
|
||||
|
||||
self._navigating_history = True
|
||||
self.post_message(self.HistoryPrevious(self._history_prefix))
|
||||
self.post_message(self.HistoryPrevious())
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_on_loaded_history_entry(self) -> bool:
|
||||
return self._cursor_pos_after_load is not None
|
||||
|
||||
def _has_history_prefix(self) -> bool:
|
||||
return self._history_prefix is not None
|
||||
|
||||
def _has_history_navigation_context(self) -> bool:
|
||||
return self._is_on_loaded_history_entry() or self._has_history_prefix()
|
||||
|
||||
def _should_intercept_history_down(self) -> bool:
|
||||
history_loaded_and_cursor_unmoved = (
|
||||
self._is_on_loaded_history_entry() and not self._cursor_moved_since_load
|
||||
)
|
||||
if history_loaded_and_cursor_unmoved and self._has_history_prefix():
|
||||
if self._is_on_loaded_history_entry() and not self._cursor_moved_since_load:
|
||||
return True
|
||||
|
||||
if not self.navigator.is_last_wrapped_line(self.cursor_location):
|
||||
return False
|
||||
|
||||
return self._has_history_navigation_context()
|
||||
return self._is_on_loaded_history_entry()
|
||||
|
||||
def _handle_history_down(self) -> bool:
|
||||
_, cursor_col = self.cursor_location
|
||||
|
||||
if not self._should_intercept_history_down():
|
||||
return False
|
||||
|
||||
navigating_loaded_history = self._is_on_loaded_history_entry()
|
||||
|
||||
if self._history_prefix is not None and cursor_col != self._last_cursor_col:
|
||||
if not navigating_loaded_history:
|
||||
self._reset_prefix()
|
||||
self._last_cursor_col = 0
|
||||
|
||||
if self._history_prefix is None:
|
||||
if navigating_loaded_history and self._last_used_prefix is not None:
|
||||
self._history_prefix = self._last_used_prefix
|
||||
else:
|
||||
self._history_prefix = self._get_prefix_up_to_cursor()
|
||||
|
||||
self._navigating_history = True
|
||||
self.post_message(self.HistoryNext(self._history_prefix))
|
||||
self.post_message(self.HistoryNext())
|
||||
return True
|
||||
|
||||
async def _handle_voice_key(self, event: events.Key) -> bool:
|
||||
if not self._voice_manager:
|
||||
return False
|
||||
|
||||
# Handle key pressed during audio recording
|
||||
if self._voice_manager.transcribe_state != TranscribeState.IDLE:
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
if event.key == "ctrl+c": # Escape is handled in app.py
|
||||
self._voice_manager.cancel_recording()
|
||||
elif self._voice_manager.transcribe_state == TranscribeState.RECORDING:
|
||||
await self._voice_manager.stop_recording()
|
||||
return True
|
||||
|
||||
# Handle audio record keybind
|
||||
if self._voice_manager.is_enabled and event.key == "ctrl+r":
|
||||
event.prevent_default()
|
||||
event.stop()
|
||||
try:
|
||||
self._voice_manager.start_recording()
|
||||
except RecordingStartError as e:
|
||||
self.notify(str(e), severity="warning")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _on_key(self, event: events.Key) -> None: # noqa: PLR0911
|
||||
if await self._handle_voice_key(event):
|
||||
return
|
||||
|
||||
self._mark_cursor_moved_if_needed()
|
||||
|
||||
manager = self._completion_manager
|
||||
|
|
@ -223,7 +207,6 @@ class ChatTextArea(TextArea):
|
|||
event.stop()
|
||||
value = self.get_full_text().strip()
|
||||
if value:
|
||||
self._reset_prefix()
|
||||
self.post_message(self.Submitted(value))
|
||||
return
|
||||
|
||||
|
|
@ -232,7 +215,6 @@ class ChatTextArea(TextArea):
|
|||
event.stop()
|
||||
value = self.get_full_text().strip()
|
||||
if value:
|
||||
self._reset_prefix()
|
||||
self.post_message(self.Submitted(value))
|
||||
return
|
||||
|
||||
|
|
@ -323,7 +305,6 @@ class ChatTextArea(TextArea):
|
|||
self.move_cursor((last_row, len(lines[last_row])))
|
||||
|
||||
def reset_history_state(self) -> None:
|
||||
self._reset_prefix()
|
||||
self._original_text = ""
|
||||
self._cursor_pos_after_load = None
|
||||
self._cursor_moved_since_load = False
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vibe.cli.textual_ui.app import ChatScroll
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
|
|
@ -67,6 +70,7 @@ class StreamingMessageBase(Static):
|
|||
self._markdown: Markdown | None = None
|
||||
self._stream: MarkdownStream | None = None
|
||||
self._content_initialized = False
|
||||
self._to_write_buffer = ""
|
||||
|
||||
def _get_markdown(self) -> Markdown:
|
||||
if self._markdown is None:
|
||||
|
|
@ -80,23 +84,46 @@ class StreamingMessageBase(Static):
|
|||
self._stream = Markdown.get_stream(self._get_markdown())
|
||||
return self._stream
|
||||
|
||||
def _is_chat_at_bottom(self) -> bool:
|
||||
try:
|
||||
chat = cast("ChatScroll", self.app.query_one("#chat"))
|
||||
return chat.is_at_bottom
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
async def append_content(self, content: str) -> None:
|
||||
if not content:
|
||||
return
|
||||
|
||||
self._content += content
|
||||
if self._should_write_content():
|
||||
|
||||
if not self._should_write_content():
|
||||
return
|
||||
|
||||
if self._is_chat_at_bottom():
|
||||
to_write = self._to_write_buffer + content
|
||||
self._to_write_buffer = ""
|
||||
stream = self._ensure_stream()
|
||||
await stream.write(content)
|
||||
await stream.write(to_write)
|
||||
return
|
||||
|
||||
self._to_write_buffer += content
|
||||
|
||||
async def write_initial_content(self) -> None:
|
||||
if self._content_initialized:
|
||||
return
|
||||
self._content_initialized = True
|
||||
if self._content and self._should_write_content():
|
||||
stream = self._ensure_stream()
|
||||
await stream.write(self._content)
|
||||
self._to_write_buffer = ""
|
||||
|
||||
async def stop_stream(self) -> None:
|
||||
if self._to_write_buffer and self._should_write_content():
|
||||
stream = self._ensure_stream()
|
||||
await stream.write(self._to_write_buffer)
|
||||
self._to_write_buffer = ""
|
||||
|
||||
if self._stream is None:
|
||||
return
|
||||
|
||||
|
|
@ -187,6 +214,7 @@ class ReasoningMessage(SpinnerMixin, StreamingMessageBase):
|
|||
await self._markdown.update("")
|
||||
stream = self._ensure_stream()
|
||||
await stream.write(self._content)
|
||||
self._to_write_buffer = ""
|
||||
|
||||
|
||||
class UserCommandMessage(Static):
|
||||
|
|
|
|||
15
vibe/cli/voice_manager/__init__.py
Normal file
15
vibe/cli/voice_manager/__init__.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from vibe.cli.voice_manager.voice_manager import VoiceManager
|
||||
from vibe.cli.voice_manager.voice_manager_port import (
|
||||
RecordingStartError,
|
||||
VoiceManagerPort,
|
||||
VoiceToggleResult,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"RecordingStartError",
|
||||
"VoiceManager",
|
||||
"VoiceManagerPort",
|
||||
"VoiceToggleResult",
|
||||
]
|
||||
186
vibe/cli/voice_manager/voice_manager.py
Normal file
186
vibe/cli/voice_manager/voice_manager.py
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from asyncio import CancelledError, Task, create_task, wait_for
|
||||
from collections.abc import Callable
|
||||
|
||||
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,
|
||||
NoAudioInputDeviceError,
|
||||
RecordingMode,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
TRANSCRIPTION_DRAIN_TIMEOUT = 10.0
|
||||
|
||||
|
||||
class VoiceManager:
|
||||
def __init__(
|
||||
self,
|
||||
config_getter: Callable[[], VibeConfig],
|
||||
audio_recorder: AudioRecorderPort,
|
||||
transcribe_client: TranscribeClientPort | None,
|
||||
) -> None:
|
||||
self._config_getter = config_getter
|
||||
self._audio_recorder = audio_recorder
|
||||
self._transcribe_client = transcribe_client
|
||||
self._transcribe_state = TranscribeState.IDLE
|
||||
self._transcribe_task: Task[None] | None = None
|
||||
self._listeners: list[VoiceManagerListener] = []
|
||||
|
||||
@property
|
||||
def is_enabled(self) -> bool:
|
||||
return self._config_getter().voice_mode_enabled
|
||||
|
||||
@property
|
||||
def transcribe_state(self) -> TranscribeState:
|
||||
return self._transcribe_state
|
||||
|
||||
@property
|
||||
def peak(self) -> float:
|
||||
return self._audio_recorder.peak
|
||||
|
||||
def toggle_voice_mode(self) -> VoiceToggleResult:
|
||||
new_state = not self.is_enabled
|
||||
if not new_state:
|
||||
self.cancel_recording()
|
||||
|
||||
VibeConfig.save_updates({"voice_mode_enabled": new_state})
|
||||
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener.on_voice_mode_change(new_state)
|
||||
except Exception:
|
||||
logger.error("Listener raised during voice mode change", exc_info=True)
|
||||
|
||||
return VoiceToggleResult(enabled=new_state)
|
||||
|
||||
def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None:
|
||||
if self._transcribe_state != TranscribeState.IDLE:
|
||||
return
|
||||
|
||||
if self._transcribe_client is None:
|
||||
logger.warning(
|
||||
"Failed to start recording as the transcribe client is missing"
|
||||
)
|
||||
raise RecordingStartError("Transcribe client is not available")
|
||||
|
||||
model = self._config_getter().get_active_transcribe_model()
|
||||
|
||||
try:
|
||||
self._audio_recorder.start(mode, sample_rate=model.sample_rate)
|
||||
except AlreadyRecordingError:
|
||||
raise RecordingStartError("Recording is already in progress")
|
||||
except AudioBackendUnavailableError:
|
||||
raise RecordingStartError("Audio backend is unavailable")
|
||||
except NoAudioInputDeviceError:
|
||||
raise RecordingStartError("No audio input device found")
|
||||
|
||||
self._set_state(TranscribeState.RECORDING)
|
||||
self._transcribe_task = create_task(self._run_transcription())
|
||||
|
||||
async def stop_recording(self) -> None:
|
||||
if self._transcribe_state != TranscribeState.RECORDING:
|
||||
return
|
||||
should_flush_queue = self._audio_recorder.mode == RecordingMode.STREAM
|
||||
|
||||
if should_flush_queue:
|
||||
self._set_state(TranscribeState.FLUSHING)
|
||||
self._audio_recorder.stop(wait_for_queue_drained=should_flush_queue)
|
||||
|
||||
if self._transcribe_task is not None:
|
||||
try:
|
||||
await wait_for(
|
||||
self._transcribe_task, timeout=TRANSCRIPTION_DRAIN_TIMEOUT
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning("Transcription task timed out, cancelling")
|
||||
self._transcribe_task.cancel()
|
||||
except CancelledError:
|
||||
pass
|
||||
self._transcribe_task = None
|
||||
|
||||
if self._transcribe_state != TranscribeState.IDLE:
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
|
||||
def cancel_recording(self) -> None:
|
||||
if self._transcribe_state == TranscribeState.IDLE:
|
||||
return
|
||||
|
||||
self._audio_recorder.cancel()
|
||||
|
||||
if self._transcribe_task is not None:
|
||||
self._transcribe_task.cancel()
|
||||
self._transcribe_task = None
|
||||
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
|
||||
def add_listener(self, listener: VoiceManagerListener) -> None:
|
||||
if listener not in self._listeners:
|
||||
self._listeners.append(listener)
|
||||
|
||||
def remove_listener(self, listener: VoiceManagerListener) -> None:
|
||||
try:
|
||||
self._listeners.remove(listener)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def _run_transcription(self) -> None:
|
||||
if self._transcribe_client is None:
|
||||
return
|
||||
|
||||
try:
|
||||
audio_stream = self._audio_recorder.audio_stream()
|
||||
|
||||
async for event in self._transcribe_client.transcribe(audio_stream):
|
||||
match event:
|
||||
case TranscribeTextDelta(text=text):
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener.on_transcribe_text(text)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Listener raised during transcribe text",
|
||||
exc_info=True,
|
||||
)
|
||||
case TranscribeDone():
|
||||
pass
|
||||
case TranscribeError(message=msg):
|
||||
raise RuntimeError(msg)
|
||||
case TranscribeSessionCreated():
|
||||
pass
|
||||
if self._transcribe_state != TranscribeState.IDLE:
|
||||
self._set_state(TranscribeState.IDLE)
|
||||
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)
|
||||
|
||||
def _set_state(self, state: TranscribeState) -> None:
|
||||
if self._transcribe_state == state:
|
||||
return
|
||||
|
||||
self._transcribe_state = state
|
||||
for listener in self._listeners:
|
||||
try:
|
||||
listener.on_transcribe_state_change(state)
|
||||
except Exception:
|
||||
logger.error("Listener raised during state change", exc_info=True)
|
||||
56
vibe/cli/voice_manager/voice_manager_port.py
Normal file
56
vibe/cli/voice_manager/voice_manager_port.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
from typing import Protocol
|
||||
|
||||
from vibe.core.audio_recorder.audio_recorder_port import RecordingMode
|
||||
|
||||
|
||||
class TranscribeState(StrEnum):
|
||||
IDLE = auto()
|
||||
RECORDING = auto()
|
||||
FLUSHING = auto()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VoiceToggleResult:
|
||||
enabled: bool
|
||||
|
||||
|
||||
class RecordingStartError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class VoiceManagerListener:
|
||||
def on_transcribe_state_change(self, state: TranscribeState) -> None:
|
||||
pass
|
||||
|
||||
def on_voice_mode_change(self, enabled: bool) -> None:
|
||||
pass
|
||||
|
||||
def on_transcribe_text(self, text: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class VoiceManagerPort(Protocol):
|
||||
@property
|
||||
def is_enabled(self) -> bool: ...
|
||||
|
||||
@property
|
||||
def transcribe_state(self) -> TranscribeState: ...
|
||||
|
||||
@property
|
||||
def peak(self) -> float: ...
|
||||
|
||||
def toggle_voice_mode(self) -> VoiceToggleResult: ...
|
||||
|
||||
def start_recording(self, mode: RecordingMode = RecordingMode.STREAM) -> None: ...
|
||||
|
||||
async def stop_recording(self) -> None: ...
|
||||
|
||||
def cancel_recording(self) -> None: ...
|
||||
|
||||
def add_listener(self, listener: VoiceManagerListener) -> None: ...
|
||||
|
||||
def remove_listener(self, listener: VoiceManagerListener) -> None: ...
|
||||
Loading…
Add table
Add a link
Reference in a new issue