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:
Mathias Gesbert 2026-03-16 17:51:47 +01:00 committed by GitHub
parent 9421fbc08e
commit 5103019b01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
104 changed files with 7277 additions and 691 deletions

View file

@ -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:

View file

@ -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()

View file

@ -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

View file

@ -11,6 +11,7 @@ from pydantic import TypeAdapter, ValidationError
class WhoAmIPlanType(StrEnum):
API = "API"
CHAT = "CHAT"
MISTRAL_CODE = "MISTRAL_CODE"
UNKNOWN = "UNKNOWN"
UNAUTHORIZED = "UNAUTHORIZED"

View file

@ -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
)

View file

@ -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 {

View file

@ -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,
)

View file

@ -0,0 +1,5 @@
from __future__ import annotations
from vibe.cli.textual_ui.recording.recording_indicator import RecordingIndicator
__all__ = ["RecordingIndicator"]

View 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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):

View 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",
]

View 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)

View 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: ...