v2.14.1 (#756)
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai> Co-authored-by: Gauthier Guinet <43207538+Gguinet@users.noreply.github.com> Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com> Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai> Co-authored-by: Stanislas <stanislas.lange@mistral.ai> Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
parent
3f8487f761
commit
702d0f412e
51 changed files with 2446 additions and 502 deletions
|
|
@ -33,7 +33,7 @@ Always go through `uv` — never invoke bare `python` or `pip`.
|
||||||
- Use f-strings, comprehensions, and context managers; follow PEP 8.
|
- 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.
|
- 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.
|
- 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
|
- Avoid comments and docstrings, except for when there's a hard to spot corner case
|
||||||
|
|
||||||
## Typing & imports
|
## Typing & imports
|
||||||
|
|
|
||||||
14
CHANGELOG.md
14
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/),
|
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).
|
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
|
## [2.14.0] - 2026-06-04
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -644,14 +644,12 @@ Each path is implicitly trusted (no trust prompt) and contributes its `AGENTS.md
|
||||||
|
|
||||||
### Update Settings
|
### 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 the daily check entirely, add this to your `config.toml`:
|
||||||
|
|
||||||
To disable auto-updates, add this to your `config.toml`:
|
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
enable_auto_update = false
|
enable_update_checks = false
|
||||||
```
|
```
|
||||||
|
|
||||||
### Notification Settings
|
### Notification Settings
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
id = "mistral-vibe"
|
id = "mistral-vibe"
|
||||||
name = "Mistral Vibe"
|
name = "Mistral Vibe"
|
||||||
description = "Mistral's open-source coding assistant"
|
description = "Mistral's open-source coding assistant"
|
||||||
version = "2.14.0"
|
version = "2.14.1"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Mistral AI"]
|
authors = ["Mistral AI"]
|
||||||
repository = "https://github.com/mistralai/mistral-vibe"
|
repository = "https://github.com/mistralai/mistral-vibe"
|
||||||
|
|
@ -11,21 +11,21 @@ name = "Mistral Vibe"
|
||||||
icon = "./icons/mistral_vibe.svg"
|
icon = "./icons/mistral_vibe.svg"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-aarch64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.darwin-x86_64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-aarch64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.linux-x86_64]
|
[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"
|
cmd = "./vibe-acp"
|
||||||
|
|
||||||
[agent_servers.mistral-vibe.targets.windows-x86_64]
|
[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"
|
cmd = "./vibe-acp.exe"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.14.0"
|
version = "2.14.1"
|
||||||
description = "Minimal CLI coding agent by Mistral"
|
description = "Minimal CLI coding agent by Mistral"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,21 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
from unittest.mock import AsyncMock, patch
|
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
|
import pytest
|
||||||
|
|
||||||
from tests.acp.conftest import _create_acp_agent
|
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_backend import FakeBackend
|
||||||
from tests.stubs.fake_client import FakeClient
|
from tests.stubs.fake_client import FakeClient
|
||||||
from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
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.agent_loop import AgentLoop
|
||||||
from vibe.core.config import SessionLoggingConfig
|
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:
|
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:
|
async def _new_session_and_clear(agent: VibeAcpAgentLoop) -> str:
|
||||||
"""Create a new session, drain the startup updates, return session_id."""
|
"""Create a new session, drain the startup updates, return session_id."""
|
||||||
resp = await agent.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
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()
|
_get_client(agent)._session_updates.clear()
|
||||||
return resp.session_id
|
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):
|
async def _prompt(agent: VibeAcpAgentLoop, session_id: str, text: str):
|
||||||
return await agent.prompt(
|
return await agent.prompt(
|
||||||
prompt=[TextContentBlock(type="text", text=text)], session_id=session_id
|
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,
|
skill_paths: list[Path] | None = None,
|
||||||
session_logging: SessionLoggingConfig | None = None,
|
session_logging: SessionLoggingConfig | None = None,
|
||||||
|
vibe_code_enabled: bool | None = None,
|
||||||
) -> type[AgentLoop]:
|
) -> type[AgentLoop]:
|
||||||
"""Create a PatchedAgentLoop class that injects config overrides."""
|
"""Create a PatchedAgentLoop class that injects config overrides."""
|
||||||
config_updates: dict = {}
|
config_updates: dict = {}
|
||||||
|
|
@ -58,6 +105,8 @@ def _make_patched_agent_loop(
|
||||||
config_updates["skill_paths"] = skill_paths
|
config_updates["skill_paths"] = skill_paths
|
||||||
if session_logging is not None:
|
if session_logging is not None:
|
||||||
config_updates["session_logging"] = session_logging
|
config_updates["session_logging"] = session_logging
|
||||||
|
if vibe_code_enabled is not None:
|
||||||
|
config_updates["vibe_code_enabled"] = vibe_code_enabled
|
||||||
|
|
||||||
class PatchedAgentLoop(AgentLoop):
|
class PatchedAgentLoop(AgentLoop):
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
|
|
@ -75,6 +124,13 @@ def acp_agent_loop(backend: FakeBackend) -> VibeAcpAgentLoop:
|
||||||
return _create_acp_agent()
|
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
|
@pytest.fixture
|
||||||
def skills_dir(tmp_path: Path) -> Path:
|
def skills_dir(tmp_path: Path) -> Path:
|
||||||
d = tmp_path / "skills"
|
d = tmp_path / "skills"
|
||||||
|
|
@ -159,6 +215,264 @@ class TestHandleCompact:
|
||||||
mock_compact.assert_called_once()
|
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:
|
class TestHandleReload:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reload_calls_reload_with_initial_messages(
|
async def test_reload_calls_reload_with_initial_messages(
|
||||||
|
|
@ -213,6 +527,19 @@ class TestCommandFallthrough:
|
||||||
texts = _get_message_texts(acp_agent_loop)
|
texts = _get_message_texts(acp_agent_loop)
|
||||||
assert any("Hi" in t for t in texts)
|
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:
|
class TestAvailableCommandsWithSkills:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -224,7 +551,7 @@ class TestAvailableCommandsWithSkills:
|
||||||
await acp_agent_loop_with_skills.new_session(
|
await acp_agent_loop_with_skills.new_session(
|
||||||
cwd=str(Path.cwd()), mcp_servers=[]
|
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
|
updates = _get_client(acp_agent_loop_with_skills)._session_updates
|
||||||
available = [
|
available = [
|
||||||
|
|
@ -247,7 +574,7 @@ class TestAvailableCommandsWithSkills:
|
||||||
await acp_agent_loop_with_skills.new_session(
|
await acp_agent_loop_with_skills.new_session(
|
||||||
cwd=str(Path.cwd()), mcp_servers=[]
|
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
|
updates = _get_client(acp_agent_loop_with_skills)._session_updates
|
||||||
available = [
|
available = [
|
||||||
|
|
|
||||||
|
|
@ -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:
|
class TestACPInitialize:
|
||||||
@pytest.mark.asyncio
|
@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()
|
acp_agent_loop = build_acp_agent_loop()
|
||||||
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
||||||
|
|
||||||
|
|
@ -62,7 +67,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
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
|
assert response.auth_methods is not None
|
||||||
|
|
@ -73,7 +78,9 @@ class TestACPInitialize:
|
||||||
assert auth_method.description == BROWSER_AUTH_DESCRIPTION
|
assert auth_method.description == BROWSER_AUTH_DESCRIPTION
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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."""
|
"""Test initialize with terminal-auth capabilities to check it was included."""
|
||||||
acp_agent_loop = build_acp_agent_loop()
|
acp_agent_loop = build_acp_agent_loop()
|
||||||
client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
|
client_capabilities = ClientCapabilities(field_meta={"terminal-auth": True})
|
||||||
|
|
@ -94,7 +101,7 @@ class TestACPInitialize:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert response.agent_info == Implementation(
|
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
|
assert response.auth_methods is not None
|
||||||
|
|
@ -109,6 +116,8 @@ class TestACPInitialize:
|
||||||
assert auth_method.id == "vibe-setup"
|
assert auth_method.id == "vibe-setup"
|
||||||
assert auth_method.name == "Register your API Key"
|
assert auth_method.name == "Register your API Key"
|
||||||
assert auth_method.description == "Register your API Key inside Mistral Vibe"
|
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 auth_method.field_meta is not None
|
||||||
assert "terminal-auth" in auth_method.field_meta
|
assert "terminal-auth" in auth_method.field_meta
|
||||||
terminal_auth_meta = auth_method.field_meta["terminal-auth"]
|
terminal_auth_meta = auth_method.field_meta["terminal-auth"]
|
||||||
|
|
@ -118,7 +127,9 @@ class TestACPInitialize:
|
||||||
assert terminal_auth_meta["label"] == "Mistral Vibe Setup"
|
assert terminal_auth_meta["label"] == "Mistral Vibe Setup"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
acp_agent_loop = build_acp_agent_loop()
|
||||||
client_capabilities = ClientCapabilities(
|
client_capabilities = ClientCapabilities(
|
||||||
field_meta={"browser-auth-delegated": True}
|
field_meta={"browser-auth-delegated": True}
|
||||||
|
|
@ -156,3 +167,43 @@ class TestACPInitialize:
|
||||||
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
response = await acp_agent_loop.initialize(protocol_version=PROTOCOL_VERSION)
|
||||||
|
|
||||||
assert response.auth_methods == []
|
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",
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,34 @@ def _get_fake_client(acp_agent_loop: VibeAcpAgentLoop) -> FakeClient:
|
||||||
return acp_agent_loop.client
|
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:
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_available_commands_sent_on_new_session(
|
async def test_available_commands_sent_on_new_session(
|
||||||
self, acp_agent_loop: VibeAcpAgentLoop
|
self, acp_agent_loop: VibeAcpAgentLoop
|
||||||
) -> None:
|
) -> None:
|
||||||
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
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
|
updates = _get_fake_client(acp_agent_loop)._session_updates
|
||||||
available_commands_updates = [
|
available_commands_updates = [
|
||||||
|
|
@ -62,7 +82,7 @@ class TestAvailableCommandsUpdate:
|
||||||
) -> None:
|
) -> None:
|
||||||
await acp_agent_loop.new_session(cwd=str(Path.cwd()), mcp_servers=[])
|
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
|
updates = _get_fake_client(acp_agent_loop)._session_updates
|
||||||
available_commands_updates = [
|
available_commands_updates = [
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from vibe.acp.acp_agent_loop import VibeAcpAgentLoop
|
||||||
from vibe.core.agent_loop import AgentLoop
|
from vibe.core.agent_loop import AgentLoop
|
||||||
from vibe.core.agents.models import BuiltinAgentName
|
from vibe.core.agents.models import BuiltinAgentName
|
||||||
from vibe.core.config import ModelConfig, VibeConfig
|
from vibe.core.config import ModelConfig, VibeConfig
|
||||||
|
from vibe.core.middleware import TurnLimitMiddleware
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -399,3 +400,131 @@ class TestACPSetConfigOptionThinking:
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response is None
|
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
|
||||||
|
|
|
||||||
224
tests/cli/test_startup_update_prompt.py
Normal file
224
tests/cli/test_startup_update_prompt.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -54,7 +54,6 @@ def get_base_config() -> dict[str, Any]:
|
||||||
"alias": "devstral-latest",
|
"alias": "devstral-latest",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"enable_auto_update": False,
|
|
||||||
"enable_telemetry": False,
|
"enable_telemetry": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
34
tests/core/test_build_attachment_counts.py
Normal file
34
tests/core/test_build_attachment_counts.py
Normal file
|
|
@ -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) == {}
|
||||||
|
|
@ -15,7 +15,11 @@ from vibe.core.telemetry.build_metadata import (
|
||||||
build_request_metadata,
|
build_request_metadata,
|
||||||
)
|
)
|
||||||
from vibe.core.telemetry.send import TelemetryClient
|
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.tools.base import BaseTool, ToolPermission
|
||||||
from vibe.core.types import Backend
|
from vibe.core.types import Backend
|
||||||
from vibe.core.utils import get_user_agent
|
from vibe.core.utils import get_user_agent
|
||||||
|
|
@ -694,6 +698,43 @@ class TestTelemetryClient:
|
||||||
assert properties["call_source"] == "vibe_code"
|
assert properties["call_source"] == "vibe_code"
|
||||||
assert properties["call_type"] == "main_call"
|
assert properties["call_type"] == "main_call"
|
||||||
assert properties["message_id"] is None
|
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(
|
def test_send_user_rating_feedback_payload(
|
||||||
self, telemetry_events: list[dict[str, Any]]
|
self, telemetry_events: list[dict[str, Any]]
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ def write_e2e_config(vibe_home: Path, api_base: str) -> None:
|
||||||
"\n".join([
|
"\n".join([
|
||||||
'active_model = "mock-model"',
|
'active_model = "mock-model"',
|
||||||
"enable_update_checks = false",
|
"enable_update_checks = false",
|
||||||
"enable_auto_update = false",
|
|
||||||
"disable_welcome_banner_animation = true",
|
"disable_welcome_banner_animation = true",
|
||||||
"",
|
"",
|
||||||
"[[providers]]",
|
"[[providers]]",
|
||||||
|
|
|
||||||
0
tests/setup/__init__.py
Normal file
0
tests/setup/__init__.py
Normal file
127
tests/setup/test_update_prompt_dialog.py
Normal file
127
tests/setup/test_update_prompt_dialog.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -1,204 +0,0 @@
|
||||||
<svg class="rich-terminal" viewBox="0 0 1482 928.4" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<!-- Generated with Rich https://www.textualize.io -->
|
|
||||||
<style>
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "Fira Code";
|
|
||||||
src: local("FiraCode-Regular"),
|
|
||||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
|
|
||||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
@font-face {
|
|
||||||
font-family: "Fira Code";
|
|
||||||
src: local("FiraCode-Bold"),
|
|
||||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
|
|
||||||
url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
|
|
||||||
font-style: bold;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-matrix {
|
|
||||||
font-family: Fira Code, monospace;
|
|
||||||
font-size: 20px;
|
|
||||||
line-height: 24.4px;
|
|
||||||
font-variant-east-asian: full-width;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
font-family: arial;
|
|
||||||
}
|
|
||||||
|
|
||||||
.terminal-r1 { fill: #c5c8c6 }
|
|
||||||
.terminal-r2 { fill: #ff8205;font-weight: bold }
|
|
||||||
.terminal-r3 { fill: #68a0b3 }
|
|
||||||
.terminal-r4 { fill: #ff8205 }
|
|
||||||
.terminal-r5 { fill: #98729f;font-weight: bold }
|
|
||||||
.terminal-r6 { fill: #98a84b }
|
|
||||||
.terminal-r7 { fill: #98a84b;font-weight: bold }
|
|
||||||
.terminal-r8 { fill: #868887 }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<defs>
|
|
||||||
<clipPath id="terminal-clip-terminal">
|
|
||||||
<rect x="0" y="0" width="1463.0" height="877.4" />
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-0">
|
|
||||||
<rect x="0" y="1.5" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-1">
|
|
||||||
<rect x="0" y="25.9" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-2">
|
|
||||||
<rect x="0" y="50.3" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-3">
|
|
||||||
<rect x="0" y="74.7" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-4">
|
|
||||||
<rect x="0" y="99.1" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-5">
|
|
||||||
<rect x="0" y="123.5" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-6">
|
|
||||||
<rect x="0" y="147.9" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-7">
|
|
||||||
<rect x="0" y="172.3" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-8">
|
|
||||||
<rect x="0" y="196.7" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-9">
|
|
||||||
<rect x="0" y="221.1" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-10">
|
|
||||||
<rect x="0" y="245.5" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-11">
|
|
||||||
<rect x="0" y="269.9" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-12">
|
|
||||||
<rect x="0" y="294.3" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-13">
|
|
||||||
<rect x="0" y="318.7" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-14">
|
|
||||||
<rect x="0" y="343.1" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-15">
|
|
||||||
<rect x="0" y="367.5" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-16">
|
|
||||||
<rect x="0" y="391.9" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-17">
|
|
||||||
<rect x="0" y="416.3" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-18">
|
|
||||||
<rect x="0" y="440.7" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-19">
|
|
||||||
<rect x="0" y="465.1" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-20">
|
|
||||||
<rect x="0" y="489.5" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-21">
|
|
||||||
<rect x="0" y="513.9" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-22">
|
|
||||||
<rect x="0" y="538.3" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-23">
|
|
||||||
<rect x="0" y="562.7" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-24">
|
|
||||||
<rect x="0" y="587.1" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-25">
|
|
||||||
<rect x="0" y="611.5" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-26">
|
|
||||||
<rect x="0" y="635.9" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-27">
|
|
||||||
<rect x="0" y="660.3" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-28">
|
|
||||||
<rect x="0" y="684.7" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-29">
|
|
||||||
<rect x="0" y="709.1" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-30">
|
|
||||||
<rect x="0" y="733.5" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-31">
|
|
||||||
<rect x="0" y="757.9" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-32">
|
|
||||||
<rect x="0" y="782.3" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-33">
|
|
||||||
<rect x="0" y="806.7" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
<clipPath id="terminal-line-34">
|
|
||||||
<rect x="0" y="831.1" width="1464" height="24.65"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="1480" height="926.4" rx="8"/><text class="terminal-title" fill="#c5c8c6" text-anchor="middle" x="740" y="27">SnapshotTestAppWithUpdate</text>
|
|
||||||
<g transform="translate(26,22)">
|
|
||||||
<circle cx="0" cy="0" r="7" fill="#ff5f57"/>
|
|
||||||
<circle cx="22" cy="0" r="7" fill="#febc2e"/>
|
|
||||||
<circle cx="44" cy="0" r="7" fill="#28c840"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<g transform="translate(9, 41)" clip-path="url(#terminal-clip-terminal)">
|
|
||||||
<rect fill="#4b4e55" x="719.8" y="611.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="611.5" width="707.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="635.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="744.2" y="635.9" width="195.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="939.4" y="635.9" width="500.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="660.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="744.2" y="660.3" width="207.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="951.6" y="660.3" width="488" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="684.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="744.2" y="684.7" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="1378.6" y="684.7" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="719.8" y="709.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#4b4e55" x="732" y="709.1" width="707.6" height="24.65" shape-rendering="crispEdges"/>
|
|
||||||
<g class="terminal-matrix">
|
|
||||||
<text class="terminal-r1" x="1464" y="20" textLength="12.2" clip-path="url(#terminal-line-0)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="44.4" textLength="12.2" clip-path="url(#terminal-line-1)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="68.8" textLength="12.2" clip-path="url(#terminal-line-2)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="93.2" textLength="12.2" clip-path="url(#terminal-line-3)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="117.6" textLength="12.2" clip-path="url(#terminal-line-4)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="142" textLength="12.2" clip-path="url(#terminal-line-5)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="166.4" textLength="12.2" clip-path="url(#terminal-line-6)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="190.8" textLength="12.2" clip-path="url(#terminal-line-7)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="215.2" textLength="12.2" clip-path="url(#terminal-line-8)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="239.6" textLength="12.2" clip-path="url(#terminal-line-9)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="264" textLength="12.2" clip-path="url(#terminal-line-10)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="288.4" textLength="12.2" clip-path="url(#terminal-line-11)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="312.8" textLength="12.2" clip-path="url(#terminal-line-12)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="337.2" textLength="12.2" clip-path="url(#terminal-line-13)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="361.6" textLength="12.2" clip-path="url(#terminal-line-14)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="386" textLength="12.2" clip-path="url(#terminal-line-15)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="410.4" textLength="12.2" clip-path="url(#terminal-line-16)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="434.8" textLength="12.2" clip-path="url(#terminal-line-17)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="459.2" textLength="12.2" clip-path="url(#terminal-line-18)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="483.6" textLength="12.2" clip-path="url(#terminal-line-19)">
|
|
||||||
</text><text class="terminal-r1" x="0" y="508" textLength="134.2" clip-path="url(#terminal-line-20)">  ⡠⣒⠄  ⡔⢄⠔⡄</text><text class="terminal-r2" x="170.8" y="508" textLength="146.4" clip-path="url(#terminal-line-20)">Mistral Vibe</text><text class="terminal-r1" x="317.2" y="508" textLength="122" clip-path="url(#terminal-line-20)"> v0.0.0 · </text><text class="terminal-r3" x="439.2" y="508" textLength="244" clip-path="url(#terminal-line-20)">devstral-latest[off]</text><text class="terminal-r1" x="683.2" y="508" textLength="256.2" clip-path="url(#terminal-line-20)"> · [Subscription] Pro</text><text class="terminal-r1" x="1464" y="508" textLength="12.2" clip-path="url(#terminal-line-20)">
|
|
||||||
</text><text class="terminal-r1" x="0" y="532.4" textLength="134.2" clip-path="url(#terminal-line-21)"> ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣</text><text class="terminal-r1" x="170.8" y="532.4" textLength="414.8" clip-path="url(#terminal-line-21)">1 model · 0 MCP servers · 0 skills</text><text class="terminal-r1" x="1464" y="532.4" textLength="12.2" clip-path="url(#terminal-line-21)">
|
|
||||||
</text><text class="terminal-r1" x="0" y="556.8" textLength="134.2" clip-path="url(#terminal-line-22)">  ⠉⠒⠣⠤⠵⠤⠬⠮⠆</text><text class="terminal-r1" x="170.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">Type </text><text class="terminal-r3" x="231.8" y="556.8" textLength="61" clip-path="url(#terminal-line-22)">/help</text><text class="terminal-r1" x="292.8" y="556.8" textLength="256.2" clip-path="url(#terminal-line-22)"> for more information</text><text class="terminal-r1" x="1464" y="556.8" textLength="12.2" clip-path="url(#terminal-line-22)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="581.2" textLength="12.2" clip-path="url(#terminal-line-23)">
|
|
||||||
</text><text class="terminal-r4" x="0" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">┃</text><text class="terminal-r5" x="24.4" y="605.6" textLength="122" clip-path="url(#terminal-line-24)">What's New</text><text class="terminal-r1" x="1464" y="605.6" textLength="12.2" clip-path="url(#terminal-line-24)">
|
|
||||||
</text><text class="terminal-r4" x="0" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">┃</text><text class="terminal-r1" x="24.4" y="630" textLength="134.2" clip-path="url(#terminal-line-25)">• Feature 1</text><text class="terminal-r6" x="719.8" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">▌</text><text class="terminal-r1" x="1464" y="630" textLength="12.2" clip-path="url(#terminal-line-25)">
|
|
||||||
</text><text class="terminal-r4" x="0" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">┃</text><text class="terminal-r1" x="24.4" y="654.4" textLength="134.2" clip-path="url(#terminal-line-26)">• Feature 2</text><text class="terminal-r6" x="719.8" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">▌</text><text class="terminal-r7" x="744.2" y="654.4" textLength="195.2" clip-path="url(#terminal-line-26)">Update available</text><text class="terminal-r1" x="1464" y="654.4" textLength="12.2" clip-path="url(#terminal-line-26)">
|
|
||||||
</text><text class="terminal-r6" x="719.8" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">▌</text><text class="terminal-r1" x="744.2" y="678.8" textLength="207.4" clip-path="url(#terminal-line-27)">1.0.4 => 1000.2.0</text><text class="terminal-r1" x="1464" y="678.8" textLength="12.2" clip-path="url(#terminal-line-27)">
|
|
||||||
</text><text class="terminal-r6" x="719.8" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">▌</text><text class="terminal-r1" x="744.2" y="703.2" textLength="634.4" clip-path="url(#terminal-line-28)">Please update mistral-vibe with your package manager</text><text class="terminal-r1" x="1464" y="703.2" textLength="12.2" clip-path="url(#terminal-line-28)">
|
|
||||||
</text><text class="terminal-r6" x="719.8" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">▌</text><text class="terminal-r1" x="1464" y="727.6" textLength="12.2" clip-path="url(#terminal-line-29)">
|
|
||||||
</text><text class="terminal-r1" x="0" y="752" textLength="1464" clip-path="url(#terminal-line-30)">────────────────────────────────────────────────────────────────────────────────────────────────────────────── default ─</text><text class="terminal-r1" x="1464" y="752" textLength="12.2" clip-path="url(#terminal-line-30)">
|
|
||||||
</text><text class="terminal-r2" x="0" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">></text><text class="terminal-r1" x="1464" y="776.4" textLength="12.2" clip-path="url(#terminal-line-31)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="800.8" textLength="12.2" clip-path="url(#terminal-line-32)">
|
|
||||||
</text><text class="terminal-r1" x="1464" y="825.2" textLength="12.2" clip-path="url(#terminal-line-33)">
|
|
||||||
</text><text class="terminal-r1" x="0" y="849.6" textLength="1464" clip-path="url(#terminal-line-34)">────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────</text><text class="terminal-r1" x="1464" y="849.6" textLength="12.2" clip-path="url(#terminal-line-34)">
|
|
||||||
</text><text class="terminal-r8" x="0" y="874" textLength="158.6" clip-path="url(#terminal-line-35)">/test/workdir</text><text class="terminal-r8" x="1256.6" y="874" textLength="207.4" clip-path="url(#terminal-line-35)">0% of 200k tokens</text>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 16 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
|
|
@ -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,
|
|
||||||
)
|
|
||||||
42
tests/snapshots/test_ui_snapshot_update_prompt.py
Normal file
42
tests/snapshots/test_ui_snapshot_update_prompt.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -7,7 +7,6 @@ import time
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from textual.app import Notification
|
|
||||||
|
|
||||||
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
from tests.conftest import build_test_vibe_app, build_test_vibe_config
|
||||||
from tests.update_notifier.adapters.fake_update_cache_repository import (
|
from tests.update_notifier.adapters.fake_update_cache_repository import (
|
||||||
|
|
@ -50,21 +49,6 @@ def build_update_test_app(
|
||||||
return _build
|
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(
|
async def _assert_no_notifications(
|
||||||
app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
|
app: VibeApp, pilot, *, timeout: float = 1.0, interval: float = 0.05
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -85,25 +69,25 @@ def vibe_config_with_update_checks_enabled() -> VibeConfig:
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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],
|
build_update_test_app: Callable[..., VibeApp],
|
||||||
) -> None:
|
) -> None:
|
||||||
notifier = FakeUpdateGateway(update=Update(latest_version="0.2.0"))
|
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:
|
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 notifier.fetch_update_calls == 1
|
||||||
assert notification.title == "Update available"
|
assert repository.update_cache is not None
|
||||||
assert (
|
assert repository.update_cache.latest_version == "0.2.0"
|
||||||
notification.message
|
|
||||||
== "0.1.0 => 0.2.0\nPlease update mistral-vibe with your package manager"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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],
|
build_update_test_app: Callable[..., VibeApp],
|
||||||
) -> None:
|
) -> None:
|
||||||
notifier = FakeUpdateGateway(update=None)
|
notifier = FakeUpdateGateway(update=None)
|
||||||
|
|
@ -115,7 +99,7 @@ async def test_ui_does_not_display_update_notification_when_not_available(
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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],
|
build_update_test_app: Callable[..., VibeApp],
|
||||||
) -> None:
|
) -> None:
|
||||||
notifier = FakeUpdateGateway(
|
notifier = FakeUpdateGateway(
|
||||||
|
|
@ -124,13 +108,7 @@ async def test_ui_displays_warning_toast_when_check_fails(
|
||||||
app = build_update_test_app(update_notifier=notifier)
|
app = build_update_test_app(update_notifier=notifier)
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
async with app.run_test() as pilot:
|
||||||
await pilot.pause(0.3)
|
await _assert_no_notifications(app, pilot, timeout=0.3)
|
||||||
notifications = list(app._notifications)
|
|
||||||
|
|
||||||
assert notifications
|
|
||||||
warning = notifications[-1]
|
|
||||||
assert warning.severity == "warning"
|
|
||||||
assert "forbidden" in warning.message.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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
|
@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],
|
build_update_test_app: Callable[..., VibeApp],
|
||||||
) -> None:
|
) -> None:
|
||||||
timestamp_two_days_ago = int(time.time()) - 2 * 24 * 60 * 60
|
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(
|
update_cache = UpdateCache(
|
||||||
latest_version="0.2.0", stored_at_timestamp=timestamp_two_days_ago
|
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:
|
async with app.run_test() as pilot:
|
||||||
await pilot.pause(0.3)
|
await _assert_no_notifications(app, pilot, timeout=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"
|
|
||||||
)
|
|
||||||
assert notifier.fetch_update_calls == 1
|
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(
|
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 is not None
|
||||||
assert repository.update_cache.seen_whats_new_version == "1.0.0"
|
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"
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,12 @@ from vibe.cli.update_notifier import (
|
||||||
UpdateGatewayCause,
|
UpdateGatewayCause,
|
||||||
UpdateGatewayError,
|
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
|
@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 is not None
|
||||||
assert update_cache_repository.update_cache.latest_version == "1.0.0"
|
assert update_cache_repository.update_cache.latest_version == "1.0.0"
|
||||||
assert update_cache_repository.update_cache.stored_at_timestamp == current_timestamp
|
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"
|
||||||
|
|
|
||||||
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -825,7 +825,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mistral-vibe"
|
name = "mistral-vibe"
|
||||||
version = "2.14.0"
|
version = "2.14.1"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "agent-client-protocol" },
|
{ name = "agent-client-protocol" },
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VIBE_ROOT = Path(__file__).parent
|
VIBE_ROOT = Path(__file__).parent
|
||||||
__version__ = "2.14.0"
|
__version__ = "2.14.1"
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ from pydantic import AliasChoices, BaseModel, ConfigDict, Field, ValidationError
|
||||||
|
|
||||||
from vibe import VIBE_ROOT, __version__
|
from vibe import VIBE_ROOT, __version__
|
||||||
from vibe.acp.acp_logger import acp_message_observer
|
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 (
|
from vibe.acp.exceptions import (
|
||||||
ConfigurationError,
|
ConfigurationError,
|
||||||
ContextTooLongError,
|
ContextTooLongError,
|
||||||
|
|
@ -86,6 +86,7 @@ from vibe.acp.exceptions import (
|
||||||
UnauthenticatedError,
|
UnauthenticatedError,
|
||||||
)
|
)
|
||||||
from vibe.acp.session import AcpSessionLoop
|
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.title import acp_blocks_to_title_segments
|
||||||
from vibe.acp.tools.base import BaseAcpTool
|
from vibe.acp.tools.base import BaseAcpTool
|
||||||
from vibe.acp.tools.events import ToolTerminalOpenedEvent
|
from vibe.acp.tools.events import ToolTerminalOpenedEvent
|
||||||
|
|
@ -109,6 +110,7 @@ from vibe.acp.utils import (
|
||||||
create_tool_result_replay,
|
create_tool_result_replay,
|
||||||
create_user_message_replay,
|
create_user_message_replay,
|
||||||
get_proxy_help_text,
|
get_proxy_help_text,
|
||||||
|
is_jetbrains_client,
|
||||||
is_valid_acp_mode,
|
is_valid_acp_mode,
|
||||||
make_thinking_response,
|
make_thinking_response,
|
||||||
)
|
)
|
||||||
|
|
@ -186,6 +188,7 @@ from vibe.setup.onboarding.context import OnboardingContext
|
||||||
logger = logging.getLogger("vibe")
|
logger = logging.getLogger("vibe")
|
||||||
|
|
||||||
NON_INTERACTIVE_DISABLED_TOOLS = ["ask_user_question", "exit_plan_mode"]
|
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:
|
def _merge_non_interactive_disabled_tools(config: VibeConfig) -> None:
|
||||||
|
|
@ -354,6 +357,7 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
id="vibe-setup",
|
id="vibe-setup",
|
||||||
name="Register your API Key",
|
name="Register your API Key",
|
||||||
description="Register your API Key inside Mistral Vibe",
|
description="Register your API Key inside Mistral Vibe",
|
||||||
|
args=args,
|
||||||
field_meta={
|
field_meta={
|
||||||
"terminal-auth": {
|
"terminal-auth": {
|
||||||
"command": command,
|
"command": command,
|
||||||
|
|
@ -448,6 +452,12 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
if supports_terminal_auth:
|
if supports_terminal_auth:
|
||||||
auth_methods.append(self._build_terminal_auth_method(command, args))
|
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(
|
response = InitializeResponse(
|
||||||
agent_capabilities=AgentCapabilities(
|
agent_capabilities=AgentCapabilities(
|
||||||
load_session=True,
|
load_session=True,
|
||||||
|
|
@ -606,7 +616,11 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
async def _create_acp_session(
|
async def _create_acp_session(
|
||||||
self, session_id: str, agent_loop: AgentLoop
|
self, session_id: str, agent_loop: AgentLoop
|
||||||
) -> AcpSessionLoop:
|
) -> AcpSessionLoop:
|
||||||
command_registry = AcpCommandRegistry()
|
command_registry = AcpCommandRegistry(
|
||||||
|
availability_context=AcpCommandAvailabilityContext(
|
||||||
|
vibe_code_enabled=agent_loop.base_config.vibe_code_enabled
|
||||||
|
)
|
||||||
|
)
|
||||||
session = AcpSessionLoop(
|
session = AcpSessionLoop(
|
||||||
id=session_id, agent_loop=agent_loop, command_registry=command_registry
|
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:
|
if not agent_loop.bypass_tool_permissions:
|
||||||
agent_loop.set_approval_callback(self._create_approval_callback(session.id))
|
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))
|
session.spawn(self._warm_up_agent_loop(agent_loop))
|
||||||
|
|
||||||
return session
|
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:
|
async def _warm_up_agent_loop(self, agent_loop: AgentLoop) -> None:
|
||||||
"""Proactively await deferred init so `vibe.ready` telemetry is emitted
|
"""Proactively await deferred init so `vibe.ready` telemetry is emitted
|
||||||
without waiting for the user's first prompt. Errors are swallowed here
|
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(
|
success = await self._apply_thinking_change(
|
||||||
session, cast(ThinkingLevel, value)
|
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 _:
|
case _:
|
||||||
success = False
|
success = False
|
||||||
|
|
||||||
|
|
@ -1701,6 +1729,11 @@ class VibeAcpAgentLoop(AcpAgent):
|
||||||
)
|
)
|
||||||
return PromptResponse(stop_reason="end_turn", user_message_id=message_id)
|
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(
|
async def _handle_help(
|
||||||
self, session: AcpSessionLoop, text_prompt: str, message_id: str
|
self, session: AcpSessionLoop, text_prompt: str, message_id: str
|
||||||
) -> PromptResponse:
|
) -> PromptResponse:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from vibe.acp.commands.registry import AcpCommandRegistry
|
from vibe.acp.commands.registry import AcpCommandAvailabilityContext, AcpCommandRegistry
|
||||||
|
|
||||||
__all__ = ["AcpCommandRegistry"]
|
__all__ = ["AcpCommandAvailabilityContext", "AcpCommandRegistry"]
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,19 @@ from dataclasses import dataclass, field
|
||||||
type OnCommandsChanged = Callable[[], Awaitable[None]]
|
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)
|
@dataclass(frozen=True)
|
||||||
class AcpCommand:
|
class AcpCommand:
|
||||||
"""Command advertised to ACP clients via available_commands_update."""
|
"""Command advertised to ACP clients via available_commands_update."""
|
||||||
|
|
@ -14,18 +27,31 @@ class AcpCommand:
|
||||||
description: str
|
description: str
|
||||||
handler: str
|
handler: str
|
||||||
input_hint: str | None = None
|
input_hint: str | None = None
|
||||||
|
is_available: CommandAvailability | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AcpCommandRegistry:
|
class AcpCommandRegistry:
|
||||||
"""Registry of ACP commands. Notifies listeners when commands change."""
|
"""Registry of ACP commands. Notifies listeners when commands change."""
|
||||||
|
|
||||||
|
availability_context: AcpCommandAvailabilityContext = field(
|
||||||
|
default_factory=AcpCommandAvailabilityContext
|
||||||
|
)
|
||||||
_commands: dict[str, AcpCommand] = field(default_factory=dict)
|
_commands: dict[str, AcpCommand] = field(default_factory=dict)
|
||||||
_on_changed: OnCommandsChanged | None = None
|
_on_changed: OnCommandsChanged | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if not self._commands:
|
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:
|
def set_on_changed(self, callback: OnCommandsChanged) -> None:
|
||||||
self._on_changed = callback
|
self._on_changed = callback
|
||||||
|
|
@ -65,6 +91,12 @@ def _build_commands() -> dict[str, AcpCommand]:
|
||||||
description="Show path to current session log directory",
|
description="Show path to current session log directory",
|
||||||
handler="_handle_log",
|
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(
|
"proxy-setup": AcpCommand(
|
||||||
name="proxy-setup",
|
name="proxy-setup",
|
||||||
description="Configure proxy and SSL certificate settings",
|
description="Configure proxy and SSL certificate settings",
|
||||||
|
|
|
||||||
334
vibe/acp/teleport.py
Normal file
334
vibe/acp/teleport.py
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from contextlib import aclosing
|
||||||
|
from typing import Literal, cast
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from acp import Client, PromptResponse
|
||||||
|
from acp.schema import (
|
||||||
|
AgentMessageChunk,
|
||||||
|
AllowedOutcome,
|
||||||
|
ContentToolCallContent,
|
||||||
|
PermissionOption,
|
||||||
|
TextContentBlock,
|
||||||
|
ToolCallProgress,
|
||||||
|
ToolCallStart,
|
||||||
|
ToolCallStatus,
|
||||||
|
ToolCallUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
from vibe.acp.session import AcpSessionLoop
|
||||||
|
from vibe.core.agent_loop import TeleportError
|
||||||
|
from vibe.core.teleport.telemetry import send_teleport_early_failure_telemetry
|
||||||
|
from vibe.core.teleport.types import (
|
||||||
|
TeleportCheckingGitEvent,
|
||||||
|
TeleportCompleteEvent,
|
||||||
|
TeleportPushingEvent,
|
||||||
|
TeleportPushRequiredEvent,
|
||||||
|
TeleportPushResponseEvent,
|
||||||
|
TeleportStartingWorkflowEvent,
|
||||||
|
)
|
||||||
|
from vibe.core.types import Role
|
||||||
|
|
||||||
|
TELEPORT_PUSH_OPTION_ID = "teleport_push_and_continue"
|
||||||
|
TELEPORT_CANCEL_OPTION_ID = "teleport_cancel"
|
||||||
|
TELEPORT_FIELD_META_KEY = "teleport"
|
||||||
|
type TeleportAcpStatus = Literal[
|
||||||
|
"starting",
|
||||||
|
"preparing_workspace",
|
||||||
|
"push_required",
|
||||||
|
"syncing_remote",
|
||||||
|
"starting_workflow",
|
||||||
|
"completed",
|
||||||
|
"failed",
|
||||||
|
"no_history",
|
||||||
|
"unavailable",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _teleport_field_meta(
|
||||||
|
status: TeleportAcpStatus,
|
||||||
|
*,
|
||||||
|
url: str | None = None,
|
||||||
|
unpushed_count: int | None = None,
|
||||||
|
branch_not_pushed: bool | None = None,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
teleport_meta: dict[str, object] = {"status": status}
|
||||||
|
if url is not None:
|
||||||
|
teleport_meta["url"] = url
|
||||||
|
if unpushed_count is not None:
|
||||||
|
teleport_meta["unpushedCount"] = unpushed_count
|
||||||
|
if branch_not_pushed is not None:
|
||||||
|
teleport_meta["branchNotPushed"] = branch_not_pushed
|
||||||
|
return {"tool_name": "teleport", TELEPORT_FIELD_META_KEY: teleport_meta}
|
||||||
|
|
||||||
|
|
||||||
|
def _teleport_progress_update(
|
||||||
|
tool_call_id: str,
|
||||||
|
*,
|
||||||
|
title: str,
|
||||||
|
status: ToolCallStatus = "in_progress",
|
||||||
|
text: str | None = None,
|
||||||
|
raw_output: str | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
teleport_status: TeleportAcpStatus,
|
||||||
|
unpushed_count: int | None = None,
|
||||||
|
branch_not_pushed: bool | None = None,
|
||||||
|
) -> ToolCallProgress:
|
||||||
|
return ToolCallProgress(
|
||||||
|
session_update="tool_call_update",
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
title=title,
|
||||||
|
kind="other",
|
||||||
|
status=status,
|
||||||
|
raw_output=raw_output,
|
||||||
|
content=(
|
||||||
|
[
|
||||||
|
ContentToolCallContent(
|
||||||
|
type="content", content=TextContentBlock(type="text", text=text)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
if text
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
field_meta=_teleport_field_meta(
|
||||||
|
teleport_status,
|
||||||
|
url=url,
|
||||||
|
unpushed_count=unpushed_count,
|
||||||
|
branch_not_pushed=branch_not_pushed,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _teleport_command_reply(
|
||||||
|
client: Client,
|
||||||
|
session: AcpSessionLoop,
|
||||||
|
text: str,
|
||||||
|
message_id: str,
|
||||||
|
*,
|
||||||
|
field_meta: dict[str, object],
|
||||||
|
) -> PromptResponse:
|
||||||
|
await client.session_update(
|
||||||
|
session_id=session.id,
|
||||||
|
update=AgentMessageChunk(
|
||||||
|
session_update="agent_message_chunk",
|
||||||
|
content=TextContentBlock(type="text", text=text),
|
||||||
|
message_id=str(uuid4()),
|
||||||
|
field_meta=field_meta,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return PromptResponse(
|
||||||
|
stop_reason="end_turn", user_message_id=message_id, field_meta=field_meta
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _request_teleport_push_approval(
|
||||||
|
client: Client,
|
||||||
|
session: AcpSessionLoop,
|
||||||
|
tool_call_id: str,
|
||||||
|
*,
|
||||||
|
count: int,
|
||||||
|
branch_not_pushed: bool,
|
||||||
|
) -> TeleportPushResponseEvent:
|
||||||
|
if branch_not_pushed:
|
||||||
|
question = "Your branch doesn't exist on remote. Push to continue?"
|
||||||
|
else:
|
||||||
|
word = f"commit{'s' if count != 1 else ''}"
|
||||||
|
question = f"You have {count} unpushed {word}. Push to continue?"
|
||||||
|
|
||||||
|
await client.session_update(
|
||||||
|
session_id=session.id,
|
||||||
|
update=_teleport_progress_update(
|
||||||
|
tool_call_id,
|
||||||
|
title="Push required",
|
||||||
|
text=question,
|
||||||
|
teleport_status="push_required",
|
||||||
|
unpushed_count=count,
|
||||||
|
branch_not_pushed=branch_not_pushed,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.request_permission(
|
||||||
|
session_id=session.id,
|
||||||
|
tool_call=ToolCallUpdate(
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
title=question,
|
||||||
|
kind="execute",
|
||||||
|
status="pending",
|
||||||
|
field_meta=_teleport_field_meta(
|
||||||
|
"push_required",
|
||||||
|
unpushed_count=count,
|
||||||
|
branch_not_pushed=branch_not_pushed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
options=[
|
||||||
|
PermissionOption(
|
||||||
|
option_id=TELEPORT_PUSH_OPTION_ID,
|
||||||
|
name="Push and continue",
|
||||||
|
kind="allow_once",
|
||||||
|
),
|
||||||
|
PermissionOption(
|
||||||
|
option_id=TELEPORT_CANCEL_OPTION_ID, name="Cancel", kind="reject_once"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.outcome.outcome != "selected":
|
||||||
|
return TeleportPushResponseEvent(approved=False)
|
||||||
|
|
||||||
|
outcome = cast(AllowedOutcome, response.outcome)
|
||||||
|
return TeleportPushResponseEvent(
|
||||||
|
approved=outcome.option_id == TELEPORT_PUSH_OPTION_ID
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_teleport_command(
|
||||||
|
client: Client, session: AcpSessionLoop, message_id: str
|
||||||
|
) -> PromptResponse:
|
||||||
|
if not session.agent_loop.base_config.vibe_code_enabled:
|
||||||
|
return await _teleport_command_reply(
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
"Teleport is not available because Vibe Code is disabled.",
|
||||||
|
message_id,
|
||||||
|
field_meta=_teleport_field_meta("unavailable"),
|
||||||
|
)
|
||||||
|
|
||||||
|
last_user_message = next(
|
||||||
|
(
|
||||||
|
msg
|
||||||
|
for msg in reversed(session.agent_loop.messages)
|
||||||
|
if msg.role == Role.user and not msg.injected
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
has_resolvable_prompt = (
|
||||||
|
last_user_message is not None
|
||||||
|
and isinstance(last_user_message.content, str)
|
||||||
|
and bool(last_user_message.content)
|
||||||
|
)
|
||||||
|
if not has_resolvable_prompt:
|
||||||
|
send_teleport_early_failure_telemetry(
|
||||||
|
session.agent_loop.telemetry_client,
|
||||||
|
stage="no_history",
|
||||||
|
error_class="TeleportNoHistoryError",
|
||||||
|
nb_session_messages=len(session.agent_loop.messages[1:]),
|
||||||
|
)
|
||||||
|
return await _teleport_command_reply(
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
"No conversation history to teleport.",
|
||||||
|
message_id,
|
||||||
|
field_meta=_teleport_field_meta("no_history"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tool_call_id = str(uuid4())
|
||||||
|
await client.session_update(
|
||||||
|
session_id=session.id,
|
||||||
|
update=ToolCallStart(
|
||||||
|
session_update="tool_call",
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
title="Teleporting session to Vibe Code Web...",
|
||||||
|
kind="other",
|
||||||
|
status="in_progress",
|
||||||
|
content=[
|
||||||
|
ContentToolCallContent(
|
||||||
|
type="content",
|
||||||
|
content=TextContentBlock(
|
||||||
|
type="text", text="Preparing workspace..."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
field_meta=_teleport_field_meta("starting"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
final_url: str | None = None
|
||||||
|
try:
|
||||||
|
async with aclosing(session.agent_loop.teleport_to_vibe_code(None)) as events:
|
||||||
|
response: TeleportPushResponseEvent | None = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
event = await events.asend(response)
|
||||||
|
except StopAsyncIteration:
|
||||||
|
break
|
||||||
|
response = None
|
||||||
|
|
||||||
|
match event:
|
||||||
|
case TeleportCheckingGitEvent():
|
||||||
|
await client.session_update(
|
||||||
|
session_id=session.id,
|
||||||
|
update=_teleport_progress_update(
|
||||||
|
tool_call_id,
|
||||||
|
title="Preparing workspace...",
|
||||||
|
text="Preparing workspace...",
|
||||||
|
teleport_status="preparing_workspace",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
case TeleportPushRequiredEvent(
|
||||||
|
unpushed_count=count, branch_not_pushed=branch_not_pushed
|
||||||
|
):
|
||||||
|
response = await _request_teleport_push_approval(
|
||||||
|
client,
|
||||||
|
session,
|
||||||
|
tool_call_id,
|
||||||
|
count=count,
|
||||||
|
branch_not_pushed=branch_not_pushed,
|
||||||
|
)
|
||||||
|
case TeleportPushingEvent():
|
||||||
|
await client.session_update(
|
||||||
|
session_id=session.id,
|
||||||
|
update=_teleport_progress_update(
|
||||||
|
tool_call_id,
|
||||||
|
title="Syncing with remote...",
|
||||||
|
text="Syncing with remote...",
|
||||||
|
teleport_status="syncing_remote",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
case TeleportStartingWorkflowEvent():
|
||||||
|
await client.session_update(
|
||||||
|
session_id=session.id,
|
||||||
|
update=_teleport_progress_update(
|
||||||
|
tool_call_id,
|
||||||
|
title="Starting Vibe Code Web session...",
|
||||||
|
text="Starting Vibe Code Web session...",
|
||||||
|
teleport_status="starting_workflow",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
case TeleportCompleteEvent(url=url):
|
||||||
|
final_url = url
|
||||||
|
await client.session_update(
|
||||||
|
session_id=session.id,
|
||||||
|
update=_teleport_progress_update(
|
||||||
|
tool_call_id,
|
||||||
|
title="Teleported to Vibe Code Web",
|
||||||
|
status="completed",
|
||||||
|
text=f"Teleported to Vibe Code Web: {url}",
|
||||||
|
raw_output=url,
|
||||||
|
url=url,
|
||||||
|
teleport_status="completed",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except TeleportError as e:
|
||||||
|
await client.session_update(
|
||||||
|
session_id=session.id,
|
||||||
|
update=_teleport_progress_update(
|
||||||
|
tool_call_id,
|
||||||
|
title="Teleport failed",
|
||||||
|
status="failed",
|
||||||
|
text=str(e),
|
||||||
|
raw_output=str(e),
|
||||||
|
teleport_status="failed",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return PromptResponse(
|
||||||
|
stop_reason="end_turn",
|
||||||
|
user_message_id=message_id,
|
||||||
|
field_meta=_teleport_field_meta("failed"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return PromptResponse(
|
||||||
|
stop_reason="end_turn",
|
||||||
|
user_message_id=message_id,
|
||||||
|
field_meta=_teleport_field_meta("completed", url=final_url),
|
||||||
|
)
|
||||||
|
|
@ -7,6 +7,7 @@ from acp.schema import (
|
||||||
AgentMessageChunk,
|
AgentMessageChunk,
|
||||||
AgentThoughtChunk,
|
AgentThoughtChunk,
|
||||||
ContentToolCallContent,
|
ContentToolCallContent,
|
||||||
|
Implementation,
|
||||||
ModelInfo,
|
ModelInfo,
|
||||||
PermissionOption,
|
PermissionOption,
|
||||||
PermissionOptionKind,
|
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(
|
def build_mode_state(
|
||||||
profiles: list[AgentProfile], current_mode_id: str
|
profiles: list[AgentProfile], current_mode_id: str
|
||||||
) -> tuple[SessionModeState, SessionConfigOptionSelect]:
|
) -> tuple[SessionModeState, SessionConfigOptionSelect]:
|
||||||
|
|
|
||||||
158
vibe/cli/cli.py
158
vibe/cli/cli.py
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
@ -10,10 +11,16 @@ import tomli_w
|
||||||
|
|
||||||
from vibe import __version__
|
from vibe import __version__
|
||||||
from vibe.cli.textual_ui.app import StartupOptions, run_textual_ui
|
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.agent_loop import AgentLoop, TeleportError
|
||||||
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
|
from vibe.core.config import MissingAPIKeyError, VibeConfig, load_dotenv_values
|
||||||
from vibe.core.config.harness_files import get_harness_files_manager
|
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.logger import logger
|
||||||
from vibe.core.paths import HISTORY_FILE
|
from vibe.core.paths import HISTORY_FILE
|
||||||
from vibe.core.programmatic import run_programmatic
|
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.types import LLMMessage, OutputFormat, Role
|
||||||
from vibe.core.utils import ConversationLimitException
|
from vibe.core.utils import ConversationLimitException
|
||||||
from vibe.setup.onboarding import run_onboarding
|
from vibe.setup.onboarding import run_onboarding
|
||||||
|
from vibe.setup.update_prompt import UpdatePromptResult, ask_update_prompt
|
||||||
|
|
||||||
|
|
||||||
def _build_cli_entrypoint_metadata() -> EntrypointMetadata:
|
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:
|
def run_cli(args: argparse.Namespace) -> None:
|
||||||
load_dotenv_values()
|
load_dotenv_values()
|
||||||
bootstrap_config_files()
|
bootstrap_config_files()
|
||||||
|
|
@ -194,6 +290,11 @@ def run_cli(args: argparse.Namespace) -> None:
|
||||||
try:
|
try:
|
||||||
is_interactive = args.prompt is None
|
is_interactive = args.prompt is None
|
||||||
config = load_config_or_exit(interactive=is_interactive)
|
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)
|
initial_agent_name = get_initial_agent_name(args, config)
|
||||||
hook_config_result = load_hooks_from_fs(config)
|
hook_config_result = load_hooks_from_fs(config)
|
||||||
setup_tracing(config)
|
setup_tracing(config)
|
||||||
|
|
@ -204,50 +305,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
||||||
loaded_session = load_session(args, config)
|
loaded_session = load_session(args, config)
|
||||||
|
|
||||||
stdin_prompt = get_prompt_from_stdin()
|
stdin_prompt = get_prompt_from_stdin()
|
||||||
if args.prompt is not None:
|
if is_interactive:
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
agent_loop = AgentLoop(
|
agent_loop = AgentLoop(
|
||||||
config,
|
config,
|
||||||
|
|
@ -266,6 +324,7 @@ def run_cli(args: argparse.Namespace) -> None:
|
||||||
|
|
||||||
run_textual_ui(
|
run_textual_ui(
|
||||||
agent_loop=agent_loop,
|
agent_loop=agent_loop,
|
||||||
|
update_cache_repository=update_cache_repository,
|
||||||
startup=StartupOptions(
|
startup=StartupOptions(
|
||||||
initial_prompt=args.initial_prompt or stdin_prompt,
|
initial_prompt=args.initial_prompt or stdin_prompt,
|
||||||
teleport_on_start=args.teleport,
|
teleport_on_start=args.teleport,
|
||||||
|
|
@ -273,6 +332,15 @@ def run_cli(args: argparse.Namespace) -> None:
|
||||||
is_resuming_session=loaded_session is not 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):
|
except (KeyboardInterrupt, EOFError):
|
||||||
rprint("\n[dim]Bye![/]")
|
rprint("\n[dim]Bye![/]")
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,6 @@ from vibe.cli.textual_ui.windowing import (
|
||||||
sync_backfill_state,
|
sync_backfill_state,
|
||||||
)
|
)
|
||||||
from vibe.cli.update_notifier import (
|
from vibe.cli.update_notifier import (
|
||||||
FileSystemUpdateCacheRepository,
|
|
||||||
PyPIUpdateGateway,
|
PyPIUpdateGateway,
|
||||||
UpdateCacheRepository,
|
UpdateCacheRepository,
|
||||||
UpdateError,
|
UpdateError,
|
||||||
|
|
@ -122,7 +121,6 @@ from vibe.cli.update_notifier import (
|
||||||
mark_version_as_seen,
|
mark_version_as_seen,
|
||||||
should_show_whats_new,
|
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 import VoiceManager, VoiceManagerPort
|
||||||
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
|
from vibe.cli.voice_manager.voice_manager_port import TranscribeState
|
||||||
from vibe.cli.vscode_extension_promo import (
|
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")
|
asyncio.create_task(self._check_update(), name="version-update-check")
|
||||||
|
|
||||||
async def _check_update(self) -> None:
|
async def _check_update(self) -> None:
|
||||||
try:
|
if self._update_notifier is None or self._update_cache_repository is None:
|
||||||
if self._update_notifier is None or self._update_cache_repository is None:
|
return
|
||||||
return
|
|
||||||
|
|
||||||
update_availability = await get_update_if_available(
|
try:
|
||||||
|
await get_update_if_available(
|
||||||
update_notifier=self._update_notifier,
|
update_notifier=self._update_notifier,
|
||||||
current_version=self._current_version,
|
current_version=self._current_version,
|
||||||
update_cache_repository=self._update_cache_repository,
|
update_cache_repository=self._update_cache_repository,
|
||||||
)
|
)
|
||||||
except UpdateError as error:
|
except UpdateError as exc:
|
||||||
self.notify(
|
logger.warning("Update check failed", exc_info=exc)
|
||||||
error.message,
|
|
||||||
title="Update check failed",
|
|
||||||
severity="warning",
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Version update check failed", exc_info=exc)
|
logger.debug("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
|
|
||||||
)
|
|
||||||
|
|
||||||
def action_copy_selection(self) -> None:
|
def action_copy_selection(self) -> None:
|
||||||
copied_text = copy_selection_to_clipboard(self, show_toast=False)
|
copied_text = copy_selection_to_clipboard(self, show_toast=False)
|
||||||
|
|
@ -3364,12 +3333,13 @@ class VibeApp(App): # noqa: PLR0904
|
||||||
|
|
||||||
|
|
||||||
def run_textual_ui(
|
def run_textual_ui(
|
||||||
agent_loop: AgentLoop, startup: StartupOptions | None = None
|
agent_loop: AgentLoop,
|
||||||
|
update_cache_repository: UpdateCacheRepository,
|
||||||
|
startup: StartupOptions | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
from vibe.cli.stderr_guard import stderr_guard
|
from vibe.cli.stderr_guard import stderr_guard
|
||||||
|
|
||||||
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
|
update_notifier = PyPIUpdateGateway(project_name="mistral-vibe")
|
||||||
update_cache_repository = FileSystemUpdateCacheRepository()
|
|
||||||
plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url)
|
plan_offer_gateway = HttpWhoAmIGateway(base_url=agent_loop.config.console_base_url)
|
||||||
vscode_extension_promo_repository = FileSystemVscodeExtensionPromoRepository()
|
vscode_extension_promo_repository = FileSystemVscodeExtensionPromoRepository()
|
||||||
vscode_extension_promo = VscodeExtensionPromo(
|
vscode_extension_promo = VscodeExtensionPromo(
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ from vibe.cli.update_notifier.ports.update_gateway import (
|
||||||
from vibe.cli.update_notifier.update import (
|
from vibe.cli.update_notifier.update import (
|
||||||
UpdateAvailability,
|
UpdateAvailability,
|
||||||
UpdateError,
|
UpdateError,
|
||||||
|
get_pending_update_from_cache,
|
||||||
get_update_if_available,
|
get_update_if_available,
|
||||||
|
mark_update_as_dismissed,
|
||||||
)
|
)
|
||||||
from vibe.cli.update_notifier.whats_new import (
|
from vibe.cli.update_notifier.whats_new import (
|
||||||
load_whats_new_content,
|
load_whats_new_content,
|
||||||
|
|
@ -40,8 +42,10 @@ __all__ = [
|
||||||
"UpdateGateway",
|
"UpdateGateway",
|
||||||
"UpdateGatewayCause",
|
"UpdateGatewayCause",
|
||||||
"UpdateGatewayError",
|
"UpdateGatewayError",
|
||||||
|
"get_pending_update_from_cache",
|
||||||
"get_update_if_available",
|
"get_update_if_available",
|
||||||
"load_whats_new_content",
|
"load_whats_new_content",
|
||||||
|
"mark_update_as_dismissed",
|
||||||
"mark_version_as_seen",
|
"mark_version_as_seen",
|
||||||
"should_show_whats_new",
|
"should_show_whats_new",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,16 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
|
||||||
self._base_path = Path(base_path) if base_path is not None else VIBE_HOME.path
|
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._cache_file = self._base_path / "cache.toml"
|
||||||
self._legacy_json = self._base_path / "update_cache.json"
|
self._legacy_json = self._base_path / "update_cache.json"
|
||||||
|
self._cached: UpdateCache | None = None
|
||||||
|
self._loaded = False
|
||||||
|
|
||||||
async def get(self) -> UpdateCache | None:
|
async def get(self) -> UpdateCache | None:
|
||||||
|
if self._loaded:
|
||||||
|
return self._cached
|
||||||
data = await asyncio.to_thread(self._read_section)
|
data = await asyncio.to_thread(self._read_section)
|
||||||
if data is None:
|
self._cached = self._parse(data) if data is not None else None
|
||||||
return None
|
self._loaded = True
|
||||||
return self._parse(data)
|
return self._cached
|
||||||
|
|
||||||
async def set(self, update_cache: UpdateCache) -> None:
|
async def set(self, update_cache: UpdateCache) -> None:
|
||||||
payload: dict[str, str | int] = {
|
payload: dict[str, str | int] = {
|
||||||
|
|
@ -33,7 +37,11 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
|
||||||
}
|
}
|
||||||
if update_cache.seen_whats_new_version is not None:
|
if update_cache.seen_whats_new_version is not None:
|
||||||
payload["seen_whats_new_version"] = update_cache.seen_whats_new_version
|
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)
|
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:
|
def _read_section(self) -> dict | None:
|
||||||
cache = read_cache(self._cache_file)
|
cache = read_cache(self._cache_file)
|
||||||
|
|
@ -58,20 +66,22 @@ class FileSystemUpdateCacheRepository(UpdateCacheRepository):
|
||||||
latest_version = data.get("latest_version")
|
latest_version = data.get("latest_version")
|
||||||
stored_at_timestamp = data.get("stored_at_timestamp")
|
stored_at_timestamp = data.get("stored_at_timestamp")
|
||||||
seen_whats_new_version = data.get("seen_whats_new_version")
|
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(
|
if not isinstance(latest_version, str) or not isinstance(
|
||||||
stored_at_timestamp, int
|
stored_at_timestamp, int
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if (
|
if not isinstance(seen_whats_new_version, str):
|
||||||
not isinstance(seen_whats_new_version, str)
|
|
||||||
and seen_whats_new_version is not None
|
|
||||||
):
|
|
||||||
seen_whats_new_version = None
|
seen_whats_new_version = None
|
||||||
|
|
||||||
|
if not isinstance(dismissed_version, str):
|
||||||
|
dismissed_version = None
|
||||||
|
|
||||||
return UpdateCache(
|
return UpdateCache(
|
||||||
latest_version=latest_version,
|
latest_version=latest_version,
|
||||||
stored_at_timestamp=stored_at_timestamp,
|
stored_at_timestamp=stored_at_timestamp,
|
||||||
seen_whats_new_version=seen_whats_new_version,
|
seen_whats_new_version=seen_whats_new_version,
|
||||||
|
dismissed_version=dismissed_version,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ class UpdateCache:
|
||||||
latest_version: str
|
latest_version: str
|
||||||
stored_at_timestamp: int
|
stored_at_timestamp: int
|
||||||
seen_whats_new_version: str | None = None
|
seen_whats_new_version: str | None = None
|
||||||
|
dismissed_version: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateCacheRepository(Protocol):
|
class UpdateCacheRepository(Protocol):
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from packaging.version import InvalidVersion, Version
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
@ -74,18 +75,56 @@ async def _write_update_cache(
|
||||||
version: str,
|
version: str,
|
||||||
get_current_timestamp: Callable[[], int],
|
get_current_timestamp: Callable[[], int],
|
||||||
) -> None:
|
) -> 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(
|
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(
|
async def get_update_if_available(
|
||||||
update_notifier: UpdateGateway,
|
update_notifier: UpdateGateway,
|
||||||
current_version: str,
|
current_version: str,
|
||||||
update_cache_repository: UpdateCacheRepository,
|
update_cache_repository: UpdateCacheRepository,
|
||||||
get_current_timestamp: Callable[[], int] = lambda: int(time.time()),
|
get_current_timestamp: Callable[[], int] = lambda: int(time.time()),
|
||||||
) -> UpdateAvailability | None:
|
) -> UpdateAvailability | None:
|
||||||
if not (current := _parse_version(current_version)):
|
current = _parse_version(current_version)
|
||||||
|
if current is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if update_cache := await update_cache_repository.get():
|
if update_cache := await update_cache_repository.get():
|
||||||
|
|
@ -133,7 +172,24 @@ async def do_update() -> bool:
|
||||||
stderr=asyncio.subprocess.PIPE,
|
stderr=asyncio.subprocess.PIPE,
|
||||||
stdin=asyncio.subprocess.DEVNULL,
|
stdin=asyncio.subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
await process.wait()
|
try:
|
||||||
|
await process.wait()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await _terminate(process)
|
||||||
|
raise
|
||||||
if process.returncode == 0:
|
if process.returncode == 0:
|
||||||
return True
|
return True
|
||||||
return False
|
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()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from vibe import VIBE_ROOT
|
from vibe import VIBE_ROOT
|
||||||
|
|
@ -40,11 +41,5 @@ async def mark_version_as_seen(version: str, repository: UpdateCacheRepository)
|
||||||
seen_whats_new_version=version,
|
seen_whats_new_version=version,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
return
|
||||||
await repository.set(
|
await repository.set(replace(cache, seen_whats_new_version=version))
|
||||||
UpdateCache(
|
|
||||||
latest_version=cache.latest_version,
|
|
||||||
stored_at_timestamp=cache.stored_at_timestamp,
|
|
||||||
seen_whats_new_version=version,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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.session.session_migration import migrate_sessions_entrypoint
|
||||||
from vibe.core.skills.manager import SkillManager
|
from vibe.core.skills.manager import SkillManager
|
||||||
from vibe.core.system_prompt import get_universal_system_prompt
|
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.send import TelemetryClient
|
||||||
from vibe.core.telemetry.types import (
|
from vibe.core.telemetry.types import (
|
||||||
EntrypointMetadata,
|
EntrypointMetadata,
|
||||||
|
|
@ -748,6 +751,10 @@ class AgentLoop: # noqa: PLR0904
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def set_max_turns(self, max_turns: int) -> None:
|
||||||
|
self._max_turns = max_turns
|
||||||
|
self._setup_middleware()
|
||||||
|
|
||||||
def _setup_middleware(self) -> None:
|
def _setup_middleware(self) -> None:
|
||||||
"""Configure middleware pipeline for this conversation."""
|
"""Configure middleware pipeline for this conversation."""
|
||||||
self.middleware_pipeline.clear()
|
self.middleware_pipeline.clear()
|
||||||
|
|
@ -1373,6 +1380,9 @@ class AgentLoop: # noqa: PLR0904
|
||||||
else 0,
|
else 0,
|
||||||
call_type=backend_metadata.call_type,
|
call_type=backend_metadata.call_type,
|
||||||
message_id=backend_metadata.message_id,
|
message_id=backend_metadata.message_id,
|
||||||
|
attachment_counts=build_attachment_counts(
|
||||||
|
last_user_message, supports_images=active_model.supports_images
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -1436,6 +1446,9 @@ class AgentLoop: # noqa: PLR0904
|
||||||
else 0,
|
else 0,
|
||||||
call_type=backend_metadata.call_type,
|
call_type=backend_metadata.call_type,
|
||||||
message_id=backend_metadata.message_id,
|
message_id=backend_metadata.message_id,
|
||||||
|
attachment_counts=build_attachment_counts(
|
||||||
|
last_user_message, supports_images=active_model.supports_images
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -531,7 +531,6 @@ class VibeConfig(BaseSettings):
|
||||||
include_project_context: bool = True
|
include_project_context: bool = True
|
||||||
include_prompt_detail: bool = True
|
include_prompt_detail: bool = True
|
||||||
enable_update_checks: bool = True
|
enable_update_checks: bool = True
|
||||||
enable_auto_update: bool = True
|
|
||||||
enable_notifications: bool = True
|
enable_notifications: bool = True
|
||||||
enable_system_trust_store: bool = False
|
enable_system_trust_store: bool = False
|
||||||
api_timeout: float = DEFAULT_API_TIMEOUT
|
api_timeout: float = DEFAULT_API_TIMEOUT
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,7 @@ bypass_tool_permissions = false # Skip tool approval prompts
|
||||||
system_prompt_id = "cli" # System prompt: "cli", "lean", or custom .md filename
|
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
|
compaction_prompt_id = "compact" # Compaction prompt: built-in "compact" or custom .md filename
|
||||||
enable_telemetry = true
|
enable_telemetry = true
|
||||||
enable_update_checks = true
|
enable_update_checks = true # Daily PyPI check; prompts on next launch when a newer release exists
|
||||||
enable_auto_update = true
|
|
||||||
enable_notifications = true
|
enable_notifications = true
|
||||||
enable_system_trust_store = false # Use OS trust store for outbound HTTPS
|
enable_system_trust_store = false # Use OS trust store for outbound HTTPS
|
||||||
api_timeout = 720.0 # API request timeout in seconds
|
api_timeout = 720.0 # API request timeout in seconds
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,13 @@ from typing import Any, cast
|
||||||
|
|
||||||
from vibe.core.telemetry.types import (
|
from vibe.core.telemetry.types import (
|
||||||
AgentEntrypoint,
|
AgentEntrypoint,
|
||||||
|
AttachmentKind,
|
||||||
EntrypointMetadata,
|
EntrypointMetadata,
|
||||||
TelemetryBaseMetadata,
|
TelemetryBaseMetadata,
|
||||||
TelemetryCallType,
|
TelemetryCallType,
|
||||||
TelemetryRequestMetadata,
|
TelemetryRequestMetadata,
|
||||||
)
|
)
|
||||||
|
from vibe.core.types import LLMMessage
|
||||||
|
|
||||||
|
|
||||||
def build_base_metadata(
|
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(
|
def build_entrypoint_metadata(
|
||||||
*,
|
*,
|
||||||
agent_entrypoint: AgentEntrypoint,
|
agent_entrypoint: AgentEntrypoint,
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ from vibe.core.logger import logger
|
||||||
from vibe.core.telemetry.build_metadata import build_base_metadata
|
from vibe.core.telemetry.build_metadata import build_base_metadata
|
||||||
from vibe.core.telemetry.types import (
|
from vibe.core.telemetry.types import (
|
||||||
AgentEntrypoint,
|
AgentEntrypoint,
|
||||||
|
AttachmentKind,
|
||||||
EntrypointMetadata,
|
EntrypointMetadata,
|
||||||
TelemetryCallType,
|
TelemetryCallType,
|
||||||
TeleportCompletedPayload,
|
TeleportCompletedPayload,
|
||||||
|
|
@ -303,6 +304,7 @@ class TelemetryClient:
|
||||||
nb_prompt_chars: int,
|
nb_prompt_chars: int,
|
||||||
call_type: TelemetryCallType,
|
call_type: TelemetryCallType,
|
||||||
message_id: str | None = None,
|
message_id: str | None = None,
|
||||||
|
attachment_counts: dict[AttachmentKind, int] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
payload = {
|
payload = {
|
||||||
"model": model,
|
"model": model,
|
||||||
|
|
@ -312,6 +314,11 @@ class TelemetryClient:
|
||||||
"call_source": "vibe_code",
|
"call_source": "vibe_code",
|
||||||
"call_type": call_type,
|
"call_type": call_type,
|
||||||
"message_id": message_id,
|
"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)
|
self.send_telemetry_event("vibe.request_sent", payload)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,15 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
from typing import Literal, TypedDict
|
from typing import Literal, TypedDict
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentKind(StrEnum):
|
||||||
|
IMAGE = "image"
|
||||||
|
|
||||||
|
|
||||||
class ClientMetadata(BaseModel):
|
class ClientMetadata(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
version: str
|
version: str
|
||||||
|
|
|
||||||
|
|
@ -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)`
|
- `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
|
- `less`, `more`, `vim`, `nano` → Use `read` with offset/limit for navigation
|
||||||
- `echo "content" > file` → Use `write_file(path="file", content="content")`
|
- `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:**
|
**Search Operations - DO NOT USE:**
|
||||||
- `grep -r "pattern" .` → Use `grep(pattern="pattern", path=".")`
|
- `grep -r "pattern" .` → Use `grep(pattern="pattern", path=".")`
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ Use `write_file` to create a new file.
|
||||||
**BEHAVIOR:**
|
**BEHAVIOR:**
|
||||||
|
|
||||||
- `write_file` can ONLY create new files.
|
- `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.
|
- Parent directories are created automatically if they don't exist.
|
||||||
|
|
||||||
**BEST PRACTICES:**
|
**BEST PRACTICES:**
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ class WriteFile(
|
||||||
ToolUIData[WriteFileArgs, WriteFileResult],
|
ToolUIData[WriteFileArgs, WriteFileResult],
|
||||||
):
|
):
|
||||||
description: ClassVar[str] = (
|
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
|
@classmethod
|
||||||
|
|
@ -115,7 +115,7 @@ class WriteFile(
|
||||||
|
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
raise ToolError(
|
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:
|
if self.config.create_parent_dirs:
|
||||||
|
|
@ -133,7 +133,7 @@ class WriteFile(
|
||||||
await f.write(args.content)
|
await f.write(args.content)
|
||||||
except FileExistsError as e:
|
except FileExistsError as e:
|
||||||
raise ToolError(
|
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
|
) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ToolError(f"Error writing {file_path}: {e}") from e
|
raise ToolError(f"Error writing {file_path}: {e}") from e
|
||||||
|
|
|
||||||
|
|
@ -281,7 +281,7 @@ class TrustFolderApp(App):
|
||||||
self.exit()
|
self.exit()
|
||||||
|
|
||||||
def run_trust_dialog(self) -> TrustDecision | None:
|
def run_trust_dialog(self) -> TrustDecision | None:
|
||||||
self.run()
|
self.run(inline=True)
|
||||||
if self._quit_without_saving:
|
if self._quit_without_saving:
|
||||||
raise TrustDialogQuitException()
|
raise TrustDialogQuitException()
|
||||||
return self._result
|
return self._result
|
||||||
|
|
|
||||||
8
vibe/setup/update_prompt/__init__.py
Normal file
8
vibe/setup/update_prompt/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from vibe.setup.update_prompt.update_prompt_dialog import (
|
||||||
|
UpdatePromptResult,
|
||||||
|
ask_update_prompt,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = ["UpdatePromptResult", "ask_update_prompt"]
|
||||||
221
vibe/setup/update_prompt/update_prompt_dialog.py
Normal file
221
vibe/setup/update_prompt/update_prompt_dialog.py
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import suppress
|
||||||
|
from enum import StrEnum, auto
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
|
from textual import events
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding, BindingType
|
||||||
|
from textual.containers import CenterMiddle, Horizontal
|
||||||
|
from textual.message import Message
|
||||||
|
|
||||||
|
from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
|
||||||
|
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
|
||||||
|
from vibe.cli.update_notifier.update import do_update
|
||||||
|
from vibe.core.logger import logger
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePromptResult(StrEnum):
|
||||||
|
CONTINUE = auto()
|
||||||
|
UPDATED = auto()
|
||||||
|
UPDATE_FAILED = auto()
|
||||||
|
QUIT = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateChoice(StrEnum):
|
||||||
|
UPDATE = auto()
|
||||||
|
CONTINUE = auto()
|
||||||
|
|
||||||
|
|
||||||
|
_CHOICE_LABELS: dict[UpdateChoice, str] = {
|
||||||
|
UpdateChoice.UPDATE: "Update now",
|
||||||
|
UpdateChoice.CONTINUE: "Continue with current version",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePromptDialog(CenterMiddle):
|
||||||
|
can_focus = True
|
||||||
|
can_focus_children = True
|
||||||
|
|
||||||
|
BINDINGS: ClassVar[list[BindingType]] = [
|
||||||
|
Binding("left", "move_left", "Left", show=False),
|
||||||
|
Binding("right", "move_right", "Right", show=False),
|
||||||
|
Binding("enter", "select", "Select", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Selected(Message):
|
||||||
|
def __init__(self, choice: UpdateChoice) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.choice = choice
|
||||||
|
|
||||||
|
class UpdateFinished(Message):
|
||||||
|
def __init__(self, succeeded: bool) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.succeeded = succeeded
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, current_version: str, latest_version: str, **kwargs: Any
|
||||||
|
) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.current_version = current_version
|
||||||
|
self.latest_version = latest_version
|
||||||
|
self.selected: UpdateChoice = UpdateChoice.UPDATE
|
||||||
|
self._option_widgets: dict[UpdateChoice, NoMarkupStatic] = {}
|
||||||
|
self._is_updating = False
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with CenterMiddle(id="update-dialog"):
|
||||||
|
yield NoMarkupStatic(
|
||||||
|
"A new Vibe release is available", id="update-dialog-title"
|
||||||
|
)
|
||||||
|
yield NoMarkupStatic(
|
||||||
|
f"{self.current_version} → {self.latest_version}",
|
||||||
|
id="update-dialog-version",
|
||||||
|
)
|
||||||
|
|
||||||
|
with Horizontal(id="update-options-container"):
|
||||||
|
for choice in UpdateChoice:
|
||||||
|
widget = NoMarkupStatic(
|
||||||
|
f" {_CHOICE_LABELS[choice]}", classes="update-option"
|
||||||
|
)
|
||||||
|
self._option_widgets[choice] = widget
|
||||||
|
yield widget
|
||||||
|
|
||||||
|
yield NoMarkupStatic(
|
||||||
|
"← → navigate Enter select", classes="update-dialog-help"
|
||||||
|
)
|
||||||
|
|
||||||
|
yield PetitChat(id="update-dialog-spinner")
|
||||||
|
yield NoMarkupStatic("Updating mistral-vibe…", id="update-dialog-status")
|
||||||
|
|
||||||
|
async def on_mount(self) -> None:
|
||||||
|
spinner = self.query_one("#update-dialog-spinner", PetitChat)
|
||||||
|
spinner.display = False
|
||||||
|
status = self.query_one("#update-dialog-status", NoMarkupStatic)
|
||||||
|
status.display = False
|
||||||
|
self._refresh_options()
|
||||||
|
self.focus()
|
||||||
|
|
||||||
|
def _refresh_options(self) -> None:
|
||||||
|
for choice in UpdateChoice:
|
||||||
|
widget = self._option_widgets[choice]
|
||||||
|
cursor = "› " if choice == self.selected else " "
|
||||||
|
widget.update(f"{cursor}{_CHOICE_LABELS[choice]}")
|
||||||
|
widget.remove_class("update-option--active")
|
||||||
|
widget.remove_class("update-option--inactive")
|
||||||
|
widget.add_class(
|
||||||
|
"update-option--active"
|
||||||
|
if choice == self.selected
|
||||||
|
else "update-option--inactive"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _move(self, delta: int) -> None:
|
||||||
|
if self._is_updating:
|
||||||
|
return
|
||||||
|
choices = list(UpdateChoice)
|
||||||
|
idx = (choices.index(self.selected) + delta) % len(choices)
|
||||||
|
self.selected = choices[idx]
|
||||||
|
self._refresh_options()
|
||||||
|
|
||||||
|
def action_move_left(self) -> None:
|
||||||
|
self._move(-1)
|
||||||
|
|
||||||
|
def action_move_right(self) -> None:
|
||||||
|
self._move(1)
|
||||||
|
|
||||||
|
def action_select(self) -> None:
|
||||||
|
if self._is_updating:
|
||||||
|
return
|
||||||
|
self.post_message(self.Selected(self.selected))
|
||||||
|
|
||||||
|
async def enter_updating_state(self) -> None:
|
||||||
|
self._is_updating = True
|
||||||
|
|
||||||
|
for widget in self._option_widgets.values():
|
||||||
|
widget.display = False
|
||||||
|
self.query_one("#update-options-container", Horizontal).display = False
|
||||||
|
self.query_one(".update-dialog-help", NoMarkupStatic).display = False
|
||||||
|
|
||||||
|
self.query_one("#update-dialog-spinner", PetitChat).display = True
|
||||||
|
self.query_one("#update-dialog-status", NoMarkupStatic).display = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
succeeded = await do_update()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("do_update raised unexpectedly", exc_info=exc)
|
||||||
|
succeeded = False
|
||||||
|
self.post_message(self.UpdateFinished(succeeded=succeeded))
|
||||||
|
|
||||||
|
def on_blur(self, _: events.Blur) -> None:
|
||||||
|
if self._is_updating:
|
||||||
|
return
|
||||||
|
self.call_after_refresh(self.focus)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePromptApp(App[UpdatePromptResult]):
|
||||||
|
CSS_PATH = "update_prompt_dialog.tcss"
|
||||||
|
|
||||||
|
BINDINGS: ClassVar[list[BindingType]] = [
|
||||||
|
Binding("ctrl+q", "quit_prompt", "Quit", show=False, priority=True),
|
||||||
|
Binding("ctrl+c", "quit_prompt", "Quit", show=False, priority=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
current_version: str,
|
||||||
|
latest_version: str,
|
||||||
|
theme: str | None = None,
|
||||||
|
**kwargs: Any,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.current_version = current_version
|
||||||
|
self.latest_version = latest_version
|
||||||
|
self._theme_name = theme
|
||||||
|
self._dialog: UpdatePromptDialog | None = None
|
||||||
|
self._update_task: asyncio.Task[None] | None = None
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
if self._theme_name is not None:
|
||||||
|
self.theme = self._theme_name
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
self._dialog = UpdatePromptDialog(self.current_version, self.latest_version)
|
||||||
|
yield self._dialog
|
||||||
|
|
||||||
|
async def action_quit_prompt(self) -> None:
|
||||||
|
if self._update_task is not None and not self._update_task.done():
|
||||||
|
self._update_task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError, Exception):
|
||||||
|
await self._update_task
|
||||||
|
self.exit(UpdatePromptResult.QUIT)
|
||||||
|
|
||||||
|
def on_update_prompt_dialog_selected(
|
||||||
|
self, message: UpdatePromptDialog.Selected
|
||||||
|
) -> None:
|
||||||
|
match message.choice:
|
||||||
|
case UpdateChoice.UPDATE:
|
||||||
|
if self._dialog is None or self._update_task is not None:
|
||||||
|
return
|
||||||
|
self._update_task = asyncio.create_task(
|
||||||
|
self._dialog.enter_updating_state()
|
||||||
|
)
|
||||||
|
case UpdateChoice.CONTINUE:
|
||||||
|
self.exit(UpdatePromptResult.CONTINUE)
|
||||||
|
|
||||||
|
def on_update_prompt_dialog_update_finished(
|
||||||
|
self, message: UpdatePromptDialog.UpdateFinished
|
||||||
|
) -> None:
|
||||||
|
self.exit(
|
||||||
|
UpdatePromptResult.UPDATED
|
||||||
|
if message.succeeded
|
||||||
|
else UpdatePromptResult.UPDATE_FAILED
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ask_update_prompt(
|
||||||
|
current_version: str, latest_version: str, theme: str | None = None
|
||||||
|
) -> UpdatePromptResult:
|
||||||
|
app = UpdatePromptApp(current_version, latest_version, theme=theme)
|
||||||
|
return app.run(inline=True) or UpdatePromptResult.CONTINUE
|
||||||
85
vibe/setup/update_prompt/update_prompt_dialog.tcss
Normal file
85
vibe/setup/update_prompt/update_prompt_dialog.tcss
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
Screen {
|
||||||
|
align: center middle;
|
||||||
|
background: transparent 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#update-dialog {
|
||||||
|
max-width: 70;
|
||||||
|
border: round $border-blurred;
|
||||||
|
background: transparent;
|
||||||
|
height: auto;
|
||||||
|
padding: 1;
|
||||||
|
padding-left: 5;
|
||||||
|
padding-right: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#update-dialog-title {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
text-style: bold;
|
||||||
|
color: $primary;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#update-dialog-version {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
color: $foreground;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#update-options-container {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
align: center middle;
|
||||||
|
margin-bottom: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-option {
|
||||||
|
height: auto;
|
||||||
|
width: auto;
|
||||||
|
color: $foreground;
|
||||||
|
margin: 0 3;
|
||||||
|
content-align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-option--active {
|
||||||
|
color: $foreground;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-option--inactive {
|
||||||
|
color: $foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-dialog-help {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
color: $text-muted;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
&:ansi {
|
||||||
|
text-style: dim;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#update-dialog-spinner {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin-bottom: 1;
|
||||||
|
align: center middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.petit-chat {
|
||||||
|
color: $foreground;
|
||||||
|
width: 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
#update-dialog-status {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
color: $foreground;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
@ -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.
|
- **Update prompt at startup**: Vibe now asks you to install a pending update before starting the session, instead of just notifying you.
|
||||||
- **New read and edit tools**: `read` and `edit` replace `read_file` and `search_replace`. Your config has been migrated to these new tool names.
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue