diff --git a/AGENTS.md b/AGENTS.md
index 523a2fb..3eb593a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,7 +33,7 @@ Always go through `uv` — never invoke bare `python` or `pip`.
- Use f-strings, comprehensions, and context managers; follow PEP 8.
- Enums: `StrEnum` / `IntEnum` with `auto()` and UPPERCASE members. For type-mixing, the mix-in type comes before `Enum` in the bases. Add methods or `@property` rather than parallel lookup tables.
- Write declarative, minimalist code: express intent, drop boilerplate.
-- Never call a private method from outside of it's class
+- Never call a private method from outside of its class in production code. Accessing private methods in tests is acceptable.
- Avoid comments and docstrings, except for when there's a hard to spot corner case
## Typing & imports
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dba0b66..772d81f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.14.1] - 2026-06-08
+
+### Added
+
+- `/teleport` slash command exposed over ACP, mirroring the TUI command for IDE integrations
+- Startup prompt to install a pending Vibe update before continuing the session
+
+### Fixed
+
+- JetBrains IDEs no longer trigger a preemptive auth prompt over ACP and no longer drop terminal arguments
+- Initial ACP slash-command advertisement is delayed so Zed registers commands like `/help` instead of rejecting them
+- Built-in tool prompts no longer reference the removed `search_replace` name and now point to `edit`
+
+
## [2.14.0] - 2026-06-04
### Added
diff --git a/README.md b/README.md
index e9f3f94..26bf108 100644
--- a/README.md
+++ b/README.md
@@ -644,14 +644,12 @@ Each path is implicitly trusted (no trust prompt) and contributes its `AGENTS.md
### Update Settings
-#### Auto-Update
+Vibe checks PyPI at most once per day during a session. When a newer version is found, the next launch shows an update prompt before opening the chat, offering to either update immediately (via `uv tool upgrade mistral-vibe` or `brew upgrade mistral-vibe`) or continue with the current version.
-Vibe includes an automatic update feature that keeps your installation current. This is enabled by default.
-
-To disable auto-updates, add this to your `config.toml`:
+To disable the daily check entirely, add this to your `config.toml`:
```toml
-enable_auto_update = false
+enable_update_checks = false
```
### Notification Settings
diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml
index a110bf3..e66310a 100644
--- a/distribution/zed/extension.toml
+++ b/distribution/zed/extension.toml
@@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
-version = "2.14.0"
+version = "2.14.1"
schema_version = 1
authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe"
@@ -11,21 +11,21 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-darwin-aarch64-2.14.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.1/vibe-acp-darwin-aarch64-2.14.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-darwin-x86_64-2.14.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.1/vibe-acp-darwin-x86_64-2.14.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-linux-aarch64-2.14.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.1/vibe-acp-linux-aarch64-2.14.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-linux-x86_64-2.14.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.1/vibe-acp-linux-x86_64-2.14.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.0/vibe-acp-windows-x86_64-2.14.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.14.1/vibe-acp-windows-x86_64-2.14.1.zip"
cmd = "./vibe-acp.exe"
diff --git a/pyproject.toml b/pyproject.toml
index 7789d53..882832f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
-version = "2.14.0"
+version = "2.14.1"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/tests/acp/test_commands.py b/tests/acp/test_commands.py
index 585421d..136a564 100644
--- a/tests/acp/test_commands.py
+++ b/tests/acp/test_commands.py
@@ -3,10 +3,21 @@
from __future__ import annotations
import asyncio
+from collections.abc import AsyncGenerator
from pathlib import Path
+from typing import cast
from unittest.mock import AsyncMock, patch
-from acp.schema import AgentMessageChunk, AvailableCommandsUpdate, TextContentBlock
+from acp.schema import (
+ AgentMessageChunk,
+ AllowedOutcome,
+ AvailableCommandsUpdate,
+ DeniedOutcome,
+ RequestPermissionResponse,
+ TextContentBlock,
+ ToolCallProgress,
+ ToolCallStart,
+)
import pytest
from tests.acp.conftest import _create_acp_agent
@@ -14,8 +25,20 @@ from tests.skills.conftest import create_skill
from tests.stubs.fake_backend import FakeBackend
from tests.stubs.fake_client import FakeClient
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
+from vibe.acp.teleport import TELEPORT_PUSH_OPTION_ID
from vibe.core.agent_loop import AgentLoop
from vibe.core.config import SessionLoggingConfig
+from vibe.core.teleport.errors import ServiceTeleportError
+from vibe.core.teleport.teleport import TeleportService
+from vibe.core.teleport.types import (
+ TeleportCheckingGitEvent,
+ TeleportCompleteEvent,
+ TeleportPushingEvent,
+ TeleportPushRequiredEvent,
+ TeleportPushResponseEvent,
+ TeleportStartingWorkflowEvent,
+)
+from vibe.core.types import LLMMessage, Role
def _get_client(agent: VibeAcpAgentLoop) -> FakeClient:
@@ -32,14 +55,37 @@ def _get_message_texts(agent: VibeAcpAgentLoop) -> list[str]:
]
+def _get_tool_updates(
+ agent: VibeAcpAgentLoop,
+) -> list[ToolCallStart | ToolCallProgress]:
+ return [
+ u.update
+ for u in _get_client(agent)._session_updates
+ if isinstance(u.update, (ToolCallStart, ToolCallProgress))
+ ]
+
+
+def _set_teleport_service(agent_loop: AgentLoop, service: object) -> None:
+ agent_loop._teleport_service = cast(TeleportService, service)
+
+
async def _new_session_and_clear(agent: VibeAcpAgentLoop) -> str:
"""Create a new session, drain the startup updates, return session_id."""
resp = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
- await asyncio.sleep(0) # let background tasks (available_commands) complete
+ await _wait_for_available_commands(agent)
_get_client(agent)._session_updates.clear()
return resp.session_id
+async def _wait_for_available_commands(agent: VibeAcpAgentLoop) -> None:
+ for _ in range(50):
+ updates = _get_client(agent)._session_updates
+ if any(isinstance(u.update, AvailableCommandsUpdate) for u in updates):
+ return
+ await asyncio.sleep(0.01)
+ raise TimeoutError("available commands update was not sent")
+
+
async def _prompt(agent: VibeAcpAgentLoop, session_id: str, text: str):
return await agent.prompt(
prompt=[TextContentBlock(type="text", text=text)], session_id=session_id
@@ -51,6 +97,7 @@ def _make_patched_agent_loop(
*,
skill_paths: list[Path] | None = None,
session_logging: SessionLoggingConfig | None = None,
+ vibe_code_enabled: bool | None = None,
) -> type[AgentLoop]:
"""Create a PatchedAgentLoop class that injects config overrides."""
config_updates: dict = {}
@@ -58,6 +105,8 @@ def _make_patched_agent_loop(
config_updates["skill_paths"] = skill_paths
if session_logging is not None:
config_updates["session_logging"] = session_logging
+ if vibe_code_enabled is not None:
+ config_updates["vibe_code_enabled"] = vibe_code_enabled
class PatchedAgentLoop(AgentLoop):
def __init__(self, *args, **kwargs) -> None:
@@ -75,6 +124,13 @@ def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
return _create_acp_agent()
+@pytest.fixture
+def acp_agent_loop_vibe_code_disabled(backend: FakeBackend) -> VibeAcpAgentLoop:
+ patched = _make_patched_agent_loop(backend, vibe_code_enabled=False)
+ patch("vibe.acp.acp_agent_loop.AgentLoop", side_effect=patched).start()
+ return _create_acp_agent()
+
+
@pytest.fixture
def skills_dir(tmp_path: Path) -> Path:
d = tmp_path / "skills"
@@ -159,6 +215,264 @@ class TestHandleCompact:
mock_compact.assert_called_once()
+class TestHandleTeleport:
+ @pytest.mark.asyncio
+ async def test_available_commands_includes_teleport(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
+ await _wait_for_available_commands(acp_agent_loop)
+
+ available = [
+ u
+ for u in _get_client(acp_agent_loop)._session_updates
+ if isinstance(u.update, AvailableCommandsUpdate)
+ ]
+ cmd_names = [c.name for c in available[0].update.available_commands]
+
+ assert "teleport" in cmd_names
+
+ @pytest.mark.asyncio
+ async def test_teleport_hidden_when_vibe_code_disabled(
+ self, acp_agent_loop_vibe_code_disabled: VibeAcpAgentLoop
+ ) -> None:
+ agent = acp_agent_loop_vibe_code_disabled
+ await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
+ await _wait_for_available_commands(agent)
+
+ available = [
+ u
+ for u in _get_client(agent)._session_updates
+ if isinstance(u.update, AvailableCommandsUpdate)
+ ]
+ cmd_names = [c.name for c in available[0].update.available_commands]
+
+ assert "teleport" not in cmd_names
+
+ @pytest.mark.asyncio
+ async def test_teleport_without_history_replies_with_no_history(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_id = await _new_session_and_clear(acp_agent_loop)
+
+ response = await _prompt(acp_agent_loop, session_id, "/teleport")
+
+ assert response.stop_reason == "end_turn"
+ assert response.field_meta == {
+ "tool_name": "teleport",
+ "teleport": {"status": "no_history"},
+ }
+ assert _get_message_texts(acp_agent_loop) == [
+ "No conversation history to teleport."
+ ]
+ assert _get_tool_updates(acp_agent_loop) == []
+
+ @pytest.mark.asyncio
+ async def test_teleport_sends_tool_updates_and_structured_url(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ class FakeTeleportService:
+ prompts: list[str]
+
+ def __init__(self) -> None:
+ self.prompts = []
+
+ async def __aenter__(self) -> FakeTeleportService:
+ return self
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ async def execute(self, prompt: str) -> AsyncGenerator[object, object]:
+ self.prompts.append(prompt)
+ yield TeleportCheckingGitEvent()
+ yield TeleportStartingWorkflowEvent()
+ yield TeleportCompleteEvent(url="https://chat.example.com/code/1/2")
+
+ session_id = await _new_session_and_clear(acp_agent_loop)
+ session = acp_agent_loop.sessions[session_id]
+ session.agent_loop.messages.append(
+ LLMMessage(role=Role.user, content="continue this task")
+ )
+ service = FakeTeleportService()
+ _set_teleport_service(session.agent_loop, service)
+
+ response = await _prompt(acp_agent_loop, session_id, "/teleport ignored")
+
+ assert response.stop_reason == "end_turn"
+ assert response.field_meta == {
+ "tool_name": "teleport",
+ "teleport": {
+ "status": "completed",
+ "url": "https://chat.example.com/code/1/2",
+ },
+ }
+ assert service.prompts == ["continue this task (continue)"]
+ assert _get_message_texts(acp_agent_loop) == []
+
+ tool_updates = _get_tool_updates(acp_agent_loop)
+ assert isinstance(tool_updates[0], ToolCallStart)
+ assert tool_updates[0].title == "Teleporting session to Vibe Code Web..."
+ assert tool_updates[-1].status == "completed"
+ assert tool_updates[-1].title == "Teleported to Vibe Code Web"
+ assert [update.field_meta for update in tool_updates] == [
+ {"tool_name": "teleport", "teleport": {"status": "starting"}},
+ {"tool_name": "teleport", "teleport": {"status": "preparing_workspace"}},
+ {"tool_name": "teleport", "teleport": {"status": "starting_workflow"}},
+ {
+ "tool_name": "teleport",
+ "teleport": {
+ "status": "completed",
+ "url": "https://chat.example.com/code/1/2",
+ },
+ },
+ ]
+
+ @pytest.mark.asyncio
+ async def test_teleport_push_required_requests_permission(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ class FakeTeleportService:
+ response: TeleportPushResponseEvent | None
+
+ def __init__(self) -> None:
+ self.response = None
+
+ async def __aenter__(self) -> FakeTeleportService:
+ return self
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ async def execute(self, prompt: str) -> AsyncGenerator[object, object]:
+ yield TeleportCheckingGitEvent()
+ response = yield TeleportPushRequiredEvent(
+ unpushed_count=2, branch_not_pushed=False
+ )
+ self.response = cast(TeleportPushResponseEvent, response)
+ yield TeleportPushingEvent()
+ yield TeleportCompleteEvent(url="https://chat.example.com/code/1/2")
+
+ session_id = await _new_session_and_clear(acp_agent_loop)
+ session = acp_agent_loop.sessions[session_id]
+ session.agent_loop.messages.append(
+ LLMMessage(role=Role.user, content="ship it")
+ )
+ service = FakeTeleportService()
+ _set_teleport_service(session.agent_loop, service)
+
+ client = _get_client(acp_agent_loop)
+ client.request_permission = AsyncMock(
+ return_value=RequestPermissionResponse(
+ outcome=AllowedOutcome(
+ outcome="selected", option_id=TELEPORT_PUSH_OPTION_ID
+ )
+ )
+ )
+
+ response = await _prompt(acp_agent_loop, session_id, "/teleport")
+
+ assert response.field_meta == {
+ "tool_name": "teleport",
+ "teleport": {
+ "status": "completed",
+ "url": "https://chat.example.com/code/1/2",
+ },
+ }
+ assert service.response == TeleportPushResponseEvent(approved=True)
+ request_permission = cast(AsyncMock, client.request_permission)
+ request_permission.assert_awaited_once()
+ await_args = request_permission.await_args
+ assert await_args is not None
+ kwargs = await_args.kwargs
+ assert (
+ kwargs["tool_call"].title
+ == "You have 2 unpushed commits. Push to continue?"
+ )
+ assert kwargs["tool_call"].field_meta == {
+ "tool_name": "teleport",
+ "teleport": {
+ "status": "push_required",
+ "unpushedCount": 2,
+ "branchNotPushed": False,
+ },
+ }
+ assert [option.name for option in kwargs["options"]] == [
+ "Push and continue",
+ "Cancel",
+ ]
+ assert [update.field_meta for update in _get_tool_updates(acp_agent_loop)] == [
+ {"tool_name": "teleport", "teleport": {"status": "starting"}},
+ {"tool_name": "teleport", "teleport": {"status": "preparing_workspace"}},
+ {
+ "tool_name": "teleport",
+ "teleport": {
+ "status": "push_required",
+ "unpushedCount": 2,
+ "branchNotPushed": False,
+ },
+ },
+ {"tool_name": "teleport", "teleport": {"status": "syncing_remote"}},
+ {
+ "tool_name": "teleport",
+ "teleport": {
+ "status": "completed",
+ "url": "https://chat.example.com/code/1/2",
+ },
+ },
+ ]
+
+ @pytest.mark.asyncio
+ async def test_teleport_push_denied_marks_tool_call_failed(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ class FakeTeleportService:
+ async def __aenter__(self) -> FakeTeleportService:
+ return self
+
+ async def __aexit__(self, *_args: object) -> None:
+ return None
+
+ async def execute(self, prompt: str) -> AsyncGenerator[object, object]:
+ response = yield TeleportPushRequiredEvent()
+ if (
+ not isinstance(response, TeleportPushResponseEvent)
+ or not response.approved
+ ):
+ raise ServiceTeleportError(
+ "Teleport cancelled: changes not pushed."
+ )
+
+ session_id = await _new_session_and_clear(acp_agent_loop)
+ session = acp_agent_loop.sessions[session_id]
+ session.agent_loop.messages.append(
+ LLMMessage(role=Role.user, content="ship it")
+ )
+ _set_teleport_service(session.agent_loop, FakeTeleportService())
+
+ client = _get_client(acp_agent_loop)
+ client.request_permission = AsyncMock(
+ return_value=RequestPermissionResponse(
+ outcome=DeniedOutcome(outcome="cancelled")
+ )
+ )
+
+ response = await _prompt(acp_agent_loop, session_id, "/teleport")
+
+ assert response.field_meta == {
+ "tool_name": "teleport",
+ "teleport": {"status": "failed"},
+ }
+ failed = _get_tool_updates(acp_agent_loop)[-1]
+ assert failed.status == "failed"
+ assert failed.title == "Teleport failed"
+ assert failed.raw_output == "Teleport cancelled: changes not pushed."
+ assert failed.field_meta == {
+ "tool_name": "teleport",
+ "teleport": {"status": "failed"},
+ }
+
+
class TestHandleReload:
@pytest.mark.asyncio
async def test_reload_calls_reload_with_initial_messages(
@@ -213,6 +527,19 @@ class TestCommandFallthrough:
texts = _get_message_texts(acp_agent_loop)
assert any("Hi" in t for t in texts)
+ @pytest.mark.asyncio
+ async def test_ampersand_message_reaches_agent(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_id = await _new_session_and_clear(acp_agent_loop)
+ response = await _prompt(acp_agent_loop, session_id, "&fix from here")
+
+ assert response.stop_reason == "end_turn"
+ assert response.field_meta is None
+ assert _get_tool_updates(acp_agent_loop) == []
+ texts = _get_message_texts(acp_agent_loop)
+ assert any("Hi" in t for t in texts)
+
class TestAvailableCommandsWithSkills:
@pytest.mark.asyncio
@@ -224,7 +551,7 @@ class TestAvailableCommandsWithSkills:
await acp_agent_loop_with_skills.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
- await asyncio.sleep(0)
+ await _wait_for_available_commands(acp_agent_loop_with_skills)
updates = _get_client(acp_agent_loop_with_skills)._session_updates
available = [
@@ -247,7 +574,7 @@ class TestAvailableCommandsWithSkills:
await acp_agent_loop_with_skills.new_session(
cwd=str(Path.cwd()), mcp_servers=[]
)
- await asyncio.sleep(0)
+ await _wait_for_available_commands(acp_agent_loop_with_skills)
updates = _get_client(acp_agent_loop_with_skills)._session_updates
available = [
diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py
index 9b379e8..06e0771 100644
--- a/tests/acp/test_initialize.py
+++ b/tests/acp/test_initialize.py
@@ -43,9 +43,14 @@ def build_acp_agent_loop(*, provider: ProviderConfig | None = None) -> VibeAcpAg
)
+@pytest.fixture
+def unauthenticated_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
+
+
class TestACPInitialize:
@pytest.mark.asyncio
- async def test_initialize(self) -> None:
+ async def test_initialize(self, unauthenticated_env: None) -> None:
acp_agent_loop = build_acp_agent_loop()
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
@@ -62,7 +67,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
- name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.0"
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.1"
)
assert response.auth_methods is not None
@@ -73,7 +78,9 @@ class TestACPInitialize:
assert auth_method.description == BROWSER_AUTH_DESCRIPTION
@pytest.mark.asyncio
- async def test_initialize_with_terminal_auth(self) -> None:
+ async def test_initialize_with_terminal_auth(
+ self, unauthenticated_env: None
+ ) -> None:
"""Test initialize with terminal-auth capabilities to check it was included."""
acp_agent_loop = build_acp_agent_loop()
client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
@@ -94,7 +101,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
- name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.0"
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.14.1"
)
assert response.auth_methods is not None
@@ -109,6 +116,8 @@ class TestACPInitialize:
assert auth_method.id == "vibe-setup"
assert auth_method.name == "Register your API Key"
assert auth_method.description == "Register your API Key inside Mistral Vibe"
+ assert auth_method.args is not None
+ assert auth_method.args[-1:] == ["--setup"]
assert auth_method.field_meta is not None
assert "terminal-auth" in auth_method.field_meta
terminal_auth_meta = auth_method.field_meta["terminal-auth"]
@@ -118,7 +127,9 @@ class TestACPInitialize:
assert terminal_auth_meta["label"] == "Mistral Vibe Setup"
@pytest.mark.asyncio
- async def test_initialize_with_delegated_browser_auth(self) -> None:
+ async def test_initialize_with_delegated_browser_auth(
+ self, unauthenticated_env: None
+ ) -> None:
acp_agent_loop = build_acp_agent_loop()
client_capabilities = ClientCapabilities(
field_meta={"browser-auth-delegated": True}
@@ -156,3 +167,43 @@ class TestACPInitialize:
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
assert response.auth_methods == []
+
+ @pytest.mark.asyncio
+ async def test_initialize_omits_auth_methods_for_authenticated_jetbrains_client(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-api-key")
+ acp_agent_loop = build_acp_agent_loop()
+ client_capabilities = ClientCapabilities(
+ field_meta={"terminal-auth": True, "browser-auth-delegated": True}
+ )
+ client_info = Implementation(name="JetBrains.PyCharm", version="2026.1.2")
+
+ response = await acp_agent_loop.initialize(
+ protocol_version=PROTOCOL_VERSION,
+ client_capabilities=client_capabilities,
+ client_info=client_info,
+ )
+
+ assert response.auth_methods == []
+
+ @pytest.mark.asyncio
+ async def test_initialize_keeps_auth_methods_for_authenticated_non_jetbrains_client(
+ self, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("MISTRAL_API_KEY", "test-api-key")
+ acp_agent_loop = build_acp_agent_loop()
+ client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
+ client_info = Implementation(name="zed", version="0.999.0")
+
+ response = await acp_agent_loop.initialize(
+ protocol_version=PROTOCOL_VERSION,
+ client_capabilities=client_capabilities,
+ client_info=client_info,
+ )
+
+ assert response.auth_methods is not None
+ assert {method.id for method in response.auth_methods} == {
+ "browser-auth",
+ "vibe-setup",
+ }
diff --git a/tests/acp/test_proxy_setup_acp.py b/tests/acp/test_proxy_setup_acp.py
index d45ea38..5ba1ab4 100644
--- a/tests/acp/test_proxy_setup_acp.py
+++ b/tests/acp/test_proxy_setup_acp.py
@@ -34,14 +34,34 @@ def _get_fake_client(acp_agent_loop: VibeAcpAgentLoop) -> FakeClient:
return acp_agent_loop.client
+async def _wait_for_available_commands(acp_agent_loop: VibeAcpAgentLoop) -> None:
+ for _ in range(50):
+ updates = _get_fake_client(acp_agent_loop)._session_updates
+ if any(isinstance(u.update, AvailableCommandsUpdate) for u in updates):
+ return
+ await asyncio.sleep(0.01)
+ raise TimeoutError("available commands update was not sent")
+
+
class TestAvailableCommandsUpdate:
+ @pytest.mark.asyncio
+ async def test_initial_available_commands_are_delayed_until_after_new_session(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
+
+ updates = _get_fake_client(acp_agent_loop)._session_updates
+ assert not any(isinstance(u.update, AvailableCommandsUpdate) for u in updates)
+
+ await _wait_for_available_commands(acp_agent_loop)
+
@pytest.mark.asyncio
async def test_available_commands_sent_on_new_session(
self, acp_agent_loop: VibeAcpAgentLoop
) -> None:
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
- await asyncio.sleep(0)
+ await _wait_for_available_commands(acp_agent_loop)
updates = _get_fake_client(acp_agent_loop)._session_updates
available_commands_updates = [
@@ -62,7 +82,7 @@ class TestAvailableCommandsUpdate:
) -> None:
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
- await asyncio.sleep(0)
+ await _wait_for_available_commands(acp_agent_loop)
updates = _get_fake_client(acp_agent_loop)._session_updates
available_commands_updates = [
diff --git a/tests/acp/test_set_config_option.py b/tests/acp/test_set_config_option.py
index b4fae67..bfb4282 100644
--- a/tests/acp/test_set_config_option.py
+++ b/tests/acp/test_set_config_option.py
@@ -11,6 +11,7 @@ from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
from vibe.core.agent_loop import AgentLoop
from vibe.core.agents.models import BuiltinAgentName
from vibe.core.config import ModelConfig, VibeConfig
+from vibe.core.middleware import TurnLimitMiddleware
@pytest.fixture
@@ -399,3 +400,131 @@ class TestACPSetConfigOptionThinking:
)
assert response is None
+
+
+class TestACPSetConfigOptionMaxTurns:
+ @pytest.mark.asyncio
+ async def test_set_config_option_max_turns_success(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="max_turns", value="100"
+ )
+
+ assert response is not None
+ assert acp_session.agent_loop._max_turns == 100
+ turn_limits = [
+ m
+ for m in acp_session.agent_loop.middleware_pipeline.middlewares
+ if isinstance(m, TurnLimitMiddleware)
+ ]
+ assert len(turn_limits) == 1
+ assert turn_limits[0].max_turns == 100
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_max_turns_string_value(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="max_turns", value="50"
+ )
+
+ assert response is not None
+ assert acp_session.agent_loop._max_turns == 50
+ turn_limits = [
+ m
+ for m in acp_session.agent_loop.middleware_pipeline.middlewares
+ if isinstance(m, TurnLimitMiddleware)
+ ]
+ assert len(turn_limits) == 1
+ assert turn_limits[0].max_turns == 50
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_max_turns_invalid_string_returns_none(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+ initial_max_turns = acp_session.agent_loop._max_turns
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="max_turns", value="abc"
+ )
+
+ assert response is None
+ assert acp_session.agent_loop._max_turns == initial_max_turns
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_max_turns_bool_returns_none(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+ initial_max_turns = acp_session.agent_loop._max_turns
+
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="max_turns", value=True
+ )
+
+ assert response is None
+ assert acp_session.agent_loop._max_turns == initial_max_turns
+
+ @pytest.mark.asyncio
+ async def test_set_config_option_max_turns_repeated_set(
+ self, acp_agent_loop: VibeAcpAgentLoop
+ ) -> None:
+ session_response = await acp_agent_loop.new_session(
+ cwd=str(Path.cwd()), mcp_servers=[]
+ )
+ session_id = session_response.session_id
+ acp_session = next(
+ (s for s in acp_agent_loop.sessions.values() if s.id == session_id), None
+ )
+ assert acp_session is not None
+
+ await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="max_turns", value="100"
+ )
+ response = await acp_agent_loop.set_config_option(
+ session_id=session_id, config_id="max_turns", value="200"
+ )
+
+ assert response is not None
+ assert acp_session.agent_loop._max_turns == 200
+ turn_limits = [
+ m
+ for m in acp_session.agent_loop.middleware_pipeline.middlewares
+ if isinstance(m, TurnLimitMiddleware)
+ ]
+ assert len(turn_limits) == 1
+ assert turn_limits[0].max_turns == 200
diff --git a/tests/cli/test_startup_update_prompt.py b/tests/cli/test_startup_update_prompt.py
new file mode 100644
index 0000000..e800ec9
--- /dev/null
+++ b/tests/cli/test_startup_update_prompt.py
@@ -0,0 +1,224 @@
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+import time
+from unittest.mock import patch
+
+import pytest
+
+from tests.conftest import build_test_vibe_config
+from vibe.cli.cli import _maybe_run_startup_update_prompt
+from vibe.cli.update_notifier import FileSystemUpdateCacheRepository, UpdateCache
+from vibe.setup.update_prompt.update_prompt_dialog import UpdatePromptResult
+
+
+class _BrokenRepository:
+ async def get(self) -> UpdateCache | None:
+ raise OSError("disk on fire")
+
+ async def set(self, update_cache: UpdateCache) -> None:
+ raise OSError("disk on fire")
+
+
+@pytest.fixture
+def repository(tmp_path: Path) -> FileSystemUpdateCacheRepository:
+ return FileSystemUpdateCacheRepository(base_path=tmp_path)
+
+
+def _write_pending_update(
+ repository: FileSystemUpdateCacheRepository, version: str
+) -> None:
+ asyncio.run(
+ repository.set(
+ UpdateCache(latest_version=version, stored_at_timestamp=int(time.time()))
+ )
+ )
+
+
+def test_no_op_when_update_checks_are_disabled(
+ repository: FileSystemUpdateCacheRepository,
+) -> None:
+ config = build_test_vibe_config(enable_update_checks=False)
+ _write_pending_update(repository, "999.0.0")
+
+ with patch("vibe.cli.cli.ask_update_prompt") as mock_ask:
+ _maybe_run_startup_update_prompt(config, repository)
+
+ mock_ask.assert_not_called()
+
+
+def test_no_op_when_no_pending_update_is_cached(
+ repository: FileSystemUpdateCacheRepository,
+) -> None:
+ config = build_test_vibe_config(enable_update_checks=True)
+
+ with patch("vibe.cli.cli.ask_update_prompt") as mock_ask:
+ _maybe_run_startup_update_prompt(config, repository)
+
+ mock_ask.assert_not_called()
+
+
+def test_prompt_is_shown_and_continue_returns_without_exiting(
+ repository: FileSystemUpdateCacheRepository,
+) -> None:
+ config = build_test_vibe_config(enable_update_checks=True)
+ _write_pending_update(repository, "999.0.0")
+
+ with patch(
+ "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
+ ) as mock_ask:
+ _maybe_run_startup_update_prompt(config, repository)
+
+ mock_ask.assert_called_once()
+
+
+def test_quit_exits_zero(repository: FileSystemUpdateCacheRepository) -> None:
+ config = build_test_vibe_config(enable_update_checks=True)
+ _write_pending_update(repository, "999.0.0")
+
+ with (
+ patch("vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.QUIT),
+ pytest.raises(SystemExit) as excinfo,
+ ):
+ _maybe_run_startup_update_prompt(config, repository)
+
+ assert excinfo.value.code == 0
+
+
+def test_successful_update_exits_zero(
+ repository: FileSystemUpdateCacheRepository,
+) -> None:
+ config = build_test_vibe_config(enable_update_checks=True)
+ _write_pending_update(repository, "999.0.0")
+
+ with (
+ patch(
+ "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.UPDATED
+ ),
+ pytest.raises(SystemExit) as excinfo,
+ ):
+ _maybe_run_startup_update_prompt(config, repository)
+
+ assert excinfo.value.code == 0
+
+
+def test_failed_update_exits_one(repository: FileSystemUpdateCacheRepository) -> None:
+ config = build_test_vibe_config(enable_update_checks=True)
+ _write_pending_update(repository, "999.0.0")
+
+ with (
+ patch(
+ "vibe.cli.cli.ask_update_prompt",
+ return_value=UpdatePromptResult.UPDATE_FAILED,
+ ),
+ pytest.raises(SystemExit) as excinfo,
+ ):
+ _maybe_run_startup_update_prompt(config, repository)
+
+ assert excinfo.value.code == 1
+
+
+def test_no_op_when_cache_read_raises_oserror() -> None:
+ config = build_test_vibe_config(enable_update_checks=True)
+ repository = _BrokenRepository()
+
+ with patch("vibe.cli.cli.ask_update_prompt") as mock_ask:
+ _maybe_run_startup_update_prompt(config, repository)
+
+ mock_ask.assert_not_called()
+
+
+def test_continue_marks_version_as_dismissed_and_prevents_reprompt(
+ repository: FileSystemUpdateCacheRepository,
+) -> None:
+ config = build_test_vibe_config(enable_update_checks=True)
+ _write_pending_update(repository, "999.0.0")
+
+ with patch(
+ "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
+ ) as mock_ask:
+ _maybe_run_startup_update_prompt(config, repository)
+ _maybe_run_startup_update_prompt(config, repository)
+
+ assert mock_ask.call_count == 1
+
+
+def test_continue_reprompts_when_a_newer_version_appears(
+ repository: FileSystemUpdateCacheRepository,
+) -> None:
+ config = build_test_vibe_config(enable_update_checks=True)
+ _write_pending_update(repository, "999.0.0")
+
+ with patch(
+ "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
+ ) as mock_ask:
+ _maybe_run_startup_update_prompt(config, repository)
+ _write_pending_update(repository, "1000.0.0")
+ _maybe_run_startup_update_prompt(config, repository)
+
+ assert mock_ask.call_count == 2
+
+
+def test_successful_update_prints_restart_hint(
+ repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str]
+) -> None:
+ config = build_test_vibe_config(enable_update_checks=True)
+ _write_pending_update(repository, "999.0.0")
+
+ with (
+ patch(
+ "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.UPDATED
+ ),
+ pytest.raises(SystemExit),
+ ):
+ _maybe_run_startup_update_prompt(config, repository)
+
+ out = capsys.readouterr().out
+ assert "999.0.0" in out
+ assert "Run" in out and "vibe" in out
+
+
+def test_failed_update_prints_error_message(
+ repository: FileSystemUpdateCacheRepository, capsys: pytest.CaptureFixture[str]
+) -> None:
+ config = build_test_vibe_config(enable_update_checks=True)
+ _write_pending_update(repository, "999.0.0")
+
+ with (
+ patch(
+ "vibe.cli.cli.ask_update_prompt",
+ return_value=UpdatePromptResult.UPDATE_FAILED,
+ ),
+ pytest.raises(SystemExit),
+ ):
+ _maybe_run_startup_update_prompt(config, repository)
+
+ assert "could not be updated automatically" in capsys.readouterr().out
+
+
+def test_failed_update_does_not_dismiss_so_user_is_reprompted_on_next_launch(
+ repository: FileSystemUpdateCacheRepository,
+) -> None:
+ config = build_test_vibe_config(enable_update_checks=True)
+ _write_pending_update(repository, "999.0.0")
+
+ with (
+ patch(
+ "vibe.cli.cli.ask_update_prompt",
+ return_value=UpdatePromptResult.UPDATE_FAILED,
+ ),
+ pytest.raises(SystemExit),
+ ):
+ _maybe_run_startup_update_prompt(config, repository)
+
+ cache = asyncio.run(repository.get())
+ assert cache is not None
+ assert cache.dismissed_version is None
+
+ with patch(
+ "vibe.cli.cli.ask_update_prompt", return_value=UpdatePromptResult.CONTINUE
+ ) as mock_ask:
+ _maybe_run_startup_update_prompt(config, repository)
+
+ mock_ask.assert_called_once()
diff --git a/tests/conftest.py b/tests/conftest.py
index 3273ca8..be3d493 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -54,7 +54,6 @@ def get_base_config() -> dict[str, Any]:
"alias": "devstral-latest",
}
],
- "enable_auto_update": False,
"enable_telemetry": False,
}
diff --git a/tests/core/test_build_attachment_counts.py b/tests/core/test_build_attachment_counts.py
new file mode 100644
index 0000000..dc6093f
--- /dev/null
+++ b/tests/core/test_build_attachment_counts.py
@@ -0,0 +1,34 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from vibe.core.telemetry.build_metadata import build_attachment_counts
+from vibe.core.telemetry.types import AttachmentKind
+from vibe.core.types import ImageAttachment, LLMMessage, Role
+
+
+def _msg(images: list[ImageAttachment] | None) -> LLMMessage:
+ return LLMMessage(role=Role.user, content="hi", images=images)
+
+
+def _image() -> ImageAttachment:
+ return ImageAttachment(
+ path=Path("/tmp/x.png"), alias="x.png", mime_type="image/png"
+ )
+
+
+def test_returns_empty_when_message_is_none() -> None:
+ assert build_attachment_counts(None, supports_images=True) == {}
+
+
+def test_returns_empty_when_no_images() -> None:
+ assert build_attachment_counts(_msg(None), supports_images=True) == {}
+
+
+def test_counts_images_when_model_supports_them() -> None:
+ counts = build_attachment_counts(_msg([_image(), _image()]), supports_images=True)
+ assert counts == {AttachmentKind.IMAGE: 2}
+
+
+def test_drops_images_when_model_does_not_support_them() -> None:
+ assert build_attachment_counts(_msg([_image()]), supports_images=False) == {}
diff --git a/tests/core/test_telemetry_send.py b/tests/core/test_telemetry_send.py
index 681dabb..5193965 100644
--- a/tests/core/test_telemetry_send.py
+++ b/tests/core/test_telemetry_send.py
@@ -15,7 +15,11 @@ from vibe.core.telemetry.build_metadata import (
build_request_metadata,
)
from vibe.core.telemetry.send import TelemetryClient
-from vibe.core.telemetry.types import EntrypointMetadata, TelemetryRequestMetadata
+from vibe.core.telemetry.types import (
+ AttachmentKind,
+ EntrypointMetadata,
+ TelemetryRequestMetadata,
+)
from vibe.core.tools.base import BaseTool, ToolPermission
from vibe.core.types import Backend
from vibe.core.utils import get_user_agent
@@ -694,6 +698,43 @@ class TestTelemetryClient:
assert properties["call_source"] == "vibe_code"
assert properties["call_type"] == "main_call"
assert properties["message_id"] is None
+ assert properties["attachment_counts"] == {}
+
+ def test_send_request_sent_payload_with_attachments(
+ self, telemetry_events: list[dict[str, Any]]
+ ) -> None:
+ config = build_test_vibe_config(enable_telemetry=True)
+ client = TelemetryClient(config_getter=lambda: config)
+
+ client.send_request_sent(
+ model="codestral",
+ nb_context_chars=1234,
+ nb_context_messages=5,
+ nb_prompt_chars=42,
+ call_type="main_call",
+ attachment_counts={AttachmentKind.IMAGE: 2},
+ )
+
+ assert len(telemetry_events) == 1
+ properties = telemetry_events[0]["properties"]
+ assert properties["attachment_counts"] == {"image": 2}
+
+ def test_send_request_sent_payload_drops_zero_counts(
+ self, telemetry_events: list[dict[str, Any]]
+ ) -> None:
+ config = build_test_vibe_config(enable_telemetry=True)
+ client = TelemetryClient(config_getter=lambda: config)
+
+ client.send_request_sent(
+ model="codestral",
+ nb_context_chars=1234,
+ nb_context_messages=5,
+ nb_prompt_chars=42,
+ call_type="main_call",
+ attachment_counts={AttachmentKind.IMAGE: 0},
+ )
+
+ assert telemetry_events[0]["properties"]["attachment_counts"] == {}
def test_send_user_rating_feedback_payload(
self, telemetry_events: list[dict[str, Any]]
diff --git a/tests/e2e/common.py b/tests/e2e/common.py
index 1d6b844..fbb0d8c 100644
--- a/tests/e2e/common.py
+++ b/tests/e2e/common.py
@@ -28,7 +28,6 @@ def write_e2e_config(vibe_home: Path, api_base: str) -> None:
"\n".join([
'active_model = "mock-model"',
"enable_update_checks = false",
- "enable_auto_update = false",
"disable_welcome_banner_animation = true",
"",
"[[providers]]",
diff --git a/tests/setup/__init__.py b/tests/setup/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/setup/test_update_prompt_dialog.py b/tests/setup/test_update_prompt_dialog.py
new file mode 100644
index 0000000..1fbad10
--- /dev/null
+++ b/tests/setup/test_update_prompt_dialog.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+import asyncio
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from vibe.setup.update_prompt.update_prompt_dialog import (
+ UpdateChoice,
+ UpdatePromptApp,
+ UpdatePromptResult,
+)
+
+
+async def _await_update_completion(app: UpdatePromptApp) -> None:
+ while app._update_task is None:
+ await asyncio.sleep(0.01)
+ try:
+ await app._update_task
+ except Exception:
+ pass
+
+
+@pytest.mark.asyncio
+async def test_dialog_returns_continue_on_continue_selection() -> None:
+ app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
+
+ async with app.run_test() as pilot:
+ await pilot.press("right")
+ await pilot.press("enter")
+ await pilot.pause()
+
+ assert app.return_value is UpdatePromptResult.CONTINUE
+
+
+@pytest.mark.asyncio
+async def test_dialog_returns_updated_when_update_command_succeeds() -> None:
+ app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
+
+ with patch(
+ "vibe.setup.update_prompt.update_prompt_dialog.do_update",
+ new=AsyncMock(return_value=True),
+ ):
+ async with app.run_test() as pilot:
+ await pilot.press("enter")
+ await _await_update_completion(app)
+ await pilot.pause()
+
+ assert app.return_value is UpdatePromptResult.UPDATED
+
+
+@pytest.mark.asyncio
+async def test_dialog_returns_update_failed_when_update_command_fails() -> None:
+ app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
+
+ with patch(
+ "vibe.setup.update_prompt.update_prompt_dialog.do_update",
+ new=AsyncMock(return_value=False),
+ ):
+ async with app.run_test() as pilot:
+ await pilot.press("enter")
+ await _await_update_completion(app)
+ await pilot.pause()
+
+ assert app.return_value is UpdatePromptResult.UPDATE_FAILED
+
+
+@pytest.mark.asyncio
+async def test_dialog_default_selection_is_update() -> None:
+ app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
+
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ assert app._dialog is not None
+ assert app._dialog.selected is UpdateChoice.UPDATE
+
+
+@pytest.mark.asyncio
+async def test_dialog_returns_quit_on_ctrl_q() -> None:
+ app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
+
+ async with app.run_test() as pilot:
+ await pilot.press("ctrl+q")
+ await pilot.pause()
+
+ assert app.return_value is UpdatePromptResult.QUIT
+
+
+@pytest.mark.asyncio
+async def test_dialog_returns_update_failed_when_do_update_raises() -> None:
+ app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
+
+ with patch(
+ "vibe.setup.update_prompt.update_prompt_dialog.do_update",
+ new=AsyncMock(side_effect=OSError("boom")),
+ ):
+ async with app.run_test() as pilot:
+ await pilot.press("enter")
+ await _await_update_completion(app)
+ await pilot.pause()
+
+ assert app.return_value is UpdatePromptResult.UPDATE_FAILED
+
+
+@pytest.mark.asyncio
+async def test_ctrl_q_during_update_cancels_subprocess_and_quits() -> None:
+ app = UpdatePromptApp(current_version="1.0.0", latest_version="2.0.0")
+
+ update_started = asyncio.Event()
+
+ async def slow_update() -> bool:
+ update_started.set()
+ await asyncio.sleep(60)
+ return True
+
+ with patch(
+ "vibe.setup.update_prompt.update_prompt_dialog.do_update", new=slow_update
+ ):
+ async with app.run_test() as pilot:
+ await pilot.press("enter")
+ await update_started.wait()
+ await pilot.press("ctrl+q")
+ await pilot.pause()
+
+ assert app.return_value is UpdatePromptResult.QUIT
+ assert app._update_task is not None
+ assert app._update_task.cancelled() or app._update_task.done()
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg
deleted file mode 100644
index ab02dbe..0000000
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_release_update_notification/test_snapshot_shows_release_update_notification.svg
+++ /dev/null
@@ -1,204 +0,0 @@
-
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_continue_selected.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_continue_selected.svg
new file mode 100644
index 0000000..762501d
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_continue_selected.svg
@@ -0,0 +1,138 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_initial.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_initial.svg
new file mode 100644
index 0000000..e1d7ab9
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_update_prompt/test_snapshot_update_prompt_initial.svg
@@ -0,0 +1,138 @@
+
diff --git a/tests/snapshots/test_ui_snapshot_release_update_notification.py b/tests/snapshots/test_ui_snapshot_release_update_notification.py
deleted file mode 100644
index cbec0e4..0000000
--- a/tests/snapshots/test_ui_snapshot_release_update_notification.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-from unittest.mock import patch
-
-from textual.pilot import Pilot
-
-from tests.snapshots.base_snapshot_test_app import BaseSnapshotTestApp, default_config
-from tests.snapshots.snap_compare import SnapCompare
-from tests.update_notifier.adapters.fake_update_cache_repository import (
- FakeUpdateCacheRepository,
-)
-from tests.update_notifier.adapters.fake_update_gateway import FakeUpdateGateway
-from vibe.cli.update_notifier import Update, UpdateCache
-
-
-class SnapshotTestAppWithUpdate(BaseSnapshotTestApp):
- def __init__(self):
- config = default_config()
- config.enable_update_checks = True
- update_notifier = FakeUpdateGateway(update=Update(latest_version="1000.2.0"))
- update_cache_repository = FakeUpdateCacheRepository(
- UpdateCache(
- latest_version="1.0.4",
- stored_at_timestamp=0,
- seen_whats_new_version=None,
- )
- )
- super().__init__(
- config=config,
- update_notifier=update_notifier,
- update_cache_repository=update_cache_repository,
- current_version="1.0.4",
- )
-
-
-def test_snapshot_shows_release_update_notification(
- snap_compare: SnapCompare, tmp_path: Path
-) -> None:
- whats_new_file = tmp_path / "whats_new.md"
- whats_new_file.write_text("# What's New\n\n- Feature 1\n- Feature 2")
-
- async def run_before(pilot: Pilot) -> None:
- await pilot.pause(0.2)
-
- with patch("vibe.cli.update_notifier.whats_new.VIBE_ROOT", tmp_path):
- assert snap_compare(
- "test_ui_snapshot_release_update_notification.py:SnapshotTestAppWithUpdate",
- terminal_size=(120, 36),
- run_before=run_before,
- )
diff --git a/tests/snapshots/test_ui_snapshot_update_prompt.py b/tests/snapshots/test_ui_snapshot_update_prompt.py
new file mode 100644
index 0000000..8e6b7f0
--- /dev/null
+++ b/tests/snapshots/test_ui_snapshot_update_prompt.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from textual.pilot import Pilot
+
+from tests.snapshots.snap_compare import SnapCompare
+from vibe.setup.update_prompt import update_prompt_dialog
+from vibe.setup.update_prompt.update_prompt_dialog import UpdatePromptApp
+
+
+class UpdatePromptSnapshotApp(UpdatePromptApp):
+ CSS_PATH = str(
+ Path(update_prompt_dialog.__file__).parent / "update_prompt_dialog.tcss"
+ )
+
+ def __init__(self) -> None:
+ super().__init__(current_version="1.0.0", latest_version="2.0.0")
+
+
+def test_snapshot_update_prompt_initial(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.2)
+
+ assert snap_compare(
+ "test_ui_snapshot_update_prompt.py:UpdatePromptSnapshotApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
+
+
+def test_snapshot_update_prompt_continue_selected(snap_compare: SnapCompare) -> None:
+ async def run_before(pilot: Pilot) -> None:
+ await pilot.pause(0.2)
+ await pilot.press("right")
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_update_prompt.py:UpdatePromptSnapshotApp",
+ terminal_size=(80, 20),
+ run_before=run_before,
+ )
diff --git a/tests/update_notifier/test_ui_update_notification.py b/tests/update_notifier/test_ui_update_notification.py
index e08b8b8..00a7e51 100644
--- a/tests/update_notifier/test_ui_update_notification.py
+++ b/tests/update_notifier/test_ui_update_notification.py
@@ -7,7 +7,6 @@ import time
from unittest.mock import patch
import pytest
-from textual.app import Notification
from tests.conftest import build_test_vibe_app, build_test_vibe_config
from tests.update_notifier.adapters.fake_update_cache_repository import (
@@ -50,21 +49,6 @@ def build_update_test_app(
return _build
-async def _wait_for_notification(
- app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
-) -> Notification:
- loop = asyncio.get_running_loop()
- deadline = loop.time() + timeout
-
- while loop.time() < deadline:
- notifications = list(app._notifications)
- if notifications:
- return notifications[-1]
- await pilot.pause(interval)
-
- pytest.fail("Notification not displayed")
-
-
async def _assert_no_notifications(
app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
) -> None:
@@ -85,25 +69,25 @@ def vibe_config_with_update_checks_enabled() -> VibeConfig:
@pytest.mark.asyncio
-async def test_ui_displays_update_notification(
+async def test_ui_writes_pending_update_to_cache_without_notifying(
build_update_test_app: Callable[..., VibeApp],
) -> None:
notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
- app = build_update_test_app(update_notifier=notifier)
+ repository = FakeUpdateCacheRepository()
+ app = build_update_test_app(
+ update_notifier=notifier, update_cache_repository=repository
+ )
async with app.run_test() as pilot:
- notification = await _wait_for_notification(app, pilot, timeout=0.3)
+ await _assert_no_notifications(app, pilot, timeout=0.3)
- assert notification.severity == "information"
- assert notification.title == "Update available"
- assert (
- notification.message
- == "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager"
- )
+ assert notifier.fetch_update_calls == 1
+ assert repository.update_cache is not None
+ assert repository.update_cache.latest_version == "0.2.0"
@pytest.mark.asyncio
-async def test_ui_does_not_display_update_notification_when_not_available(
+async def test_ui_does_not_notify_when_no_update_is_available(
build_update_test_app: Callable[..., VibeApp],
) -> None:
notifier = FakeUpdateGateway(update=None)
@@ -115,7 +99,7 @@ async def test_ui_does_not_display_update_notification_when_not_available(
@pytest.mark.asyncio
-async def test_ui_displays_warning_toast_when_check_fails(
+async def test_ui_does_not_notify_when_gateway_errors(
build_update_test_app: Callable[..., VibeApp],
) -> None:
notifier = FakeUpdateGateway(
@@ -124,13 +108,7 @@ async def test_ui_displays_warning_toast_when_check_fails(
app = build_update_test_app(update_notifier=notifier)
async with app.run_test() as pilot:
- await pilot.pause(0.3)
- notifications = list(app._notifications)
-
- assert notifications
- warning = notifications[-1]
- assert warning.severity == "warning"
- assert "forbidden" in warning.message.lower()
+ await _assert_no_notifications(app, pilot, timeout=0.3)
@pytest.mark.asyncio
@@ -167,11 +145,11 @@ async def test_ui_does_not_show_toast_when_update_is_known_in_recent_cache_alrea
@pytest.mark.asyncio
-async def test_ui_does_show_toast_when_cache_entry_is_too_old(
+async def test_ui_refetches_and_updates_cache_when_cache_entry_is_too_old(
build_update_test_app: Callable[..., VibeApp],
) -> None:
timestamp_two_days_ago = int(time.time()) - 2 * 24 * 60 * 60
- notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
+ notifier = FakeUpdateGateway(update=Update(latest_version="0.3.0"))
update_cache = UpdateCache(
latest_version="0.2.0", stored_at_timestamp=timestamp_two_days_ago
)
@@ -181,18 +159,11 @@ async def test_ui_does_show_toast_when_cache_entry_is_too_old(
)
async with app.run_test() as pilot:
- await pilot.pause(0.3)
- notifications = list(app._notifications)
+ await _assert_no_notifications(app, pilot, timeout=0.3)
- assert notifications
- notification = notifications[-1]
- assert notification.severity == "information"
- assert notification.title == "Update available"
- assert (
- notification.message
- == "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager"
- )
assert notifier.fetch_update_calls == 1
+ assert update_cache_repository.update_cache is not None
+ assert update_cache_repository.update_cache.latest_version == "0.3.0"
async def _wait_for_whats_new_message(
@@ -339,51 +310,3 @@ async def test_ui_does_not_display_whats_new_when_file_does_not_exist(
assert repository.update_cache is not None
assert repository.update_cache.seen_whats_new_version == "1.0.0"
-
-
-@pytest.mark.asyncio
-async def test_ui_displays_success_notification_when_auto_update_succeeds(
- build_update_test_app: Callable[..., VibeApp],
-) -> None:
- config = build_test_vibe_config(enable_update_checks=True, enable_auto_update=True)
- notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
-
- with patch("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["true"]):
- app = build_update_test_app(update_notifier=notifier, config=config)
-
- async with app.run_test() as pilot:
- await pilot.pause(0.3)
- notifications = list(app._notifications)
-
- assert notifications, "No notifications displayed"
- notification = notifications[-1]
- assert notification.severity == "information"
- assert notification.title == "Update successful"
- assert (
- notification.message
- == "0.1.0 => 0.2.0\nVibe was updated successfully. Please restart to use the new version."
- )
-
-
-@pytest.mark.asyncio
-async def test_ui_displays_update_notification_when_auto_update_fails(
- build_update_test_app: Callable[..., VibeApp],
-) -> None:
- config = build_test_vibe_config(enable_update_checks=True, enable_auto_update=True)
- notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
-
- with patch("vibe.cli.update_notifier.update.UPDATE_COMMANDS", ["false"]):
- app = build_update_test_app(update_notifier=notifier, config=config)
-
- async with app.run_test() as pilot:
- await pilot.pause(0.3)
- notifications = list(app._notifications)
-
- assert notifications
- notification = notifications[-1]
- assert notification.severity == "information"
- assert notification.title == "Update available"
- assert (
- notification.message
- == "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager"
- )
diff --git a/tests/update_notifier/test_update_use_case.py b/tests/update_notifier/test_update_use_case.py
index 7112203..11e26b3 100644
--- a/tests/update_notifier/test_update_use_case.py
+++ b/tests/update_notifier/test_update_use_case.py
@@ -12,7 +12,12 @@ from vibe.cli.update_notifier import (
UpdateGatewayCause,
UpdateGatewayError,
)
-from vibe.cli.update_notifier.update import UpdateError, get_update_if_available
+from vibe.cli.update_notifier.update import (
+ UpdateError,
+ get_pending_update_from_cache,
+ get_update_if_available,
+ mark_update_as_dismissed,
+)
@pytest.fixture
@@ -300,3 +305,136 @@ async def test_updates_cache_timestamp_with_current_version_when_gateway_errors(
assert update_cache_repository.update_cache is not None
assert update_cache_repository.update_cache.latest_version == "1.0.0"
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
+
+
+@pytest.mark.asyncio
+async def test_get_pending_update_from_cache_returns_none_when_cache_is_empty() -> None:
+ repository = FakeUpdateCacheRepository()
+
+ assert await get_pending_update_from_cache(repository, "1.0.0") is None
+
+
+@pytest.mark.asyncio
+async def test_get_pending_update_from_cache_returns_none_when_cached_version_equals_current() -> (
+ None
+):
+ repository = FakeUpdateCacheRepository(
+ update_cache=UpdateCache(latest_version="1.0.0", stored_at_timestamp=0)
+ )
+
+ assert await get_pending_update_from_cache(repository, "1.0.0") is None
+
+
+@pytest.mark.asyncio
+async def test_get_pending_update_from_cache_returns_none_when_cached_version_is_older() -> (
+ None
+):
+ repository = FakeUpdateCacheRepository(
+ update_cache=UpdateCache(latest_version="0.9.0", stored_at_timestamp=0)
+ )
+
+ assert await get_pending_update_from_cache(repository, "1.0.0") is None
+
+
+@pytest.mark.asyncio
+async def test_get_pending_update_from_cache_returns_version_when_newer_exists() -> (
+ None
+):
+ repository = FakeUpdateCacheRepository(
+ update_cache=UpdateCache(latest_version="1.2.3", stored_at_timestamp=0)
+ )
+
+ assert await get_pending_update_from_cache(repository, "1.0.0") == "1.2.3"
+
+
+@pytest.mark.asyncio
+async def test_get_pending_update_from_cache_returns_none_when_current_is_invalid() -> (
+ None
+):
+ repository = FakeUpdateCacheRepository(
+ update_cache=UpdateCache(latest_version="1.2.3", stored_at_timestamp=0)
+ )
+
+ assert await get_pending_update_from_cache(repository, "not-a-version") is None
+
+
+@pytest.mark.asyncio
+async def test_get_pending_update_from_cache_returns_none_when_dismissed() -> None:
+ repository = FakeUpdateCacheRepository(
+ update_cache=UpdateCache(
+ latest_version="1.2.3", stored_at_timestamp=0, dismissed_version="1.2.3"
+ )
+ )
+
+ assert await get_pending_update_from_cache(repository, "1.0.0") is None
+
+
+@pytest.mark.asyncio
+async def test_get_pending_update_from_cache_returns_version_when_dismissed_is_older() -> (
+ None
+):
+ repository = FakeUpdateCacheRepository(
+ update_cache=UpdateCache(
+ latest_version="1.3.0", stored_at_timestamp=0, dismissed_version="1.2.3"
+ )
+ )
+
+ assert await get_pending_update_from_cache(repository, "1.0.0") == "1.3.0"
+
+
+@pytest.mark.asyncio
+async def test_mark_update_as_dismissed_persists_version_and_preserves_other_fields() -> (
+ None
+):
+ repository = FakeUpdateCacheRepository(
+ update_cache=UpdateCache(
+ latest_version="1.2.3",
+ stored_at_timestamp=42,
+ seen_whats_new_version="1.0.0",
+ )
+ )
+
+ await mark_update_as_dismissed(repository, "1.2.3")
+
+ assert repository.update_cache is not None
+ assert repository.update_cache.latest_version == "1.2.3"
+ assert repository.update_cache.stored_at_timestamp == 42
+ assert repository.update_cache.seen_whats_new_version == "1.0.0"
+ assert repository.update_cache.dismissed_version == "1.2.3"
+
+
+@pytest.mark.asyncio
+async def test_mark_update_as_dismissed_is_a_noop_when_cache_is_empty() -> None:
+ repository = FakeUpdateCacheRepository()
+
+ await mark_update_as_dismissed(repository, "1.2.3")
+
+ assert repository.update_cache is None
+
+
+@pytest.mark.asyncio
+async def test_writing_cache_preserves_seen_whats_new_and_dismissed_version(
+ current_timestamp: int,
+) -> None:
+ update_notifier = FakeUpdateGateway(update=Update(latest_version="1.0.2"))
+ timestamp_two_days_ago = current_timestamp - 48 * 60 * 60
+ update_cache_repository = FakeUpdateCacheRepository(
+ update_cache=UpdateCache(
+ latest_version="1.0.1",
+ stored_at_timestamp=timestamp_two_days_ago,
+ seen_whats_new_version="1.0.0",
+ dismissed_version="1.0.1",
+ )
+ )
+
+ await get_update_if_available(
+ update_notifier,
+ current_version="1.0.0",
+ update_cache_repository=update_cache_repository,
+ get_current_timestamp=lambda: current_timestamp,
+ )
+
+ assert update_cache_repository.update_cache is not None
+ assert update_cache_repository.update_cache.latest_version == "1.0.2"
+ assert update_cache_repository.update_cache.seen_whats_new_version == "1.0.0"
+ assert update_cache_repository.update_cache.dismissed_version == "1.0.1"
diff --git a/uv.lock b/uv.lock
index befca6d..9040590 100644
--- a/uv.lock
+++ b/uv.lock
@@ -825,7 +825,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
-version = "2.14.0"
+version = "2.14.1"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
diff --git a/vibe/__init__.py b/vibe/__init__.py
index 72094a1..110614e 100644
--- a/vibe/__init__.py
+++ b/vibe/__init__.py
@@ -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"
diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py
index 773c103..c7f7ee2 100644
--- a/vibe/acp/acp_agent_loop.py
+++ b/vibe/acp/acp_agent_loop.py
@@ -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:
diff --git a/vibe/acp/commands/__init__.py b/vibe/acp/commands/__init__.py
index 5d08b7a..d28826d 100644
--- a/vibe/acp/commands/__init__.py
+++ b/vibe/acp/commands/__init__.py
@@ -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"]
diff --git a/vibe/acp/commands/registry.py b/vibe/acp/commands/registry.py
index 28396b0..223c62d 100644
--- a/vibe/acp/commands/registry.py
+++ b/vibe/acp/commands/registry.py
@@ -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",
diff --git a/vibe/acp/teleport.py b/vibe/acp/teleport.py
new file mode 100644
index 0000000..ddfdb96
--- /dev/null
+++ b/vibe/acp/teleport.py
@@ -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),
+ )
diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py
index 36cd74b..ffabbb4 100644
--- a/vibe/acp/utils.py
+++ b/vibe/acp/utils.py
@@ -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]:
diff --git a/vibe/cli/cli.py b/vibe/cli/cli.py
index c8d0867..8dd7ac5 100644
--- a/vibe/cli/cli.py
+++ b/vibe/cli/cli.py
@@ -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![/]")
diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py
index a792a3b..d487679 100644
--- a/vibe/cli/textual_ui/app.py
+++ b/vibe/cli/textual_ui/app.py
@@ -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(
diff --git a/vibe/cli/update_notifier/__init__.py b/vibe/cli/update_notifier/__init__.py
index 3e894a3..e47bdfb 100644
--- a/vibe/cli/update_notifier/__init__.py
+++ b/vibe/cli/update_notifier/__init__.py
@@ -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",
]
diff --git a/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py
index b11d388..fc788cb 100644
--- a/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py
+++ b/vibe/cli/update_notifier/adapters/filesystem_update_cache_repository.py
@@ -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,
)
diff --git a/vibe/cli/update_notifier/ports/update_cache_repository.py b/vibe/cli/update_notifier/ports/update_cache_repository.py
index da0c1bf..f8e282e 100644
--- a/vibe/cli/update_notifier/ports/update_cache_repository.py
+++ b/vibe/cli/update_notifier/ports/update_cache_repository.py
@@ -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):
diff --git a/vibe/cli/update_notifier/update.py b/vibe/cli/update_notifier/update.py
index d4db614..3542635 100644
--- a/vibe/cli/update_notifier/update.py
+++ b/vibe/cli/update_notifier/update.py
@@ -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()
diff --git a/vibe/cli/update_notifier/whats_new.py b/vibe/cli/update_notifier/whats_new.py
index f44490b..ee5fe27 100644
--- a/vibe/cli/update_notifier/whats_new.py
+++ b/vibe/cli/update_notifier/whats_new.py
@@ -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))
diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py
index d1d762d..f45b340 100644
--- a/vibe/core/agent_loop.py
+++ b/vibe/core/agent_loop.py
@@ -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:
diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py
index ccacd5b..2d42b0f 100644
--- a/vibe/core/config/_settings.py
+++ b/vibe/core/config/_settings.py
@@ -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
diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py
index 9fd74a8..c72f36e 100644
--- a/vibe/core/skills/builtins/vibe.py
+++ b/vibe/core/skills/builtins/vibe.py
@@ -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
diff --git a/vibe/core/telemetry/build_metadata.py b/vibe/core/telemetry/build_metadata.py
index 014c7b6..d01b836 100644
--- a/vibe/core/telemetry/build_metadata.py
+++ b/vibe/core/telemetry/build_metadata.py
@@ -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,
diff --git a/vibe/core/telemetry/send.py b/vibe/core/telemetry/send.py
index 975b5eb..3726a1d 100644
--- a/vibe/core/telemetry/send.py
+++ b/vibe/core/telemetry/send.py
@@ -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)
diff --git a/vibe/core/telemetry/types.py b/vibe/core/telemetry/types.py
index 7c37a50..9e25e4e 100644
--- a/vibe/core/telemetry/types.py
+++ b/vibe/core/telemetry/types.py
@@ -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
diff --git a/vibe/core/tools/builtins/prompts/bash.md b/vibe/core/tools/builtins/prompts/bash.md
index d22fa7f..4156e53 100644
--- a/vibe/core/tools/builtins/prompts/bash.md
+++ b/vibe/core/tools/builtins/prompts/bash.md
@@ -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=".")`
diff --git a/vibe/core/tools/builtins/prompts/write_file.md b/vibe/core/tools/builtins/prompts/write_file.md
index f4b8777..7817588 100644
--- a/vibe/core/tools/builtins/prompts/write_file.md
+++ b/vibe/core/tools/builtins/prompts/write_file.md
@@ -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:**
diff --git a/vibe/core/tools/builtins/write_file.py b/vibe/core/tools/builtins/write_file.py
index c7d23fc..bf8ed84 100644
--- a/vibe/core/tools/builtins/write_file.py
+++ b/vibe/core/tools/builtins/write_file.py
@@ -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
diff --git a/vibe/setup/trusted_folders/trust_folder_dialog.py b/vibe/setup/trusted_folders/trust_folder_dialog.py
index 02d1bae..fd0ae86 100644
--- a/vibe/setup/trusted_folders/trust_folder_dialog.py
+++ b/vibe/setup/trusted_folders/trust_folder_dialog.py
@@ -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
diff --git a/vibe/setup/update_prompt/__init__.py b/vibe/setup/update_prompt/__init__.py
new file mode 100644
index 0000000..c891893
--- /dev/null
+++ b/vibe/setup/update_prompt/__init__.py
@@ -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"]
diff --git a/vibe/setup/update_prompt/update_prompt_dialog.py b/vibe/setup/update_prompt/update_prompt_dialog.py
new file mode 100644
index 0000000..73fa985
--- /dev/null
+++ b/vibe/setup/update_prompt/update_prompt_dialog.py
@@ -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
diff --git a/vibe/setup/update_prompt/update_prompt_dialog.tcss b/vibe/setup/update_prompt/update_prompt_dialog.tcss
new file mode 100644
index 0000000..6a086d8
--- /dev/null
+++ b/vibe/setup/update_prompt/update_prompt_dialog.tcss
@@ -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;
+}
diff --git a/vibe/whats_new.md b/vibe/whats_new.md
index 2ccb013..02a83ff 100644
--- a/vibe/whats_new.md
+++ b/vibe/whats_new.md
@@ -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.