v2.7.5 (#589)
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:
parent
e9a9217cc8
commit
e1a25caa52
85 changed files with 2830 additions and 594 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
30
vibe/cli/narrator_manager/telemetry.py
Normal file
30
vibe/cli/narrator_manager/telemetry.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue