Co-authored-by: Bastien <bastien.baret@gmail.com>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Julien Legrand <72564015+JulienLGRD@users.noreply.github.com>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Drouin 2026-04-14 10:33:15 +02:00 committed by GitHub
parent e9a9217cc8
commit e1a25caa52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 2830 additions and 594 deletions

View file

@ -207,11 +207,14 @@ def run_cli(args: argparse.Namespace) -> None:
client_name="vibe_cli",
client_version=__version__,
),
defer_heavy_init=True,
)
if loaded_session:
_resume_previous_session(agent_loop, *loaded_session)
agent_loop.start_deferred_init()
run_textual_ui(
agent_loop=agent_loop,
startup=StartupOptions(

View file

@ -10,7 +10,7 @@ from rich import print as rprint
from vibe import __version__
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config.harness_files import init_harness_files_manager
from vibe.core.trusted_folders import has_trustable_content, trusted_folders_manager
from vibe.core.trusted_folders import find_trustable_files, trusted_folders_manager
from vibe.setup.trusted_folders.trust_folder_dialog import (
TrustDialogQuitException,
ask_trust_folder,
@ -118,7 +118,12 @@ def check_and_resolve_trusted_folder() -> None:
)
sys.exit(1)
if not has_trustable_content(cwd) or cwd.resolve() == Path.home().resolve():
if cwd.resolve() == Path.home().resolve():
return
detected_files = find_trustable_files(cwd)
if not detected_files:
return
is_folder_trusted = trusted_folders_manager.is_trusted(cwd)
@ -127,7 +132,7 @@ def check_and_resolve_trusted_folder() -> None:
return
try:
is_folder_trusted = ask_trust_folder(cwd)
is_folder_trusted = ask_trust_folder(cwd, detected_files)
except (KeyboardInterrupt, EOFError, TrustDialogQuitException):
sys.exit(0)
except Exception as e:

View file

@ -3,6 +3,8 @@ from __future__ import annotations
import json
from pathlib import Path
from vibe.core.utils.io import read_safe
class HistoryManager:
def __init__(self, history_file: Path, max_entries: int = 100) -> None:
@ -18,20 +20,22 @@ class HistoryManager:
return
try:
with self.history_file.open("r", encoding="utf-8") as f:
entries = []
for raw_line in f:
raw_line = raw_line.rstrip("\n\r")
if not raw_line:
continue
try:
entry = json.loads(raw_line)
except json.JSONDecodeError:
entry = raw_line
entries.append(entry if isinstance(entry, str) else str(entry))
self._entries = entries[-self.max_entries :]
except (OSError, UnicodeDecodeError):
text = read_safe(self.history_file).text
except OSError:
self._entries = []
return
entries = []
for raw_line in text.splitlines():
raw_line = raw_line.rstrip("\n\r")
if not raw_line:
continue
try:
entry = json.loads(raw_line)
except json.JSONDecodeError:
entry = raw_line
entries.append(entry if isinstance(entry, str) else str(entry))
self._entries = entries[-self.max_entries :]
def _save_history(self) -> None:
try:

View file

@ -7,6 +7,7 @@ from vibe.cli.narrator_manager.narrator_manager_port import (
NarratorManagerListener,
NarratorState,
)
from vibe.cli.narrator_manager.telemetry import ReadAloudTrackingState
from vibe.cli.turn_summary import (
NoopTurnSummary,
TurnSummaryResult,
@ -24,16 +25,21 @@ if TYPE_CHECKING:
from vibe.cli.turn_summary import TurnSummaryPort
from vibe.core.audio_player.audio_player_port import AudioPlayerPort
from vibe.core.config import VibeConfig
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.tts.tts_client_port import TTSClientPort
from vibe.core.types import BaseEvent
class NarratorManager:
def __init__(
self, config_getter: Callable[[], VibeConfig], audio_player: AudioPlayerPort
self,
config_getter: Callable[[], VibeConfig],
audio_player: AudioPlayerPort,
telemetry_client: TelemetryClient | None = None,
) -> None:
self._config_getter = config_getter
self._audio_player = audio_player
self._telemetry_client = telemetry_client
config = config_getter()
self._turn_summary: TurnSummaryPort = self._make_turn_summary(config)
self._turn_summary.on_summary = self._on_turn_summary
@ -43,6 +49,7 @@ class NarratorManager:
self._cancel_summary: Callable[[], bool] | None = None
self._close_tasks: set[asyncio.Task[Any]] = set()
self._listeners: list[NarratorManagerListener] = []
self._tracking = ReadAloudTrackingState()
@property
def state(self) -> NarratorState:
@ -99,8 +106,12 @@ class NarratorManager:
):
self._cancel_summary = cancel_summary
self._set_state(NarratorState.SUMMARIZING)
self._tracking.reset()
self._on_read_aloud_requested()
def cancel(self) -> None:
if self._state != NarratorState.IDLE:
self._on_read_aloud_ended("canceled")
if self._cancel_summary is not None:
self._cancel_summary()
self._cancel_summary = None
@ -178,17 +189,65 @@ class NarratorManager:
loop = asyncio.get_running_loop()
tts_result = await self._tts_client.speak(text)
self._set_state(NarratorState.SPEAKING)
self._tracking.mark_play_started()
self._on_read_aloud_play_started()
self._audio_player.play(
tts_result.audio_data,
AudioFormat.WAV,
on_finished=lambda: loop.call_soon_threadsafe(
self._set_state, NarratorState.IDLE
self._on_playback_finished
),
)
except Exception:
except Exception as exc:
logger.warning("TTS speak failed", exc_info=True)
self._on_read_aloud_ended("error", error_type=type(exc).__name__)
self._set_state(NarratorState.IDLE)
def _on_playback_finished(self) -> None:
if self._state != NarratorState.SPEAKING:
return
self._on_read_aloud_ended("completed")
self._set_state(NarratorState.IDLE)
def _on_read_aloud_requested(self) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.read_aloud.requested",
{
"read_aloud_session_id": self._tracking.session_id,
"trigger": "autoplay_next_message",
},
)
def _on_read_aloud_play_started(self) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.read_aloud.play_started",
{
"read_aloud_session_id": self._tracking.session_id,
"time_to_first_read_s": self._tracking.time_to_first_read_s(),
"speed_selection": None,
},
)
def _on_read_aloud_ended(
self, status: str, *, error_type: str | None = None
) -> None:
if not self._telemetry_client:
return
self._telemetry_client.send_telemetry_event(
"vibe.read_aloud.ended",
{
"read_aloud_session_id": self._tracking.session_id,
"status": status,
"error_type": error_type,
"speed_selection": None,
"elapsed_seconds": self._tracking.elapsed_since_play_s(),
},
)
def _set_state(self, state: NarratorState) -> None:
self._state = state
for listener in list(self._listeners):

