Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Jean-Baptiste Muscat <jeanbaptiste.muscatdupuis@mistral.ai>
Co-authored-by: JeroenvdV <JeroenvdV@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: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Simon Van de Kerckhove <simon.vandekerckhove@mistral.ai>
Co-authored-by: Val <102326092+vdeva@users.noreply.github.com>
Co-authored-by: Vincent G <10739306+VinceOPS@users.noreply.github.com>
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-05 14:40:11 +02:00 committed by GitHub
parent 71f373c60c
commit 4972dd5694
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 2892 additions and 842 deletions

View file

@ -265,6 +265,7 @@ def run_cli(args: argparse.Namespace) -> None:
initial_prompt=args.initial_prompt or stdin_prompt,
teleport_on_start=args.teleport,
show_resume_picker=args.resume is True,
is_resuming_session=loaded_session is not None,
),
)

View file

@ -128,6 +128,11 @@ class CommandRegistry:
description="Browse and resume past sessions",
handler="_show_session_picker",
),
"rename": Command(
aliases=frozenset(["/rename"]),
description="Rename the current session",
handler="_rename_session",
),
"mcp": Command(
aliases=frozenset(["/mcp", "/connectors"]),
description=(
@ -225,7 +230,7 @@ class CommandRegistry:
"- `Ctrl+C` Quit (or clear input if text present)",
"- `Ctrl+G` Edit input in external editor",
"- `Ctrl+O` Toggle tool output view",
"- `Shift+Tab` Toggle auto-approve mode",
"- `Shift+Tab` Cycle through agents (default, plan, ...)",
f"- `{ALT_KEY}+↑↓` / `Ctrl+P/N` Rewind to previous/next message",
"",
"### Special Features",

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import codecs
from collections.abc import AsyncGenerator
from contextlib import aclosing
from dataclasses import dataclass
@ -9,9 +10,9 @@ import gc
import os
from pathlib import Path
import signal
import subprocess
import time
from typing import Any, ClassVar, assert_never, cast
from uuid import uuid4
from weakref import WeakKeyDictionary
import webbrowser
@ -121,6 +122,7 @@ from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.agents import AgentProfile
from vibe.core.audio_player.audio_player import AudioPlayer
from vibe.core.audio_recorder import AudioRecorder
from vibe.core.autocompletion.path_prompt import build_path_prompt_payload
from vibe.core.autocompletion.path_prompt_adapter import render_path_prompt
from vibe.core.config import VibeConfig
from vibe.core.data_retention import DATA_RETENTION_MESSAGE
@ -135,6 +137,7 @@ from vibe.core.session.resume_sessions import (
list_remote_resume_sessions,
short_session_id,
)
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.types import (
@ -294,6 +297,7 @@ class StartupOptions:
initial_prompt: str | None = None
teleport_on_start: bool = False
show_resume_picker: bool = False
is_resuming_session: bool = False
class VibeApp(App): # noqa: PLR0904
@ -349,6 +353,7 @@ class VibeApp(App): # noqa: PLR0904
self._agent_running = False
self._interrupt_requested = False
self._agent_task: asyncio.Task | None = None
self._bash_task: asyncio.Task | None = None
self._remote_manager = RemoteSessionManager()
self._loading_widget: LoadingWidget | None = None
@ -374,12 +379,7 @@ class VibeApp(App): # noqa: PLR0904
self._update_cache_repository = update_cache_repository
self._current_version = current_version
self._plan_offer_gateway = plan_offer_gateway
opts = startup or StartupOptions()
self._initial_prompt = opts.initial_prompt
self._teleport_on_start = (
opts.teleport_on_start and self.agent_loop.base_config.vibe_code_enabled
)
self._show_resume_picker = opts.show_resume_picker
self._configure_startup_options(startup)
self._last_escape_time: float | None = None
self._quit_manager = QuitManager(self)
self._banner: Banner | None = None
@ -399,6 +399,15 @@ class VibeApp(App): # noqa: PLR0904
self._fatal_init_error = False
self.commands = self._build_command_registry()
def _configure_startup_options(self, startup: StartupOptions | None) -> None:
opts = startup or StartupOptions()
self._initial_prompt = opts.initial_prompt
self._teleport_on_start = (
opts.teleport_on_start and self.agent_loop.base_config.vibe_code_enabled
)
self._show_resume_picker = opts.show_resume_picker
self._is_resuming_session = opts.is_resuming_session
@property
def config(self) -> VibeConfig:
return self.agent_loop.config
@ -497,7 +506,8 @@ class VibeApp(App): # noqa: PLR0904
await self._resume_history_from_messages()
await self._check_and_show_whats_new()
self._schedule_update_notification()
self.agent_loop.emit_new_session_telemetry()
if not self._is_resuming_session:
self.agent_loop.emit_new_session_telemetry()
self.call_after_refresh(self._refresh_banner)
self._show_hook_config_issues_once()
@ -586,11 +596,15 @@ class VibeApp(App): # noqa: PLR0904
input_widget = self.query_one(ChatInputContainer)
input_widget.value = ""
if self._bash_task and not self._bash_task.done():
self._bash_task.cancel()
self._bash_task = None
if self._agent_running:
await self._interrupt_agent_loop()
if value.startswith("!"):
await self._handle_bash_command(value[1:])
self._bash_task = asyncio.create_task(self._handle_bash_command(value[1:]))
return
if value.startswith("&") and self.commands.has_command("teleport"):
@ -619,6 +633,16 @@ class VibeApp(App): # noqa: PLR0904
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
async def on_approval_app_approval_granted_always_permanent(
self, message: ApprovalApp.ApprovalGrantedAlwaysPermanent
) -> None:
self.agent_loop.approve_always(
message.tool_name, message.required_permissions, save_permanently=True
)
if self._pending_approval and not self._pending_approval.done():
self._pending_approval.set_result((ApprovalResponse.YES, None))
async def on_approval_app_approval_rejected(
self, message: ApprovalApp.ApprovalRejected
) -> None:
@ -905,6 +929,40 @@ class VibeApp(App): # noqa: PLR0904
await self._handle_user_message(prompt)
return True
@staticmethod
async def _bash_read_stream(
stream: asyncio.StreamReader | None,
parts: list[str],
bash_msg: BashOutputMessage,
) -> None:
if not stream:
return
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
while True:
chunk = await stream.read(4096)
if not chunk:
break
text = decoder.decode(chunk)
if not text:
continue
parts.append(text)
await bash_msg.append_output(text)
final_text = decoder.decode(b"", final=True)
if not final_text:
return
parts.append(final_text)
await bash_msg.append_output(final_text)
@staticmethod
async def _kill_running_process(proc: asyncio.subprocess.Process | None) -> None:
if proc is None or proc.returncode is not None:
return
try:
proc.kill()
except ProcessLookupError:
pass
await proc.wait()
async def _handle_bash_command(self, command: str) -> None:
if not command:
await self._mount_and_scroll(
@ -914,21 +972,52 @@ class VibeApp(App): # noqa: PLR0904
)
return
bash_msg = BashOutputMessage(command, str(Path.cwd()), pending=True)
await self._mount_and_scroll(bash_msg)
proc: asyncio.subprocess.Process | None = None
stdout_parts: list[str] = []
stderr_parts: list[str] = []
try:
result = subprocess.run(
command, shell=True, capture_output=True, text=False, timeout=30
)
stdout = (
result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
)
stderr = (
result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
)
output = stdout or stderr or "(no output)"
exit_code = result.returncode
await self._mount_and_scroll(
BashOutputMessage(command, str(Path.cwd()), output, exit_code)
proc = await asyncio.create_subprocess_shell(
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
await asyncio.wait_for(
asyncio.gather(
self._bash_read_stream(proc.stdout, stdout_parts, bash_msg),
self._bash_read_stream(proc.stderr, stderr_parts, bash_msg),
proc.wait(),
),
timeout=30,
)
except TimeoutError:
await self._kill_running_process(proc)
stdout = "".join(stdout_parts)
stderr = "".join(stderr_parts)
await bash_msg.finish(1)
await self._mount_and_scroll(
ErrorMessage(
"Command timed out after 30 seconds",
collapsed=self._tools_collapsed,
)
)
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
stdout=stdout,
stderr=stderr,
status="timed out after 30 seconds",
)
)
return
stdout = "".join(stdout_parts)
stderr = "".join(stderr_parts)
exit_code = proc.returncode or 0
await bash_msg.finish(exit_code)
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
@ -938,33 +1027,25 @@ class VibeApp(App): # noqa: PLR0904
stderr=stderr,
)
)
except subprocess.TimeoutExpired as error:
stdout = (
error.stdout.decode("utf-8", errors="replace")
if isinstance(error.stdout, bytes)
else (error.stdout or "")
)
stderr = (
error.stderr.decode("utf-8", errors="replace")
if isinstance(error.stderr, bytes)
else (error.stderr or "")
)
await self._mount_and_scroll(
ErrorMessage(
"Command timed out after 30 seconds",
collapsed=self._tools_collapsed,
)
)
except asyncio.CancelledError:
await self._kill_running_process(proc)
await bash_msg.finish(1, interrupted=True)
stdout = "".join(stdout_parts)
stderr = "".join(stderr_parts)
await self.agent_loop.inject_user_context(
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
stdout=stdout,
stderr=stderr,
status="timed out after 30 seconds",
status="interrupted by user",
)
)
except Exception as e:
await self._kill_running_process(proc)
await bash_msg.finish(1)
stdout = "".join(stdout_parts)
stderr = "".join(stderr_parts)
await self._mount_and_scroll(
ErrorMessage(f"Command failed: {e}", collapsed=self._tools_collapsed)
)
@ -972,6 +1053,8 @@ class VibeApp(App): # noqa: PLR0904
self._format_manual_command_context(
command=command,
cwd=str(Path.cwd()),
stdout=stdout,
stderr=stderr,
status=f"failed before completion: {e}",
)
)
@ -1230,10 +1313,30 @@ class VibeApp(App): # noqa: PLR0904
try:
await self._handle_agent_loop_init()
await self._ensure_loading_widget()
message_id = str(uuid4())
prompt_payload = build_path_prompt_payload(prompt, base_dir=Path.cwd())
if prompt_payload.all_resources:
context_types: dict[str, int] = {}
for r in prompt_payload.all_resources:
context_types[r.kind] = context_types.get(r.kind, 0) + 1
file_ext_counts: dict[str, int] = {}
for r in prompt_payload.all_resources:
if r.kind == "file" and r.path.suffix:
file_ext_counts[r.path.suffix] = (
file_ext_counts.get(r.path.suffix, 0) + 1
)
self.agent_loop.telemetry_client.send_at_mention_inserted(
nb_mentions=len(prompt_payload.all_resources),
context_types=context_types,
file_extensions=file_ext_counts or None,
message_id=message_id,
)
rendered_prompt = render_path_prompt(prompt, base_dir=Path.cwd())
self._narrator_manager.cancel()
self._narrator_manager.on_turn_start(rendered_prompt)
async with aclosing(self.agent_loop.act(rendered_prompt)) as events:
async with aclosing(
self.agent_loop.act(rendered_prompt, client_message_id=message_id)
) as events:
await self._handle_agent_loop_events(events)
except asyncio.CancelledError:
await self._handle_turn_error()
@ -1566,6 +1669,53 @@ class VibeApp(App): # noqa: PLR0904
async def _show_data_retention(self, **kwargs: Any) -> None:
await self._mount_and_scroll(UserCommandMessage(DATA_RETENTION_MESSAGE))
async def _rename_local_session(self, title: str) -> str:
session_logger = self.agent_loop.session_logger
if not session_logger.enabled or session_logger.session_metadata is None:
raise ValueError("Session logging is disabled in configuration.")
if (
session_logger.session_dir is not None
and session_logger.metadata_filepath.exists()
):
await update_saved_session_title_at_path(session_logger.session_dir, title)
session_logger.set_title(title)
renamed_title = session_logger.session_metadata.title
assert renamed_title is not None
return renamed_title
async def _rename_session(self, cmd_args: str = "", **kwargs: Any) -> None:
if self._remote_manager.is_active:
await self._mount_and_scroll(
ErrorMessage(
"Renaming is only supported for local sessions.",
collapsed=self._tools_collapsed,
)
)
return
title = cmd_args.strip()
if not title:
await self._mount_and_scroll(
ErrorMessage("Usage: /rename <title>", collapsed=self._tools_collapsed)
)
return
try:
renamed_title = await self._rename_local_session(title)
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
f"Failed to rename session: {e}", collapsed=self._tools_collapsed
)
)
return
await self._mount_and_scroll(
UserCommandMessage(f'Session renamed to "{renamed_title}".')
)
async def _show_session_picker(self, **kwargs: Any) -> None:
cwd = str(Path.cwd())
local_sessions = (
@ -1608,7 +1758,8 @@ class VibeApp(App): # noqa: PLR0904
sessions = sorted(raw_sessions, key=lambda s: s.end_time or "", reverse=True)
latest_messages = {
s.option_id: SessionLoader.get_first_user_message(
s.option_id: s.title
or SessionLoader.get_first_user_message(
s.session_id, self.config.session_logging
)
for s in sessions
@ -2598,6 +2749,8 @@ class VibeApp(App): # noqa: PLR0904
def _force_quit(self) -> None:
if self._agent_task and not self._agent_task.done():
self._agent_task.cancel()
if self._bash_task and not self._bash_task.done():
self._bash_task.cancel()
self._remote_manager.cancel_stream_task()
self._log_reader.shutdown()

View file

@ -413,6 +413,10 @@ Markdown {
&.bash-error {
color: ansi_red;
}
&.bash-interrupted {
color: ansi_yellow;
}
}
.bash-command {
@ -786,7 +790,7 @@ StatusMessage {
.approval-title {
height: auto;
text-style: bold;
color: ansi_yellow;
color: ansi_bright_yellow;
}
.approval-tool-info-container {
@ -808,6 +812,11 @@ StatusMessage {
.approval-option {
height: auto;
color: ansi_default;
margin-bottom: 1;
&:last-of-type {
margin-bottom: 0;
}
}
.approval-cursor-selected {
@ -817,14 +826,14 @@ StatusMessage {
}
&.approval-option-no {
color: ansi_red;
color: ansi_bright_red;
text-style: bold;
}
}
.approval-option-selected {
&.approval-option-yes {
color: ansi_green;
color: ansi_white;
}
&.approval-option-no {
@ -839,7 +848,7 @@ StatusMessage {
.approval-description {
height: auto;
color: ansi_default;
color: ansi_bright_black;
}
.code-block {

View file

@ -20,6 +20,8 @@ class ApprovalApp(Container):
can_focus = True
can_focus_children = False
NUM_OPTIONS = 4
BINDINGS: ClassVar[list[BindingType]] = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
@ -27,8 +29,9 @@ class ApprovalApp(Container):
Binding("1", "select_1", "Yes", show=False),
Binding("y", "select_1", "Yes", show=False),
Binding("2", "select_2", "Always Tool Session", show=False),
Binding("3", "select_3", "No", show=False),
Binding("n", "select_3", "No", show=False),
Binding("3", "select_3", "Always Permanent", show=False),
Binding("4", "select_4", "No", show=False),
Binding("n", "select_4", "No", show=False),
]
class ApprovalGranted(Message):
@ -49,6 +52,18 @@ class ApprovalApp(Container):
self.tool_args = tool_args
self.required_permissions = required_permissions
class ApprovalGrantedAlwaysPermanent(Message):
def __init__(
self,
tool_name: str,
tool_args: BaseModel,
required_permissions: list[RequiredPermission],
) -> None:
super().__init__()
self.tool_name = tool_name
self.tool_args = tool_args
self.required_permissions = required_permissions
class ApprovalRejected(Message):
def __init__(self, tool_name: str, tool_args: BaseModel) -> None:
super().__init__()
@ -77,20 +92,18 @@ class ApprovalApp(Container):
def compose(self) -> ComposeResult:
with Vertical(id="approval-options"):
yield NoMarkupStatic("")
for _ in range(3):
for _ in range(self.NUM_OPTIONS):
widget = NoMarkupStatic("", classes="approval-option")
self.option_widgets.append(widget)
yield widget
yield NoMarkupStatic("")
self.help_widget = NoMarkupStatic(
"↑↓ navigate Enter select ESC reject", classes="approval-help"
)
yield self.help_widget
with Vertical(id="approval-content"):
self.title_widget = NoMarkupStatic(
f"{self.tool_name} command", classes="approval-title"
)
title = self._build_title()
self.title_widget = NoMarkupStatic(title, classes="approval-title")
yield self.title_widget
with VerticalScroll(classes="approval-tool-info-scroll"):
@ -99,6 +112,12 @@ class ApprovalApp(Container):
)
yield self.tool_info_container
def _build_title(self) -> str:
if self.required_permissions:
labels = ", ".join(rp.label for rp in self.required_permissions)
return f"Permission for the {self.tool_name} tool ({labels})"
return f"Permission for the {self.tool_name} tool"
async def on_mount(self) -> None:
await self._update_tool_info()
self._update_options()
@ -113,16 +132,11 @@ class ApprovalApp(Container):
await self.tool_info_container.mount(approval_widget)
def _update_options(self) -> None:
if self.required_permissions:
labels = ", ".join(rp.label for rp in self.required_permissions)
always_text = f"Yes and always allow for this session: {labels}"
else:
always_text = f"Yes and always allow {self.tool_name} for this session"
options = [
("Yes", "yes"),
(always_text, "yes"),
("No and tell the agent what to do instead", "no"),
("Allow once", "yes"),
("Allow for remainder of this session", "yes"),
("Always allow", "yes"),
("Deny", "no"),
]
for idx, ((text, color_type), widget) in enumerate(
@ -154,11 +168,11 @@ class ApprovalApp(Container):
widget.add_class("approval-option-no")
def action_move_up(self) -> None:
self.selected_option = (self.selected_option - 1) % 3
self.selected_option = (self.selected_option - 1) % self.NUM_OPTIONS
self._update_options()
def action_move_down(self) -> None:
self.selected_option = (self.selected_option + 1) % 3
self.selected_option = (self.selected_option + 1) % self.NUM_OPTIONS
self._update_options()
def action_select(self) -> None:
@ -176,9 +190,13 @@ class ApprovalApp(Container):
self.selected_option = 2
self._handle_selection(2)
def action_select_4(self) -> None:
self.selected_option = 3
self._handle_selection(3)
def action_reject(self) -> None:
self.selected_option = 2
self._handle_selection(2)
self.selected_option = 3
self._handle_selection(3)
def _handle_selection(self, option: int) -> None:
match option:
@ -197,6 +215,14 @@ class ApprovalApp(Container):
)
)
case 2:
self.post_message(
self.ApprovalGrantedAlwaysPermanent(
tool_name=self.tool_name,
tool_args=self.tool_args,
required_permissions=self.required_permissions,
)
)
case 3:
self.post_message(
self.ApprovalRejected(
tool_name=self.tool_name, tool_args=self.tool_args

View file

@ -264,23 +264,92 @@ class InterruptMessage(Static):
class BashOutputMessage(Static):
def __init__(self, command: str, cwd: str, output: str, exit_code: int) -> None:
def __init__(
self,
command: str,
cwd: str,
output: str = "",
exit_code: int = 0,
*,
pending: bool = False,
) -> None:
super().__init__()
self.add_class("bash-output-message")
self._command = command
self._cwd = cwd
self._output = output.rstrip("\n")
self._exit_code = exit_code
self._pending = pending
self._output_widget: NoMarkupStatic | None = None
self._output_container: Horizontal | None = None
self._prompt_widget: NonSelectableStatic | None = None
def compose(self) -> ComposeResult:
status_class = "bash-success" if self._exit_code == 0 else "bash-error"
status_class = (
"bash-error"
if not self._pending and self._exit_code != 0
else "bash-success"
)
self.add_class(status_class)
with Horizontal(classes="bash-command-line"):
yield NonSelectableStatic("$ ", classes=f"bash-prompt {status_class}")
self._prompt_widget = NonSelectableStatic(
"$ ", classes=f"bash-prompt {status_class}"
)
yield self._prompt_widget
yield NoMarkupStatic(self._command, classes="bash-command")
with Horizontal(classes="bash-output-container"):
yield ExpandingBorder(classes="bash-output-border")
yield NoMarkupStatic(self._output, classes="bash-output")
if not self._pending:
self._output_container = Horizontal(classes="bash-output-container")
with self._output_container:
yield ExpandingBorder(classes="bash-output-border")
self._output_widget = NoMarkupStatic(
self._output, classes="bash-output"
)
yield self._output_widget
async def _ensure_output_container(self) -> None:
if self._output_container is not None:
return
self._output_widget = NoMarkupStatic("", classes="bash-output")
self._output_container = Horizontal(
ExpandingBorder(classes="bash-output-border"),
self._output_widget,
classes="bash-output-container",
)
await self.mount(self._output_container)
async def append_output(self, text: str) -> None:
await self._ensure_output_container()
self._output += text
if self._output_widget:
self._output_widget.update(self._output.rstrip("\n"))
async def finish(self, exit_code: int, *, interrupted: bool = False) -> None:
self._exit_code = exit_code
self._pending = False
if interrupted:
self.remove_class("bash-success")
self.add_class("bash-interrupted")
if self._prompt_widget:
self._prompt_widget.remove_class("bash-success")
self._prompt_widget.add_class("bash-interrupted")
elif exit_code != 0:
self.remove_class("bash-success")
self.add_class("bash-error")
if self._prompt_widget:
self._prompt_widget.remove_class("bash-success")
self._prompt_widget.add_class("bash-error")
if interrupted:
suffix = (
"\n(interrupted)"
if self._output and not self._output.endswith("\n")
else "(interrupted)"
)
self._output += suffix
if not self._output:
self._output = "(no output)"
await self._ensure_output_container()
if self._output_widget:
self._output_widget.update(self._output.rstrip("\n"))
class ErrorMessage(Static):