Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Stanislas <stanislas.lange@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-06-08 14:59:41 +02:00 committed by GitHub
parent 3f8487f761
commit 702d0f412e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 2446 additions and 502 deletions

View file

@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
__version__ = "2.14.0"
__version__ = "2.14.1"

View file

@ -72,7 +72,7 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError
from vibe import VIBE_ROOT, __version__
from vibe.acp.acp_logger import acp_message_observer
from vibe.acp.commands import AcpCommandRegistry
from vibe.acp.commands import AcpCommandAvailabilityContext, AcpCommandRegistry
from vibe.acp.exceptions import (
ConfigurationError,
ContextTooLongError,
@ -86,6 +86,7 @@ from vibe.acp.exceptions import (
UnauthenticatedError,
)
from vibe.acp.session import AcpSessionLoop
from vibe.acp.teleport import handle_teleport_command
from vibe.acp.title import acp_blocks_to_title_segments
from vibe.acp.tools.base import BaseAcpTool
from vibe.acp.tools.events import ToolTerminalOpenedEvent
@ -109,6 +110,7 @@ from vibe.acp.utils import (
create_tool_result_replay,
create_user_message_replay,
get_proxy_help_text,
is_jetbrains_client,
is_valid_acp_mode,
make_thinking_response,
)
@ -186,6 +188,7 @@ from vibe.setup.onboarding.context import OnboardingContext
logger = logging.getLogger("vibe")
NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"]
INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS = 0.1
def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None:
@ -354,6 +357,7 @@ class VibeAcpAgentLoop(AcpAgent):
id="vibe-setup",
name="Register your API Key",
description="Register your API Key inside Mistral Vibe",
args=args,
field_meta={
"terminal-auth": {
"command": command,
@ -448,6 +452,12 @@ class VibeAcpAgentLoop(AcpAgent):
if supports_terminal_auth:
auth_methods.append(self._build_terminal_auth_method(command, args))
# JetBrains preemptively shows the auth UI as soon as `authMethods` is
# non-empty; suppress methods for already-authenticated JetBrains clients.
_, auth_state = self._assess_current_auth_state()
if is_jetbrains_client(self.client_info) and auth_state.can_use_active_provider:
auth_methods = []
response = InitializeResponse(
agent_capabilities=AgentCapabilities(
load_session=True,
@ -606,7 +616,11 @@ class VibeAcpAgentLoop(AcpAgent):
async def _create_acp_session(
self, session_id: str, agent_loop: AgentLoop
) -> AcpSessionLoop:
command_registry = AcpCommandRegistry()
command_registry = AcpCommandRegistry(
availability_context=AcpCommandAvailabilityContext(
vibe_code_enabled=agent_loop.base_config.vibe_code_enabled
)
)
session = AcpSessionLoop(
id=session_id, agent_loop=agent_loop, command_registry=command_registry
)
@ -620,11 +634,17 @@ class VibeAcpAgentLoop(AcpAgent):
if not agent_loop.bypass_tool_permissions:
agent_loop.set_approval_callback(self._create_approval_callback(session.id))
session.spawn(self._send_available_commands(session))
session.spawn(self._send_initial_available_commands(session))
session.spawn(self._warm_up_agent_loop(agent_loop))
return session
async def _send_initial_available_commands(self, session: AcpSessionLoop) -> None:
# Zed can drop session/update notifications sent before it registers
# the session returned by session/new, so delay initial command discovery.
await asyncio.sleep(INITIAL_AVAILABLE_COMMANDS_DELAY_SECONDS)
await self._send_available_commands(session)
async def _warm_up_agent_loop(self, agent_loop: AgentLoop) -> None:
"""Proactively await deferred init so `vibe.ready` telemetry is emitted
without waiting for the user's first prompt. Errors are swallowed here
@ -1052,6 +1072,14 @@ class VibeAcpAgentLoop(AcpAgent):
success = await self._apply_thinking_change(
session, cast(ThinkingLevel, value)
)
case "max_turns" if isinstance(value, str):
try:
max_turns = int(value)
except ValueError:
success = False
else:
session.agent_loop.set_max_turns(max_turns)
success = True
case _:
success = False
@ -1701,6 +1729,11 @@ class VibeAcpAgentLoop(AcpAgent):
)
return PromptResponse(stop_reason="end_turn", user_message_id=message_id)
async def _handle_teleport(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:
return await handle_teleport_command(self.client, session, message_id)
async def _handle_help(
self, session: AcpSessionLoop, text_prompt: str, message_id: str
) -> PromptResponse:

View file

@ -1,5 +1,5 @@
from __future__ import annotations
from vibe.acp.commands.registry import AcpCommandRegistry
from vibe.acp.commands.registry import AcpCommandAvailabilityContext, AcpCommandRegistry
__all__ = ["AcpCommandRegistry"]
__all__ = ["AcpCommandAvailabilityContext", "AcpCommandRegistry"]

View file

@ -6,6 +6,19 @@ from dataclasses import dataclass, field
type OnCommandsChanged = Callable[[], Awaitable[None]]
@dataclass(frozen=True)
class AcpCommandAvailabilityContext:
"""Context used to decide whether a command should be advertised."""
vibe_code_enabled: bool = False
def is_teleport_available(self) -> bool:
return self.vibe_code_enabled
type CommandAvailability = Callable[[AcpCommandAvailabilityContext], bool]
@dataclass(frozen=True)
class AcpCommand:
"""Command advertised to ACP clients via available_commands_update."""
@ -14,18 +27,31 @@ class AcpCommand:
description: str
handler: str
input_hint: str | None = None
is_available: CommandAvailability | None = None
@dataclass
class AcpCommandRegistry:
"""Registry of ACP commands. Notifies listeners when commands change."""
availability_context: AcpCommandAvailabilityContext = field(
default_factory=AcpCommandAvailabilityContext
)
_commands: dict[str, AcpCommand] = field(default_factory=dict)
_on_changed: OnCommandsChanged | None = None
def __post_init__(self) -> None:
if not self._commands:
self._commands = _build_commands()
self._commands = {
name: command
for name, command in _build_commands().items()
if self._is_available(command)
}
def _is_available(self, command: AcpCommand) -> bool:
if command.is_available is None:
return True
return command.is_available(self.availability_context)
def set_on_changed(self, callback: OnCommandsChanged) -> None:
self._on_changed = callback
@ -65,6 +91,12 @@ def _build_commands() -> dict[str, AcpCommand]:
description="Show path to current session log directory",
handler="_handle_log",
),
"teleport": AcpCommand(
name="teleport",
description="Teleport session to Vibe Code Web",
handler="_handle_teleport",
is_available=AcpCommandAvailabilityContext.is_teleport_available,
),
"proxy-setup": AcpCommand(
name="proxy-setup",
description="Configure proxy and SSL certificate settings",

334
vibe/acp/teleport.py Normal file
View file

@ -0,0 +1,334 @@
from __future__ import annotations
from contextlib import aclosing
from typing import Literal, cast
from uuid import uuid4
from acp import Client, PromptResponse
from acp.schema import (
AgentMessageChunk,
AllowedOutcome,
ContentToolCallContent,
PermissionOption,
TextContentBlock,
ToolCallProgress,
ToolCallStart,
ToolCallStatus,
ToolCallUpdate,
)
from vibe.acp.session import AcpSessionLoop
from vibe.core.agent_loop import TeleportError
from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry
from vibe.core.teleport.types import (
TeleportCheckingGitEvent,
TeleportCompleteEvent,
TeleportPushingEvent,
TeleportPushRequiredEvent,
TeleportPushResponseEvent,
TeleportStartingWorkflowEvent,
)
from vibe.core.types import Role
TELEPORT_PUSH_OPTION_ID = "teleport_push_and_continue"
TELEPORT_CANCEL_OPTION_ID = "teleport_cancel"
TELEPORT_FIELD_META_KEY = "teleport"
type TeleportAcpStatus = Literal[
"starting",
"preparing_workspace",
"push_required",
"syncing_remote",
"starting_workflow",
"completed",
"failed",
"no_history",
"unavailable",
]
def _teleport_field_meta(
status: TeleportAcpStatus,
*,
url: str | None = None,
unpushed_count: int | None = None,
branch_not_pushed: bool | None = None,
) -> dict[str, object]:
teleport_meta: dict[str, object] = {"status": status}
if url is not None:
teleport_meta["url"] = url
if unpushed_count is not None:
teleport_meta["unpushedCount"] = unpushed_count
if branch_not_pushed is not None:
teleport_meta["branchNotPushed"] = branch_not_pushed
return {"tool_name": "teleport", TELEPORT_FIELD_META_KEY: teleport_meta}
def _teleport_progress_update(
tool_call_id: str,
*,
title: str,
status: ToolCallStatus = "in_progress",
text: str | None = None,
raw_output: str | None = None,
url: str | None = None,
teleport_status: TeleportAcpStatus,
unpushed_count: int | None = None,
branch_not_pushed: bool | None = None,
) -> ToolCallProgress:
return ToolCallProgress(
session_update="tool_call_update",
tool_call_id=tool_call_id,
title=title,
kind="other",
status=status,
raw_output=raw_output,
content=(
[
ContentToolCallContent(
type="content", content=TextContentBlock(type="text", text=text)
)
]
if text
else None
),
field_meta=_teleport_field_meta(
teleport_status,
url=url,
unpushed_count=unpushed_count,
branch_not_pushed=branch_not_pushed,
),
)
async def _teleport_command_reply(
client: Client,
session: AcpSessionLoop,
text: str,
message_id: str,
*,
field_meta: dict[str, object],
) -> PromptResponse:
await client.session_update(
session_id=session.id,
update=AgentMessageChunk(
session_update="agent_message_chunk",
content=TextContentBlock(type="text", text=text),
message_id=str(uuid4()),
field_meta=field_meta,
),
)
return PromptResponse(
stop_reason="end_turn", user_message_id=message_id, field_meta=field_meta
)
async def _request_teleport_push_approval(
client: Client,
session: AcpSessionLoop,
tool_call_id: str,
*,
count: int,
branch_not_pushed: bool,
) -> TeleportPushResponseEvent:
if branch_not_pushed:
question = "Your branch doesn't exist on remote. Push to continue?"
else:
word = f"commit{'s' if count != 1 else ''}"
question = f"You have {count} unpushed {word}. Push to continue?"
await client.session_update(
session_id=session.id,
update=_teleport_progress_update(
tool_call_id,
title="Push required",
text=question,
teleport_status="push_required",
unpushed_count=count,
branch_not_pushed=branch_not_pushed,
),
)
response = await client.request_permission(
session_id=session.id,
tool_call=ToolCallUpdate(
tool_call_id=tool_call_id,
title=question,
kind="execute",
status="pending",
field_meta=_teleport_field_meta(
"push_required",
unpushed_count=count,
branch_not_pushed=branch_not_pushed,
),
),
options=[
PermissionOption(
option_id=TELEPORT_PUSH_OPTION_ID,
name="Push and continue",
kind="allow_once",
),
PermissionOption(
option_id=TELEPORT_CANCEL_OPTION_ID, name="Cancel", kind="reject_once"
),
],
)
if response.outcome.outcome != "selected":
return TeleportPushResponseEvent(approved=False)
outcome = cast(AllowedOutcome, response.outcome)
return TeleportPushResponseEvent(
approved=outcome.option_id == TELEPORT_PUSH_OPTION_ID
)
async def handle_teleport_command(
client: Client, session: AcpSessionLoop, message_id: str
) -> PromptResponse:
if not session.agent_loop.base_config.vibe_code_enabled:
return await _teleport_command_reply(
client,
session,
"Teleport is not available because Vibe Code is disabled.",
message_id,
field_meta=_teleport_field_meta("unavailable"),
)
last_user_message = next(
(
msg
for msg in reversed(session.agent_loop.messages)
if msg.role == Role.user and not msg.injected
),
None,
)
has_resolvable_prompt = (
last_user_message is not None
and isinstance(last_user_message.content, str)
and bool(last_user_message.content)
)
if not has_resolvable_prompt:
send_teleport_early_failure_telemetry(
session.agent_loop.telemetry_client,
stage="no_history",
error_class="TeleportNoHistoryError",
nb_session_messages=len(session.agent_loop.messages[1:]),
)
return await _teleport_command_reply(
client,
session,
"No conversation history to teleport.",
message_id,
field_meta=_teleport_field_meta("no_history"),
)
tool_call_id = str(uuid4())
await client.session_update(
session_id=session.id,
update=ToolCallStart(
session_update="tool_call",
tool_call_id=tool_call_id,
title="Teleporting session to Vibe Code Web...",
kind="other",
status="in_progress",
content=[
ContentToolCallContent(
type="content",
content=TextContentBlock(
type="text", text="Preparing workspace..."
),
)
],
field_meta=_teleport_field_meta("starting"),
),
)
final_url: str | None = None
try:
async with aclosing(session.agent_loop.teleport_to_vibe_code(None)) as events:
response: TeleportPushResponseEvent | None = None
while True:
try:
event = await events.asend(response)
except StopAsyncIteration:
break
response = None
match event:
case TeleportCheckingGitEvent():
await client.session_update(
session_id=session.id,
update=_teleport_progress_update(
tool_call_id,
title="Preparing workspace...",
text="Preparing workspace...",
teleport_status="preparing_workspace",
),
)
case TeleportPushRequiredEvent(
unpushed_count=count, branch_not_pushed=branch_not_pushed
):
response = await _request_teleport_push_approval(
client,
session,
tool_call_id,
count=count,
branch_not_pushed=branch_not_pushed,
)
case TeleportPushingEvent():
await client.session_update(
session_id=session.id,
update=_teleport_progress_update(
tool_call_id,
title="Syncing with remote...",
text="Syncing with remote...",
teleport_status="syncing_remote",
),
)
case TeleportStartingWorkflowEvent():
await client.session_update(
session_id=session.id,
update=_teleport_progress_update(
tool_call_id,
title="Starting Vibe Code Web session...",
text="Starting Vibe Code Web session...",
teleport_status="starting_workflow",
),
)
case TeleportCompleteEvent(url=url):
final_url = url
await client.session_update(
session_id=session.id,
update=_teleport_progress_update(
tool_call_id,
title="Teleported to Vibe Code Web",
status="completed",
text=f"Teleported to Vibe Code Web: {url}",
raw_output=url,
url=url,
teleport_status="completed",
),
)
except TeleportError as e:
await client.session_update(
session_id=session.id,
update=_teleport_progress_update(
tool_call_id,
title="Teleport failed",
status="failed",
text=str(e),
raw_output=str(e),
teleport_status="failed",
),
)
return PromptResponse(
stop_reason="end_turn",
user_message_id=message_id,
field_meta=_teleport_field_meta("failed"),
)
return PromptResponse(
stop_reason="end_turn",
user_message_id=message_id,
field_meta=_teleport_field_meta("completed", url=final_url),
)

View file

@ -7,6 +7,7 @@ from acp.schema import (
AgentMessageChunk,
AgentThoughtChunk,
ContentToolCallContent,
Implementation,
ModelInfo,
PermissionOption,
PermissionOptionKind,
@ -109,6 +110,10 @@ def is_valid_acp_mode(profiles: list[AgentProfile], mode_name: str) -> bool:
)
def is_jetbrains_client(client_info: Implementation | None) -> bool:
return bool(client_info and client_info.name.startswith("JetBrains."))
def build_mode_state(
profiles: list[AgentProfile], current_mode_id: str
) -> tuple[SessionModeState, SessionConfigOptionSelect]:

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import argparse
import asyncio
from pathlib import Path
import sys
@ -10,10 +11,16 @@ import tomli_w
from vibe import __version__
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
from vibe.cli.update_notifier import (
FileSystemUpdateCacheRepository,
UpdateCacheRepository,
get_pending_update_from_cache,
mark_update_as_dismissed,
)
from vibe.core.agent_loop import AgentLoop, TeleportError
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.hooks.config import load_hooks_from_fs
from vibe.core.hooks.config import HookConfigResult, load_hooks_from_fs
from vibe.core.logger import logger
from vibe.core.paths import HISTORY_FILE
from vibe.core.programmatic import run_programmatic
@ -26,6 +33,7 @@ from vibe.core.trusted_folders import find_trustable_files, trusted_folders_mana
from vibe.core.types import LLMMessage, OutputFormat, Role
from vibe.core.utils import ConversationLimitException
from vibe.setup.onboarding import run_onboarding
from vibe.setup.update_prompt import UpdatePromptResult, ask_update_prompt
def _build_cli_entrypoint_metadata() -> EntrypointMetadata:
@ -183,6 +191,94 @@ def _resume_previous_session(
)
def _run_programmatic_mode(
args: argparse.Namespace,
config: VibeConfig,
initial_agent_name: str,
hook_config_result: HookConfigResult,
loaded_session: tuple[list[LLMMessage], Path] | None,
stdin_prompt: str | None,
) -> None:
warn_if_workdir_trust_is_unset()
config.disabled_tools = [
*config.disabled_tools,
"ask_user_question",
"exit_plan_mode",
]
programmatic_prompt = args.prompt or stdin_prompt
if not programmatic_prompt:
print("Error: No prompt provided for programmatic mode", file=sys.stderr)
sys.exit(1)
output_format = OutputFormat(args.output if hasattr(args, "output") else "text")
try:
final_response = run_programmatic(
config=config,
prompt=programmatic_prompt or "",
max_turns=args.max_turns,
max_price=args.max_price,
max_session_tokens=args.max_tokens,
output_format=output_format,
previous_messages=loaded_session[0] if loaded_session else None,
agent_name=initial_agent_name,
teleport=args.teleport and config.vibe_code_enabled,
headless=True,
hook_config_result=hook_config_result,
)
if final_response:
print(final_response)
sys.exit(0)
except ConversationLimitException as e:
print(e, file=sys.stderr)
sys.exit(1)
except TeleportError as e:
print(f"Teleport error: {e}", file=sys.stderr)
sys.exit(1)
except (RuntimeError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
def _maybe_run_startup_update_prompt(
config: VibeConfig, repository: UpdateCacheRepository
) -> None:
if not config.enable_update_checks:
return
try:
latest_version = asyncio.run(
get_pending_update_from_cache(repository, __version__)
)
except OSError as exc:
logger.debug("Failed to read pending update from cache", exc_info=exc)
return
if latest_version is None:
return
result = ask_update_prompt(__version__, latest_version, theme=config.theme)
match result:
case UpdatePromptResult.CONTINUE:
try:
asyncio.run(mark_update_as_dismissed(repository, latest_version))
except OSError as exc:
logger.debug("Failed to persist dismissed update", exc_info=exc)
return
case UpdatePromptResult.QUIT:
sys.exit(0)
case UpdatePromptResult.UPDATED:
rprint(
f"[green]✔ Vibe was updated from {__version__} to "
f"{latest_version}.[/]\n Run [bold]vibe[/] to start using the "
"new version."
)
sys.exit(0)
case UpdatePromptResult.UPDATE_FAILED:
rprint("[red]✗ Vibe could not be updated automatically.[/]")
sys.exit(1)
def run_cli(args: argparse.Namespace) -> None:
load_dotenv_values()
bootstrap_config_files()
@ -194,6 +290,11 @@ def run_cli(args: argparse.Namespace) -> None:
try:
is_interactive = args.prompt is None
config = load_config_or_exit(interactive=is_interactive)
update_cache_repository = FileSystemUpdateCacheRepository()
if is_interactive:
_maybe_run_startup_update_prompt(config, update_cache_repository)
initial_agent_name = get_initial_agent_name(args, config)
hook_config_result = load_hooks_from_fs(config)
setup_tracing(config)
@ -204,50 +305,7 @@ def run_cli(args: argparse.Namespace) -> None:
loaded_session = load_session(args, config)
stdin_prompt = get_prompt_from_stdin()
if args.prompt is not None:
warn_if_workdir_trust_is_unset()
config.disabled_tools = [
*config.disabled_tools,
"ask_user_question",
"exit_plan_mode",
]
programmatic_prompt = args.prompt or stdin_prompt
if not programmatic_prompt:
print(
"Error: No prompt provided for programmatic mode", file=sys.stderr
)
sys.exit(1)
output_format = OutputFormat(
args.output if hasattr(args, "output") else "text"
)
try:
final_response = run_programmatic(
config=config,
prompt=programmatic_prompt or "",
max_turns=args.max_turns,
max_price=args.max_price,
max_session_tokens=args.max_tokens,
output_format=output_format,
previous_messages=loaded_session[0] if loaded_session else None,
agent_name=initial_agent_name,
teleport=args.teleport and config.vibe_code_enabled,
headless=True,
hook_config_result=hook_config_result,
)
if final_response:
print(final_response)
sys.exit(0)
except ConversationLimitException as e:
print(e, file=sys.stderr)
sys.exit(1)
except TeleportError as e:
print(f"Teleport error: {e}", file=sys.stderr)
sys.exit(1)
except (RuntimeError, ValueError) as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
else:
if is_interactive:
try:
agent_loop = AgentLoop(
config,
@ -266,6 +324,7 @@ def run_cli(args: argparse.Namespace) -> None:
run_textual_ui(
agent_loop=agent_loop,
update_cache_repository=update_cache_repository,
startup=StartupOptions(
initial_prompt=args.initial_prompt or stdin_prompt,
teleport_on_start=args.teleport,
@ -273,6 +332,15 @@ def run_cli(args: argparse.Namespace) -> None:
is_resuming_session=loaded_session is not None,
),
)
else:
_run_programmatic_mode(
args=args,
config=config,
initial_agent_name=initial_agent_name,
hook_config_result=hook_config_result,
loaded_session=loaded_session,
stdin_prompt=stdin_prompt,
)
except (KeyboardInterrupt, EOFError):
rprint("\n[dim]Bye![/]")

View file

@ -112,7 +112,6 @@ from vibe.cli.textual_ui.windowing import (
sync_backfill_state,
)
from vibe.cli.update_notifier import (
FileSystemUpdateCacheRepository,
PyPIUpdateGateway,
UpdateCacheRepository,
UpdateError,
@ -122,7 +121,6 @@ from vibe.cli.update_notifier import (
mark_version_as_seen,
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.cli.vscode_extension_promo import (
@ -3270,48 +3268,19 @@ class VibeApp(App): # noqa: PLR0904
asyncio.create_task(self._check_update(), name="version-update-check")
async def _check_update(self) -> None:
try:
if self._update_notifier is None or self._update_cache_repository is None:
return
if self._update_notifier is None or self._update_cache_repository is None:
return
update_availability = await get_update_if_available(
try:
await get_update_if_available(
update_notifier=self._update_notifier,
current_version=self._current_version,
update_cache_repository=self._update_cache_repository,
)
except UpdateError as error:
self.notify(
error.message,
title="Update check failed",
severity="warning",
timeout=10,
)
return
except UpdateError as exc:
logger.warning("Update check failed", exc_info=exc)
except Exception as exc:
logger.debug("Version update check failed", exc_info=exc)
return
if update_availability is None or not update_availability.should_notify:
return
update_message_prefix = (
f"{self._current_version} => {update_availability.latest_version}"
)
if self.config.enable_auto_update and await do_update():
self.notify(
f"{update_message_prefix}\nVibe was updated successfully. Please restart to use the new version.",
title="Update successful",
severity="information",
timeout=float("inf"),
)
return
message = f"{update_message_prefix}\nPlease update mistral-vibe with your package manager"
self.notify(
message, title="Update available", severity="information", timeout=10
)
logger.debug("Update check failed", exc_info=exc)
def action_copy_selection(self) -> None:
copied_text = copy_selection_to_clipboard(self, show_toast=False)
@ -3364,12 +3333,13 @@ class VibeApp(App): # noqa: PLR0904
def run_textual_ui(
agent_loop: AgentLoop, startup: StartupOptions | None = None
agent_loop: AgentLoop,
update_cache_repository: UpdateCacheRepository,
startup: StartupOptions | None = None,
) -> None:
from vibe.cli.stderr_guard import stderr_guard
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
update_cache_repository = FileSystemUpdateCacheRepository()
plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url)
vscode_extension_promo_repository = FileSystemVscodeExtensionPromoRepository()
vscode_extension_promo = VscodeExtensionPromo(

View file

@ -19,7 +19,9 @@ from vibe.cli.update_notifier.ports.update_gateway import (
from vibe.cli.update_notifier.update import (
UpdateAvailability,
UpdateError,
get_pending_update_from_cache,
get_update_if_available,
mark_update_as_dismissed,
)
from vibe.cli.update_notifier.whats_new import (
load_whats_new_content,
@ -40,8 +42,10 @@ __all__ = [
"UpdateGateway",
"UpdateGatewayCause",
"UpdateGatewayError",
"get_pending_update_from_cache",
"get_update_if_available",
"load_whats_new_content",
"mark_update_as_dismissed",
"mark_version_as_seen",
"should_show_whats_new",
]

View file

@ -19,12 +19,16 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path
self._cache_file = self._base_path / "cache.toml"
self._legacy_json = self._base_path / "update_cache.json"
self._cached: UpdateCache | None = None
self._loaded = False
async def get(self) -> UpdateCache | None:
if self._loaded:
return self._cached
data = await asyncio.to_thread(self._read_section)
if data is None:
return None
return self._parse(data)
self._cached = self._parse(data) if data is not None else None
self._loaded = True
return self._cached
async def set(self, update_cache: UpdateCache) -> None:
payload: dict[str, str | int] = {
@ -33,7 +37,11 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
}
if update_cache.seen_whats_new_version is not None:
payload["seen_whats_new_version"] = update_cache.seen_whats_new_version
if update_cache.dismissed_version is not None:
payload["dismissed_version"] = update_cache.dismissed_version
await asyncio.to_thread(write_cache, self._cache_file, _CACHE_SECTION, payload)
self._cached = update_cache
self._loaded = True
def _read_section(self) -> dict | None:
cache = read_cache(self._cache_file)
@ -58,20 +66,22 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
latest_version = data.get("latest_version")
stored_at_timestamp = data.get("stored_at_timestamp")
seen_whats_new_version = data.get("seen_whats_new_version")
dismissed_version = data.get("dismissed_version")
if not isinstance(latest_version, str) or not isinstance(
stored_at_timestamp, int
):
return None
if (
not isinstance(seen_whats_new_version, str)
and seen_whats_new_version is not None
):
if not isinstance(seen_whats_new_version, str):
seen_whats_new_version = None
if not isinstance(dismissed_version, str):
dismissed_version = None
return UpdateCache(
latest_version=latest_version,
stored_at_timestamp=stored_at_timestamp,
seen_whats_new_version=seen_whats_new_version,
dismissed_version=dismissed_version,
)

View file

@ -9,6 +9,7 @@ class UpdateCache:
latest_version: str
stored_at_timestamp: int
seen_whats_new_version: str | None = None
dismissed_version: str | None = None
class UpdateCacheRepository(Protocol):

View file

@ -2,7 +2,8 @@ from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from contextlib import suppress
from dataclasses import dataclass, replace
import time
from packaging.version import InvalidVersion, Version
@ -74,18 +75,56 @@ async def _write_update_cache(
version: str,
get_current_timestamp: Callable[[], int],
) -> None:
previous = await repository.get()
timestamp = get_current_timestamp()
if previous is None:
await repository.set(
UpdateCache(latest_version=version, stored_at_timestamp=timestamp)
)
return
await repository.set(
UpdateCache(latest_version=version, stored_at_timestamp=get_current_timestamp())
replace(previous, latest_version=version, stored_at_timestamp=timestamp)
)
async def get_pending_update_from_cache(
repository: UpdateCacheRepository, current_version: str
) -> str | None:
current = _parse_version(current_version)
if current is None:
return None
cache = await repository.get()
if cache is None:
return None
latest = _parse_version(cache.latest_version)
if latest is None or latest <= current:
return None
if cache.dismissed_version == cache.latest_version:
return None
return cache.latest_version
async def mark_update_as_dismissed(
repository: UpdateCacheRepository, version: str
) -> None:
cache = await repository.get()
if cache is None:
return
await repository.set(replace(cache, dismissed_version=version))
async def get_update_if_available(
update_notifier: UpdateGateway,
current_version: str,
update_cache_repository: UpdateCacheRepository,
get_current_timestamp: Callable[[], int] = lambda: int(time.time()),
) -> UpdateAvailability | None:
if not (current := _parse_version(current_version)):
current = _parse_version(current_version)
if current is None:
return None
if update_cache := await update_cache_repository.get():
@ -133,7 +172,24 @@ async def do_update() -> bool:
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.DEVNULL,
)
await process.wait()
try:
await process.wait()
except asyncio.CancelledError:
await _terminate(process)
raise
if process.returncode == 0:
return True
return False
async def _terminate(process: asyncio.subprocess.Process) -> None:
if process.returncode is not None:
return
with suppress(ProcessLookupError):
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=2.0)
except TimeoutError:
with suppress(ProcessLookupError):
process.kill()
await process.wait()

View file

@ -1,5 +1,6 @@
from __future__ import annotations
from dataclasses import replace
import time
from vibe import VIBE_ROOT
@ -40,11 +41,5 @@ async def mark_version_as_seen(version: str, repository: UpdateCacheRepository)
seen_whats_new_version=version,
)
)
else:
await repository.set(
UpdateCache(
latest_version=cache.latest_version,
stored_at_timestamp=cache.stored_at_timestamp,
seen_whats_new_version=version,
)
)
return
await repository.set(replace(cache, seen_whats_new_version=version))

View file

@ -67,7 +67,10 @@ from vibe.core.session.session_logger import SessionLogger
from vibe.core.session.session_migration import migrate_sessions_entrypoint
from vibe.core.skills.manager import SkillManager
from vibe.core.system_prompt import get_universal_system_prompt
from vibe.core.telemetry.build_metadata import build_request_metadata
from vibe.core.telemetry.build_metadata import (
build_attachment_counts,
build_request_metadata,
)
from vibe.core.telemetry.send import TelemetryClient
from vibe.core.telemetry.types import (
EntrypointMetadata,
@ -748,6 +751,10 @@ class AgentLoop: # noqa: PLR0904
None,
)
def set_max_turns(self, max_turns: int) -> None:
self._max_turns = max_turns
self._setup_middleware()
def _setup_middleware(self) -> None:
"""Configure middleware pipeline for this conversation."""
self.middleware_pipeline.clear()
@ -1373,6 +1380,9 @@ class AgentLoop: # noqa: PLR0904
else 0,
call_type=backend_metadata.call_type,
message_id=backend_metadata.message_id,
attachment_counts=build_attachment_counts(
last_user_message, supports_images=active_model.supports_images
),
)
try:
@ -1436,6 +1446,9 @@ class AgentLoop: # noqa: PLR0904
else 0,
call_type=backend_metadata.call_type,
message_id=backend_metadata.message_id,
attachment_counts=build_attachment_counts(
last_user_message, supports_images=active_model.supports_images
),
)
try:

View file

@ -531,7 +531,6 @@ class VibeConfig(BaseSettings):
include_project_context: bool = True
include_prompt_detail: bool = True
enable_update_checks: bool = True
enable_auto_update: bool = True
enable_notifications: bool = True
enable_system_trust_store: bool = False
api_timeout: float = DEFAULT_API_TIMEOUT

View file

@ -76,8 +76,7 @@ bypass_tool_permissions = false # Skip tool approval prompts
system_prompt_id = "cli" # System prompt: "cli", "lean", or custom .md filename
compaction_prompt_id = "compact" # Compaction prompt: built-in "compact" or custom .md filename
enable_telemetry = true
enable_update_checks = true
enable_auto_update = true
enable_update_checks = true # Daily PyPI check; prompts on next launch when a newer release exists
enable_notifications = true
enable_system_trust_store = false # Use OS trust store for outbound HTTPS
api_timeout = 720.0 # API request timeout in seconds

View file

@ -4,11 +4,13 @@ from typing import Any, cast
from vibe.core.telemetry.types import (
AgentEntrypoint,
AttachmentKind,
EntrypointMetadata,
TelemetryBaseMetadata,
TelemetryCallType,
TelemetryRequestMetadata,
)
from vibe.core.types import LLMMessage
def build_base_metadata(
@ -52,6 +54,17 @@ def build_request_metadata(
)
def build_attachment_counts(
message: LLMMessage | None, *, supports_images: bool
) -> dict[AttachmentKind, int]:
if message is None:
return {}
counts: dict[AttachmentKind, int] = {}
if supports_images and message.images:
counts[AttachmentKind.IMAGE] = len(message.images)
return counts
def build_entrypoint_metadata(
*,
agent_entrypoint: AgentEntrypoint,

View file

@ -15,6 +15,7 @@ from vibe.core.logger import logger
from vibe.core.telemetry.build_metadata import build_base_metadata
from vibe.core.telemetry.types import (
AgentEntrypoint,
AttachmentKind,
EntrypointMetadata,
TelemetryCallType,
TeleportCompletedPayload,
@ -303,6 +304,7 @@ class TelemetryClient:
nb_prompt_chars: int,
call_type: TelemetryCallType,
message_id: str | None = None,
attachment_counts: dict[AttachmentKind, int] | None = None,
) -> None:
payload = {
"model": model,
@ -312,6 +314,11 @@ class TelemetryClient:
"call_source": "vibe_code",
"call_type": call_type,
"message_id": message_id,
"attachment_counts": {
kind.value: count
for kind, count in (attachment_counts or {}).items()
if count > 0
},
}
self.send_telemetry_event("vibe.request_sent", payload)

View file

@ -1,10 +1,15 @@
from __future__ import annotations
from enum import StrEnum
from typing import Literal, TypedDict
from pydantic import BaseModel
class AttachmentKind(StrEnum):
IMAGE = "image"
class ClientMetadata(BaseModel):
name: str
version: str

View file

@ -17,7 +17,7 @@ Use the `bash` tool to run one-off shell commands.
- `sed -n '100,200p' filename` → Use `read(file_path="filename", offset=100, limit=101)`
- `less`, `more`, `vim`, `nano` → Use `read` with offset/limit for navigation
- `echo "content" > file` → Use `write_file(path="file", content="content")`
- `echo "content" >> existing_file` → Read first, then use `search_replace` to append (write_file refuses to overwrite)
- `echo "content" >> existing_file` → Read first, then use `edit` to append (write_file refuses to overwrite)
**Search Operations - DO NOT USE:**
- `grep -r "pattern" .` → Use `grep(pattern="pattern", path=".")`

View file

@ -7,7 +7,7 @@ Use `write_file` to create a new file.
**BEHAVIOR:**
- `write_file` can ONLY create new files.
- If the file already exists, the tool returns an error. Use `search_replace` to edit existing files.
- If the file already exists, the tool returns an error. Use `edit` to modify existing files.
- Parent directories are created automatically if they don't exist.
**BEST PRACTICES:**

View file

@ -49,7 +49,7 @@ class WriteFile(
ToolUIData[WriteFileArgs, WriteFileResult],
):
description: ClassVar[str] = (
"Create a UTF-8 file. Fails if the file already exists; use search_replace to edit."
"Create a UTF-8 file. Fails if the file already exists; use edit to modify."
)
@classmethod
@ -115,7 +115,7 @@ class WriteFile(
if file_path.exists():
raise ToolError(
f"File '{file_path}' already exists. Use search_replace to edit it."
f"File '{file_path}' already exists. Use edit to modify it."
)
if self.config.create_parent_dirs:
@ -133,7 +133,7 @@ class WriteFile(
await f.write(args.content)
except FileExistsError as e:
raise ToolError(
f"File '{file_path}' already exists. Use search_replace to edit it."
f"File '{file_path}' already exists. Use edit to modify it."
) from e
except Exception as e:
raise ToolError(f"Error writing {file_path}: {e}") from e

View file

@ -281,7 +281,7 @@ class TrustFolderApp(App):
self.exit()
def run_trust_dialog(self) -> TrustDecision | None:
self.run()
self.run(inline=True)
if self._quit_without_saving:
raise TrustDialogQuitException()
return self._result

View file

@ -0,0 +1,8 @@
from __future__ import annotations
from vibe.setup.update_prompt.update_prompt_dialog import (
UpdatePromptResult,
ask_update_prompt,
)
__all__ = ["UpdatePromptResult", "ask_update_prompt"]

View file

@ -0,0 +1,221 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
from enum import StrEnum, auto
from typing import Any, ClassVar
from textual import events
from textual.app import App, ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import CenterMiddle, Horizontal
from textual.message import Message
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.cli.update_notifier.update import do_update
from vibe.core.logger import logger
class UpdatePromptResult(StrEnum):
CONTINUE = auto()
UPDATED = auto()
UPDATE_FAILED = auto()
QUIT = auto()
class UpdateChoice(StrEnum):
UPDATE = auto()
CONTINUE = auto()
_CHOICE_LABELS: dict[UpdateChoice, str] = {
UpdateChoice.UPDATE: "Update now",
UpdateChoice.CONTINUE: "Continue with current version",
}
class UpdatePromptDialog(CenterMiddle):
can_focus = True
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("left", "move_left", "Left", show=False),
Binding("right", "move_right", "Right", show=False),
Binding("enter", "select", "Select", show=False),
]
class Selected(Message):
def __init__(self, choice: UpdateChoice) -> None:
super().__init__()
self.choice = choice
class UpdateFinished(Message):
def __init__(self, succeeded: bool) -> None:
super().__init__()
self.succeeded = succeeded
def __init__(
self, current_version: str, latest_version: str, **kwargs: Any
) -> None:
super().__init__(**kwargs)
self.current_version = current_version
self.latest_version = latest_version
self.selected: UpdateChoice = UpdateChoice.UPDATE
self._option_widgets: dict[UpdateChoice, NoMarkupStatic] = {}
self._is_updating = False
def compose(self) -> ComposeResult:
with CenterMiddle(id="update-dialog"):
yield NoMarkupStatic(
"A new Vibe release is available", id="update-dialog-title"
)
yield NoMarkupStatic(
f"{self.current_version}{self.latest_version}",
id="update-dialog-version",
)
with Horizontal(id="update-options-container"):
for choice in UpdateChoice:
widget = NoMarkupStatic(
f" {_CHOICE_LABELS[choice]}", classes="update-option"
)
self._option_widgets[choice] = widget
yield widget
yield NoMarkupStatic(
"← → navigate Enter select", classes="update-dialog-help"
)
yield PetitChat(id="update-dialog-spinner")
yield NoMarkupStatic("Updating mistral-vibe…", id="update-dialog-status")
async def on_mount(self) -> None:
spinner = self.query_one("#update-dialog-spinner", PetitChat)
spinner.display = False
status = self.query_one("#update-dialog-status", NoMarkupStatic)
status.display = False
self._refresh_options()
self.focus()
def _refresh_options(self) -> None:
for choice in UpdateChoice:
widget = self._option_widgets[choice]
cursor = " " if choice == self.selected else " "
widget.update(f"{cursor}{_CHOICE_LABELS[choice]}")
widget.remove_class("update-option--active")
widget.remove_class("update-option--inactive")
widget.add_class(
"update-option--active"
if choice == self.selected
else "update-option--inactive"
)
def _move(self, delta: int) -> None:
if self._is_updating:
return
choices = list(UpdateChoice)
idx = (choices.index(self.selected) + delta) % len(choices)
self.selected = choices[idx]
self._refresh_options()
def action_move_left(self) -> None:
self._move(-1)
def action_move_right(self) -> None:
self._move(1)
def action_select(self) -> None:
if self._is_updating:
return
self.post_message(self.Selected(self.selected))
async def enter_updating_state(self) -> None:
self._is_updating = True
for widget in self._option_widgets.values():
widget.display = False
self.query_one("#update-options-container", Horizontal).display = False
self.query_one(".update-dialog-help", NoMarkupStatic).display = False
self.query_one("#update-dialog-spinner", PetitChat).display = True
self.query_one("#update-dialog-status", NoMarkupStatic).display = True
try:
succeeded = await do_update()
except Exception as exc:
logger.warning("do_update raised unexpectedly", exc_info=exc)
succeeded = False
self.post_message(self.UpdateFinished(succeeded=succeeded))
def on_blur(self, _: events.Blur) -> None:
if self._is_updating:
return
self.call_after_refresh(self.focus)
class UpdatePromptApp(App[UpdatePromptResult]):
CSS_PATH = "update_prompt_dialog.tcss"
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+q", "quit_prompt", "Quit", show=False, priority=True),
Binding("ctrl+c", "quit_prompt", "Quit", show=False, priority=True),
]
def __init__(
self,
current_version: str,
latest_version: str,
theme: str | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
self.current_version = current_version
self.latest_version = latest_version
self._theme_name = theme
self._dialog: UpdatePromptDialog | None = None
self._update_task: asyncio.Task[None] | None = None
def on_mount(self) -> None:
if self._theme_name is not None:
self.theme = self._theme_name
def compose(self) -> ComposeResult:
self._dialog = UpdatePromptDialog(self.current_version, self.latest_version)
yield self._dialog
async def action_quit_prompt(self) -> None:
if self._update_task is not None and not self._update_task.done():
self._update_task.cancel()
with suppress(asyncio.CancelledError, Exception):
await self._update_task
self.exit(UpdatePromptResult.QUIT)
def on_update_prompt_dialog_selected(
self, message: UpdatePromptDialog.Selected
) -> None:
match message.choice:
case UpdateChoice.UPDATE:
if self._dialog is None or self._update_task is not None:
return
self._update_task = asyncio.create_task(
self._dialog.enter_updating_state()
)
case UpdateChoice.CONTINUE:
self.exit(UpdatePromptResult.CONTINUE)
def on_update_prompt_dialog_update_finished(
self, message: UpdatePromptDialog.UpdateFinished
) -> None:
self.exit(
UpdatePromptResult.UPDATED
if message.succeeded
else UpdatePromptResult.UPDATE_FAILED
)
def ask_update_prompt(
current_version: str, latest_version: str, theme: str | None = None
) -> UpdatePromptResult:
app = UpdatePromptApp(current_version, latest_version, theme=theme)
return app.run(inline=True) or UpdatePromptResult.CONTINUE

View file

@ -0,0 +1,85 @@
Screen {
align: center middle;
background: transparent 80%;
}
#update-dialog {
max-width: 70;
border: round $border-blurred;
background: transparent;
height: auto;
padding: 1;
padding-left: 5;
padding-right: 5;
}
#update-dialog-title {
width: 100%;
height: auto;
text-style: bold;
color: $primary;
text-align: center;
margin-bottom: 1;
}
#update-dialog-version {
width: 100%;
height: auto;
color: $foreground;
text-align: center;
margin-bottom: 1;
}
#update-options-container {
width: 100%;
height: auto;
align: center middle;
margin-bottom: 1;
}
.update-option {
height: auto;
width: auto;
color: $foreground;
margin: 0 3;
content-align: center middle;
}
.update-option--active {
color: $foreground;
text-style: bold;
}
.update-option--inactive {
color: $foreground;
}
.update-dialog-help {
width: 100%;
height: auto;
color: $text-muted;
text-align: center;
&:ansi {
text-style: dim;
}
}
#update-dialog-spinner {
width: 100%;
height: auto;
margin-bottom: 1;
align: center middle;
}
.petit-chat {
color: $foreground;
width: 12;
}
#update-dialog-status {
width: 100%;
height: auto;
color: $foreground;
text-align: center;
}

View file

@ -1,4 +1,3 @@
# What's new in v2.14.0
# What's new in v2.14.1
- **Image attachments**: Drop an image into the chat input — or `@`-mention it — and send it to vision-capable models.
- **New read and edit tools**: `read` and `edit` replace `read_file` and `search_replace`. Your config has been migrated to these new tool names.
- **Update prompt at startup**: Vibe now asks you to install a pending update before starting the session, instead of just notifying you.