View file

@ -0,0 +1,30 @@
from __future__ import annotations
from dataclasses import dataclass
import time
import uuid
@dataclass
class ReadAloudTrackingState:
session_id: str = ""
request_time: float = 0.0
play_start_time: float = 0.0
def reset(self) -> None:
self.session_id = str(uuid.uuid4())
self.request_time = time.monotonic()
self.play_start_time = 0.0
def mark_play_started(self) -> None:
self.play_start_time = time.monotonic()
def time_to_first_read_s(self) -> float:
if self.play_start_time == 0.0 or self.request_time == 0.0:
return 0.0
return self.play_start_time - self.request_time
def elapsed_since_play_s(self) -> float:
if self.play_start_time == 0.0:
return 0.0
return time.monotonic() - self.play_start_time

View file

@ -71,10 +71,18 @@ def stop_and_print() -> None:
output_path = Path(f"{_state.label}-profile.html")
output_path.write_text(_state.profiler.output_html(), encoding="utf-8")
text_path = Path(f"{_state.label}-profile.txt")
text_path.write_text(_state.profiler.output_text(color=False), encoding="utf-8")
print(
f"\n[profiler:{_state.label}] Saved HTML profile to {output_path.resolve()}",
file=sys.stderr,
)
print(
f"[profiler:{_state.label}] Saved text profile to {text_path.resolve()}",
file=sys.stderr,
)
print(_state.profiler.output_text(color=True), file=sys.stderr)
_state.profiler = None

View file

@ -191,7 +191,7 @@ def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
if keybindings_path.exists():
content = read_safe(keybindings_path)
content = read_safe(keybindings_path).text
return _parse_keybindings(content)
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
return []

View file

