Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: allansimon-mistral <allan.simon@ext.mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Sirieix 2026-05-07 13:55:56 +02:00 committed by GitHub
parent 4972dd5694
commit b23f49e5f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
74 changed files with 2342 additions and 240 deletions

View file

@ -32,7 +32,7 @@ class SlashCommandController:
return
suggestions = self._completer.get_completion_items(text, cursor_index)
if suggestions and all(alias != text for alias, _ in suggestions):
if suggestions:
self._suggestions = suggestions
self._selected_index = 0
self._view.render_completion_suggestions(

View file

@ -42,10 +42,10 @@ def _build_cli_entrypoint_metadata() -> EntrypointMetadata:
)
def get_initial_agent_name(args: argparse.Namespace) -> str:
if args.prompt is not None and args.agent == BuiltinAgentName.DEFAULT:
def get_initial_agent_name(args: argparse.Namespace, config: VibeConfig) -> str:
if args.prompt is not None and args.agent is None:
return BuiltinAgentName.AUTO_APPROVE
return args.agent
return args.agent or config.default_agent
def get_prompt_from_stdin() -> str | None:
@ -196,9 +196,9 @@ def run_cli(args: argparse.Namespace) -> None:
sys.exit(0)
try:
initial_agent_name = get_initial_agent_name(args)
is_interactive = args.prompt is None
config = load_config_or_exit(interactive=is_interactive)
initial_agent_name = get_initial_agent_name(args, config)
hook_config_result = load_hooks_from_fs(config)
setup_tracing(config)
@ -243,18 +243,22 @@ def run_cli(args: argparse.Namespace) -> None:
except TeleportError as e:
print(f"Teleport error: {e}", file=sys.stderr)
sys.exit(1)
except RuntimeError as e:
except (RuntimeError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
agent_loop = AgentLoop(
config,
agent_name=initial_agent_name,
enable_streaming=True,
entrypoint_metadata=_build_cli_entrypoint_metadata(),
defer_heavy_init=True,
hook_config_result=hook_config_result,
)
try:
agent_loop = AgentLoop(
config,
agent_name=initial_agent_name,
enable_streaming=True,
entrypoint_metadata=_build_cli_entrypoint_metadata(),
defer_heavy_init=True,
hook_config_result=hook_config_result,
)
except ValueError as e:
rprint(f"[red]Error:[/] {e}")
sys.exit(1)
if loaded_session:
_resume_previous_session(agent_loop, *loaded_session)

View file

@ -161,6 +161,14 @@ class CommandRegistry:
description="Rewind to a previous message",
handler="_start_rewind_mode",
),
"loop": Command(
aliases=frozenset(["/loop"]),
description=(
"Schedule a recurring prompt. "
"Use `/loop <interval> <prompt>`, `/loop list`, or `/loop cancel <id|all>`"
),
handler="_loop_command",
),
"data-retention": Command(
aliases=frozenset(["/data-retention"]),
description="Show data retention information",

View file

@ -8,7 +8,6 @@ import sys
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 find_trustable_files, trusted_folders_manager
from vibe.setup.trusted_folders.trust_folder_dialog import (
@ -18,7 +17,18 @@ from vibe.setup.trusted_folders.trust_folder_dialog import (
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run the Mistral Vibe interactive CLI")
parser = argparse.ArgumentParser(
description="Run the Mistral Vibe interactive CLI",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Environment variables:\n"
" VIBE_HOME Override the Vibe home directory (default: ~/.vibe)\n"
" LOG_LEVEL Logging level: DEBUG, INFO, WARNING (default), ERROR, CRITICAL.\n"
" Logs are written to $VIBE_HOME/logs/vibe.log.\n"
" LOG_MAX_BYTES Max size of vibe.log before rotation (default: 10485760).\n"
" VIBE_* Override any config field (e.g. VIBE_ACTIVE_MODEL=local)."
),
)
parser.add_argument(
"-v", "--version", action="version", version=f"%(prog)s {__version__}"
)
@ -72,9 +82,12 @@ def parse_arguments() -> argparse.Namespace:
parser.add_argument(
"--agent",
metavar="NAME",
default=BuiltinAgentName.DEFAULT,
default=None,
help="Agent to use (builtin: default, plan, accept-edits, auto-approve, "
"or custom from ~/.vibe/agents/NAME.toml)",
"or custom from ~/.vibe/agents/NAME.toml). In interactive mode, "
"defaults to the 'default_agent' config setting. In programmatic "
"mode (-p/--prompt), defaults to auto-approve and 'default_agent' "
"is ignored.",
)
parser.add_argument("--setup", action="store_true", help="Setup API key and exit")
parser.add_argument(

View file

@ -1,7 +1,9 @@
from __future__ import annotations
import json
import os
from pathlib import Path
import tempfile
from vibe.core.utils.io import read_safe
@ -22,12 +24,10 @@ class HistoryManager:
try:
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:
@ -36,13 +36,24 @@ class HistoryManager:
entry = raw_line
entries.append(entry if isinstance(entry, str) else str(entry))
self._entries = entries[-self.max_entries :]
self.reset_navigation()
def _save_history(self) -> None:
try:
self.history_file.parent.mkdir(parents=True, exist_ok=True)
with self.history_file.open("w", encoding="utf-8") as f:
for entry in self._entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
fd, tmp_path = tempfile.mkstemp(
prefix=f".{self.history_file.name}.",
suffix=".tmp",
dir=self.history_file.parent,
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
for entry in self._entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
os.replace(tmp_path, self.history_file)
except OSError:
Path(tmp_path).unlink(missing_ok=True)
raise
except OSError:
pass
@ -51,6 +62,8 @@ class HistoryManager:
if not text:
return
self._load_history()
if self._entries and self._entries[-1] == text:
return
@ -60,7 +73,6 @@ class HistoryManager:
self._entries = self._entries[-self.max_entries :]
self._save_history()
self.reset_navigation()
def get_previous(self, current_input: str) -> str | None:
if not self._entries:

View file

@ -51,6 +51,7 @@ from vibe.cli.textual_ui.notifications import (
)
from vibe.cli.textual_ui.quit_manager import QuitManager
from vibe.cli.textual_ui.remote import RemoteSessionManager, is_progress_event
from vibe.cli.textual_ui.scheduled_loop_runner import ScheduledLoopRunner
from vibe.cli.textual_ui.session_exit import print_session_resume_message
from vibe.cli.textual_ui.widgets.approval_app import ApprovalApp
from vibe.cli.textual_ui.widgets.banner.banner import Banner
@ -140,6 +141,7 @@ from vibe.core.session.resume_sessions import (
from vibe.core.session.saved_sessions import update_saved_session_title_at_path
from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager
from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry
from vibe.core.teleport.types import (
TeleportAuthCompleteEvent,
TeleportAuthRequiredEvent,
@ -339,7 +341,6 @@ class VibeApp(App): # noqa: PLR0904
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.scroll_sensitivity_y = 1.0
self.agent_loop = agent_loop
self._plan_info: PlanInfo | None = None
self._voice_manager: VoiceManagerPort = (
@ -397,7 +398,17 @@ class VibeApp(App): # noqa: PLR0904
self._rewind_mode = False
self._rewind_highlighted_widget: UserMessage | None = None
self._fatal_init_error = False
self._force_quit_task: asyncio.Task[None] | None = None
self.commands = self._build_command_registry()
self._loop_runner = ScheduledLoopRunner(
self.agent_loop.session_logger,
can_fire=lambda: (
not self._agent_running and self._current_bottom_app == BottomApp.Input
),
fire=self._handle_user_message,
mount=self._mount_and_scroll,
tools_collapsed=lambda: self._tools_collapsed,
)
def _configure_startup_options(self, startup: StartupOptions | None) -> None:
opts = startup or StartupOptions()
@ -504,6 +515,8 @@ class VibeApp(App): # noqa: PLR0904
await self._resolve_plan()
await self._show_dangerous_directory_warning()
await self._resume_history_from_messages()
self._loop_runner.restore_from_session()
self._loop_runner.start()
await self._check_and_show_whats_new()
self._schedule_update_notification()
if not self._is_resuming_session:
@ -1407,6 +1420,12 @@ class VibeApp(App): # noqa: PLR0904
if show_message:
await self._mount_and_scroll(UserMessage("/teleport"))
if not has_history:
send_teleport_early_failure_telemetry(
self.agent_loop.telemetry_client,
stage="no_history",
error_class="TeleportNoHistoryError",
nb_session_messages=len(self.agent_loop.messages[1:]),
)
await self._mount_and_scroll(
ErrorMessage(
"No conversation history to teleport.",
@ -1429,6 +1448,12 @@ class VibeApp(App): # noqa: PLR0904
await self._mount_and_scroll(teleport_msg)
if self._remote_manager.is_active:
send_teleport_early_failure_telemetry(
self.agent_loop.telemetry_client,
stage="remote_session",
error_class="TeleportRemoteSessionError",
nb_session_messages=len(self.agent_loop.messages[1:]),
)
await loading.remove()
await self._mount_and_scroll(
ErrorMessage(
@ -1818,6 +1843,8 @@ class VibeApp(App): # noqa: PLR0904
f"Session `{short_session_id(session.session_id)}` not found."
)
self._emit_session_closed_for_active_session()
loaded_messages, metadata = SessionLoader.load_session(session_path)
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
@ -1846,6 +1873,7 @@ class VibeApp(App): # noqa: PLR0904
if self.event_handler:
self.event_handler.is_remote = False
await self._resume_history_from_messages()
self._loop_runner.restore_from_session()
await self._mount_and_scroll(
UserCommandMessage(
f"Resumed session `{short_session_id(session.session_id)}`"
@ -1856,6 +1884,8 @@ class VibeApp(App): # noqa: PLR0904
await self._remote_manager.attach(
session_id=session.session_id, config=self.config
)
self._emit_session_closed_for_active_session()
self.agent_loop.session_id = session.session_id
self._refresh_profile_widgets()
if self._chat_input_container:
self._chat_input_container.set_custom_border(None)
@ -2018,6 +2048,10 @@ class VibeApp(App): # noqa: PLR0904
)
)
async def _loop_command(self, cmd_args: str = "", **kwargs: Any) -> None:
widget = await self._loop_runner.handle_command(cmd_args)
await self._mount_and_scroll(widget)
async def _compact_history(self, cmd_args: str = "", **kwargs: Any) -> None:
if self._agent_running:
await self._mount_and_scroll(
@ -2041,22 +2075,32 @@ class VibeApp(App): # noqa: PLR0904
return
old_tokens = self.agent_loop.stats.context_tokens
old_session_id = self.agent_loop.session_id
compact_msg = CompactMessage()
self.event_handler.current_compact = compact_msg
await self._mount_and_scroll(compact_msg)
self._agent_task = asyncio.create_task(
self._run_compact(compact_msg, old_tokens, cmd_args.strip())
self._run_compact(compact_msg, old_tokens, old_session_id, cmd_args.strip())
)
async def _run_compact(
self, compact_msg: CompactMessage, old_tokens: int, extra_instructions: str = ""
self,
compact_msg: CompactMessage,
old_tokens: int,
old_session_id: str,
extra_instructions: str = "",
) -> None:
self._agent_running = True
try:
await self.agent_loop.compact(extra_instructions=extra_instructions)
new_tokens = self.agent_loop.stats.context_tokens
compact_msg.set_complete(old_tokens=old_tokens, new_tokens=new_tokens)
compact_msg.set_complete(
old_tokens=old_tokens,
new_tokens=new_tokens,
old_session_id=old_session_id,
new_session_id=self.agent_loop.session_id,
)
except asyncio.CancelledError:
compact_msg.set_error("Compaction interrupted")
@ -2085,9 +2129,16 @@ class VibeApp(App): # noqa: PLR0904
return short_session_id(self.agent_loop.session_logger.session_id)
async def _exit_app(self, **kwargs: Any) -> None:
self._emit_session_closed_for_active_session()
await self._loop_runner.stop()
self._log_reader.shutdown()
await self._narrator_manager.close()
self.exit(result=self._get_session_resume_info())
try:
await self.agent_loop.telemetry_client.aclose()
except Exception as exc:
logger.error("Failed to close telemetry client during exit", exc_info=exc)
finally:
self.exit(result=self._get_session_resume_info())
def _make_default_voice_manager(self) -> VoiceManager:
try:
@ -2746,7 +2797,16 @@ class VibeApp(App): # noqa: PLR0904
return
self._quit_manager.request_confirmation("Ctrl+D")
def _emit_session_closed_for_active_session(self) -> None:
self.agent_loop.emit_session_closed_telemetry()
def _force_quit(self) -> None:
if self._force_quit_task is not None and not self._force_quit_task.done():
return
self._force_quit_task = asyncio.create_task(self._force_quit_async())
async def _force_quit_async(self) -> None:
self._emit_session_closed_for_active_session()
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
if self._bash_task and not self._bash_task.done():
@ -2755,7 +2815,14 @@ class VibeApp(App): # noqa: PLR0904
self._log_reader.shutdown()
self._narrator_manager.cancel()
self.exit(result=self._get_session_resume_info())
try:
await self.agent_loop.telemetry_client.aclose()
except Exception as exc:
logger.error(
"Failed to close telemetry client during force quit", exc_info=exc
)
finally:
self.exit(result=self._get_session_resume_info())
def action_scroll_chat_up(self) -> None:
try:

View file

@ -980,7 +980,7 @@ NarratorStatus {
height: auto;
background: transparent;
padding: 0;
margin: 0 0 0 1;
margin: 0;
}
#banner-container {

View file

@ -217,7 +217,10 @@ class EventHandler:
async def _handle_compact_end(self, event: CompactEndEvent) -> None:
if self.current_compact:
self.current_compact.set_complete(
old_tokens=event.old_context_tokens, new_tokens=event.new_context_tokens
old_tokens=event.old_context_tokens,
new_tokens=event.new_context_tokens,
old_session_id=event.old_session_id,
new_session_id=event.new_session_id,
)
self.current_compact = None

View file

@ -0,0 +1,99 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import time
from textual.widget import Widget
from vibe.cli.textual_ui.widgets.messages import ErrorMessage, UserCommandMessage
from vibe.core.logger import logger
from vibe.core.loop import (
USAGE_HINT,
LoopErrorResult,
LoopListResult,
LoopManager,
LoopOkResult,
ScheduledLoop,
format_duration,
)
from vibe.core.session.session_logger import SessionLogger
def _format_loop_list(loops: list[ScheduledLoop]) -> str:
if not loops:
return "No scheduled loops."
now = time.time()
rows = ["| Prompt | Next in | Every | ID |", "|--------|------|-------|----|"]
for loop in loops:
remaining = format_duration(max(0, int(loop.next_fire_at - now)), short=True)
interval = format_duration(loop.interval_seconds)
prompt = loop.prompt.replace("|", "\\|").replace("\n", " ")
rows.append(f"| {prompt} | {remaining} | {interval} | `{loop.id}` |")
return "\n".join(rows)
class ScheduledLoopRunner:
def __init__(
self,
session_logger: SessionLogger,
*,
can_fire: Callable[[], bool],
fire: Callable[[str], Awaitable[None]],
mount: Callable[[Widget], Awaitable[None]],
tools_collapsed: Callable[[], bool],
) -> None:
self._session_logger = session_logger
self._manager = LoopManager(session_logger)
self._can_fire = can_fire
self._fire = fire
self._mount = mount
self._tools_collapsed = tools_collapsed
self._task: asyncio.Task[None] | None = None
def restore_from_session(self) -> None:
metadata = self._session_logger.session_metadata
self._manager.restore(list(metadata.loops) if metadata is not None else [])
def start(self) -> None:
if self._task is None or self._task.done():
self._task = asyncio.create_task(self._poll())
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
async def handle_command(self, cmd_args: str) -> Widget:
result = await self._manager.handle_command(cmd_args)
match result:
case LoopListResult(loops=loops):
return UserCommandMessage(_format_loop_list(loops))
case LoopErrorResult(message=message):
return ErrorMessage(
f"{message}\n{USAGE_HINT}", collapsed=self._tools_collapsed()
)
case LoopOkResult(message=message):
return UserCommandMessage(message)
async def _poll(self) -> None:
while True:
try:
sleep_for = min(self._manager.next_due_in(), 1.0)
await asyncio.sleep(max(0.05, sleep_for))
if not self._can_fire():
continue
due = await self._manager.pop_due()
if due is None:
continue
await self._fire(due.prompt)
await self._mount(UserCommandMessage(f"Loop `{due.id}` fired"))
except asyncio.CancelledError:
return
except Exception as e:
logger.error("Error polling scheduled loops", exc_info=e)

View file

@ -17,6 +17,8 @@ class CompactMessage(StatusMessage):
self.add_class("compact-message")
self.old_tokens: int | None = None
self.new_tokens: int | None = None
self.old_session_id: str | None = None
self.new_session_id: str | None = None
self.error_message: str | None = None
def get_content(self) -> str:
@ -26,13 +28,25 @@ class CompactMessage(StatusMessage):
if self.error_message:
return f"Error: {self.error_message}"
return compact_reduction_display(self.old_tokens, self.new_tokens)
return compact_reduction_display(
self.old_tokens,
self.new_tokens,
old_session_id=self.old_session_id,
new_session_id=self.new_session_id,
)
def set_complete(
self, old_tokens: int | None = None, new_tokens: int | None = None
self,
old_tokens: int | None = None,
new_tokens: int | None = None,
*,
old_session_id: str | None = None,
new_session_id: str | None = None,
) -> None:
self.old_tokens = old_tokens
self.new_tokens = new_tokens
self.old_session_id = old_session_id
self.new_session_id = new_session_id
self.stop_spinning(success=True)
self.post_message(self.Completed(self))