@ -370,6 +370,7 @@ class VibeApp(App): # noqa: PLR0904
self._rewind_mode = False
self._rewind_highlighted_widget: UserMessage | None = None
self._fatal_init_error = False
@property
def config(self) -> VibeConfig:
@ -449,6 +450,8 @@ class VibeApp(App): # noqa: PLR0904
self.call_after_refresh(self._refresh_banner)
self.run_worker(self._watch_init_completion(), exclusive=False)
if self._show_resume_picker:
self.run_worker(self._show_session_picker(), exclusive=False)
elif self._initial_prompt or self._teleport_on_start:
@ -457,6 +460,37 @@ class VibeApp(App): # noqa: PLR0904
gc.collect()
gc.freeze()
async def _watch_init_completion(self) -> None:
"""Show 'Initializing' loading indicator until background init finishes."""
init_widget = None
try:
if not self.agent_loop.is_initialized:
await self._ensure_loading_widget("Initializing")
init_widget = self._loading_widget
await self.agent_loop.wait_for_init()
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
f"Background initialization failed: {e}",
collapsed=self._tools_collapsed,
)
)
await self._mount_and_scroll(
Static("Press any key to exit...", classes="error-hint")
)
if self._chat_input_container:
self._chat_input_container.disabled = True
self._chat_input_container.display = False
self._fatal_init_error = True
finally:
if self._loading_widget is init_widget:
await self._remove_loading_widget()
self._refresh_banner()
try:
self.query_one(MCPApp).refresh_index()
except Exception:
pass
def _process_initial_prompt(self) -> None:
if self._teleport_on_start:
self.run_worker(
@ -470,6 +504,10 @@ class VibeApp(App): # noqa: PLR0904
def _is_file_watcher_enabled(self) -> bool:
return self.config.file_watcher_for_autocomplete
def on_key(self) -> None:
if self._fatal_init_error:
self.exit()
async def on_chat_input_container_submitted(
self, event: ChatInputContainer.Submitted
) -> None:
@ -754,7 +792,7 @@ class VibeApp(App): # noqa: PLR0904
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
try:
skill_content = read_safe(skill_info.skill_path)
skill_content = read_safe(skill_info.skill_path).text
except OSError as e:
await self._mount_and_scroll(
ErrorMessage(
@ -1059,9 +1097,17 @@ class VibeApp(App): # noqa: PLR0904
self._agent_running = True
await self._remove_loading_widget()
await self._ensure_loading_widget()
try:
show_init_spinner = not self.agent_loop.is_initialized
if show_init_spinner:
await self._ensure_loading_widget("Initializing")
await self.agent_loop.wait_for_init()
if show_init_spinner:
await self._remove_loading_widget()
self._refresh_banner()
await self._ensure_loading_widget()
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt)
@ -1088,6 +1134,11 @@ class VibeApp(App): # noqa: PLR0904
except Exception as e:
await self._handle_turn_error()
# _watch_init_completion already rendered the fatal startup error
# and told the user to exit -- don't duplicate the message.
if self._fatal_init_error:
return
message = str(e)
if isinstance(e, RateLimitError):
message = self._rate_limit_message()
@ -2542,7 +2593,9 @@ class VibeApp(App): # noqa: PLR0904
def _make_default_narrator_manager(self) -> NarratorManager:
return NarratorManager(
config_getter=lambda: self.config, audio_player=AudioPlayer()
config_getter=lambda: self.config,
audio_player=AudioPlayer(),
telemetry_client=self.agent_loop.telemetry_client,
)

View file

@ -26,7 +26,7 @@ class ExternalEditor:
parts = shlex.split(editor)
subprocess.run([*parts, filepath], check=True)
content = read_safe(Path(filepath)).rstrip()
content = read_safe(Path(filepath)).text.rstrip()
return content if content != initial_content else None
except (OSError, subprocess.CalledProcessError):
return

View file

@ -68,6 +68,8 @@ class Banner(Static):
self.state = self._initial_state
def watch_state(self) -> None:
if not self.is_mounted:
return
self.query_one("#banner-model", NoMarkupStatic).update(self.state.active_model)
self.query_one("#banner-meta-counts", NoMarkupStatic).update(
self._format_meta_counts()

View file

@ -78,6 +78,11 @@ class MCPApp(Container):
self._refresh_view(self._viewing_server)
self.query_one(OptionList).focus()
def refresh_index(self) -> None:
"""Re-snapshot the tool index (e.g. after deferred MCP discovery)."""
self._index = collect_mcp_tool_index(self._mcp_servers, self._tool_manager)
self._refresh_view(self._viewing_server)
def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus()

View file

@ -15,7 +15,7 @@ class TeleportMessage(StatusMessage):
if self._error:
return f"Teleport failed: {self._error}"
if self._final_url:
return f"Teleported to Nuage: {self._final_url}"
return f"Teleported to a new async coding session: {self._final_url}"
return self._status
def set_status(self, status: str) -> None:

View file

@ -24,7 +24,7 @@ def load_whats_new_content() -> str | None:
if not whats_new_file.exists():
return None
try:
content = read_safe(whats_new_file).strip()
content = read_safe(whats_new_file).text.strip()
return content if content else None
except OSError:
return None