diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2eaf153..c162d09 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.9.1] - 2026-04-29
+
+### Added
+
+- Connector OAuth authentication flow in `/mcp` menu
+- `ConfigPatch` operation types for Vibe Code
+- `extra_headers` field to `ProviderConfig`
+- Structured metadata on ACP tool results
+- `vibe.user_cancelled_action` ACP telemetry coverage
+- `vibe.new_session` telemetry event emitted whenever the session is reset
+
+### Changed
+
+- Migrated default model to `mistral-medium-3.5`
+
+
## [2.9.0] - 2026-04-28
### Added
diff --git a/README.md b/README.md
index 40b5512..13ac149 100644
--- a/README.md
+++ b/README.md
@@ -439,7 +439,7 @@ Example custom agent configuration (`~/.vibe/agents/redteam.toml`):
```toml
# Custom agent configuration for red-teaming
-active_model = "devstral-2"
+active_model = "mistral-medium-3.5"
system_prompt_id = "redteam"
# Disable some tools for this agent
diff --git a/distribution/zed/extension.toml b/distribution/zed/extension.toml
index f8478b6..f1dbb2d 100644
--- a/distribution/zed/extension.toml
+++ b/distribution/zed/extension.toml
@@ -1,7 +1,7 @@
id = "mistral-vibe"
name = "Mistral Vibe"
description = "Mistral's open-source coding assistant"
-version = "2.9.0"
+version = "2.9.1"
schema_version = 1
authors = ["Mistral AI"]
repository = "https://github.com/mistralai/mistral-vibe"
@@ -11,25 +11,25 @@ name = "Mistral Vibe"
icon = "./icons/mistral_vibe.svg"
[agent_servers.mistral-vibe.targets.darwin-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-darwin-aarch64-2.9.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-darwin-aarch64-2.9.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.darwin-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-darwin-x86_64-2.9.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-darwin-x86_64-2.9.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-linux-aarch64-2.9.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-linux-aarch64-2.9.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.linux-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-linux-x86_64-2.9.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-linux-x86_64-2.9.1.zip"
cmd = "./vibe-acp"
[agent_servers.mistral-vibe.targets.windows-aarch64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-windows-aarch64-2.9.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-windows-aarch64-2.9.1.zip"
cmd = "./vibe-acp.exe"
[agent_servers.mistral-vibe.targets.windows-x86_64]
-archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.0/vibe-acp-windows-x86_64-2.9.0.zip"
+archive = "https://github.com/mistralai/mistral-vibe/releases/download/v2.9.1/vibe-acp-windows-x86_64-2.9.1.zip"
cmd = "./vibe-acp.exe"
diff --git a/pyproject.toml b/pyproject.toml
index 35e0339..a4fd1d3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "mistral-vibe"
-version = "2.9.0"
+version = "2.9.1"
description = "Minimal CLI coding agent by Mistral"
readme = "README.md"
requires-python = ">=3.12"
diff --git a/tests/acp/test_initialize.py b/tests/acp/test_initialize.py
index b6dcb47..bb9401e 100644
--- a/tests/acp/test_initialize.py
+++ b/tests/acp/test_initialize.py
@@ -34,7 +34,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
- name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.0"
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.1"
)
assert response.auth_methods == []
@@ -62,7 +62,7 @@ class TestACPInitialize:
),
)
assert response.agent_info == Implementation(
- name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.0"
+ name="@mistralai/mistral-vibe", title="Mistral Vibe", version="2.9.1"
)
assert response.auth_methods is not None
diff --git a/tests/acp/test_search_replace.py b/tests/acp/test_search_replace.py
index 1ba06d3..a90b644 100644
--- a/tests/acp/test_search_replace.py
+++ b/tests/acp/test_search_replace.py
@@ -288,7 +288,7 @@ class TestAcpSearchReplaceSessionUpdates:
assert update.content[0].new_text == "new text"
assert update.locations is not None
assert len(update.locations) == 1
- assert update.locations[0].path == "/tmp/test.txt"
+ assert update.locations[0].path == str(Path("/tmp/test.txt").resolve())
def test_tool_call_session_update_invalid_args(self) -> None:
class InvalidArgs:
@@ -302,7 +302,8 @@ class TestAcpSearchReplaceSessionUpdates:
)
update = SearchReplace.tool_call_session_update(event)
- assert update is None
+ assert update is not None
+ assert update.title == "search_replace"
def test_tool_result_session_update(self) -> None:
search_replace_content = (
@@ -337,7 +338,7 @@ class TestAcpSearchReplaceSessionUpdates:
assert update.content[0].new_text == "new text"
assert update.locations is not None
assert len(update.locations) == 1
- assert update.locations[0].path == "/tmp/test.txt"
+ assert update.locations[0].path == str(Path("/tmp/test.txt").resolve())
def test_tool_result_session_update_invalid_result(self) -> None:
class InvalidResult:
@@ -351,4 +352,5 @@ class TestAcpSearchReplaceSessionUpdates:
)
update = SearchReplace.tool_result_session_update(event)
- assert update is None
+ assert update is not None
+ assert update.status == "failed"
diff --git a/tests/acp/test_tool_call_session_update.py b/tests/acp/test_tool_call_session_update.py
index aaf2545..ef92a8c 100644
--- a/tests/acp/test_tool_call_session_update.py
+++ b/tests/acp/test_tool_call_session_update.py
@@ -2,8 +2,9 @@ from __future__ import annotations
from acp.schema import ToolCallStart
+from vibe.acp.tools.builtins.read_file import ReadFile
from vibe.acp.tools.session_update import tool_call_session_update
-from vibe.core.tools.builtins.read_file import ReadFile, ReadFileArgs
+from vibe.core.tools.builtins.read_file import ReadFileArgs
from vibe.core.types import ToolCallEvent
diff --git a/tests/acp/test_tool_field_meta.py b/tests/acp/test_tool_field_meta.py
new file mode 100644
index 0000000..03e7c6d
--- /dev/null
+++ b/tests/acp/test_tool_field_meta.py
@@ -0,0 +1,296 @@
+"""Tests for field_meta and locations in ACP tool overrides."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from acp.schema import ToolCallProgress, ToolCallStart
+from pydantic import BaseModel
+
+from vibe.acp.tools.builtins.grep import Grep
+from vibe.acp.tools.builtins.read_file import ReadFile
+from vibe.acp.tools.builtins.skill import Skill
+from vibe.acp.tools.builtins.task import Task
+from vibe.acp.tools.builtins.web_fetch import WebFetch
+from vibe.acp.tools.builtins.web_search import WebSearch
+from vibe.acp.tools.builtins.write_file import WriteFile
+from vibe.acp.tools.session_update import (
+ tool_call_session_update,
+ tool_result_session_update,
+)
+from vibe.core.tools.builtins.grep import GrepArgs, GrepResult
+from vibe.core.tools.builtins.read_file import ReadFileArgs, ReadFileResult
+from vibe.core.tools.builtins.skill import SkillArgs, SkillResult
+from vibe.core.tools.builtins.task import TaskArgs, TaskResult
+from vibe.core.tools.builtins.webfetch import WebFetchArgs, WebFetchResult
+from vibe.core.tools.builtins.websearch import (
+ WebSearchArgs,
+ WebSearchResult,
+ WebSearchSource,
+)
+from vibe.core.tools.builtins.write_file import WriteFileArgs, WriteFileResult
+from vibe.core.types import ToolCallEvent, ToolResultEvent
+
+
+def _call_event(
+ tool_name: str, tool_class: type, args: BaseModel | None
+) -> ToolCallEvent:
+ return ToolCallEvent(
+ tool_name=tool_name, tool_call_id="tc-1", tool_class=tool_class, args=args
+ )
+
+
+def _result_event(
+ tool_name: str, tool_class: type, result: BaseModel, *, error: str | None = None
+) -> ToolResultEvent:
+ return ToolResultEvent(
+ tool_name=tool_name,
+ tool_call_id="tc-1",
+ tool_class=tool_class,
+ result=result,
+ error=error,
+ )
+
+
+class TestGrepFieldMeta:
+ def test_call_meta_contains_query_and_tool_name(self) -> None:
+ event = _call_event("grep", Grep, GrepArgs(pattern="TODO", path="src"))
+ update = tool_call_session_update(event)
+
+ assert isinstance(update, ToolCallStart)
+ assert update.field_meta == {"tool_name": "grep", "query": "TODO"}
+ assert update.kind == "search"
+
+ def test_call_location_is_resolved_search_path(self) -> None:
+ event = _call_event("grep", Grep, GrepArgs(pattern="TODO", path="src"))
+ update = tool_call_session_update(event)
+
+ assert isinstance(update, ToolCallStart)
+ assert update.locations is not None
+ assert len(update.locations) == 1
+ assert update.locations[0].path == str(Path("src").resolve())
+
+ def test_result_locations_from_parsed_matches(self) -> None:
+ result = GrepResult(
+ matches="src/a.py:10:match\nsrc/b.py:20:other",
+ match_count=2,
+ was_truncated=False,
+ )
+ event = _result_event("grep", Grep, result)
+ update = tool_result_session_update(event)
+
+ assert isinstance(update, ToolCallProgress)
+ assert update.status == "completed"
+ assert update.locations is not None
+ assert len(update.locations) == 2
+ assert update.locations[0].path.endswith("src/a.py")
+ assert update.locations[0].line == 10
+ assert update.locations[1].path.endswith("src/b.py")
+ assert update.locations[1].line == 20
+
+ def test_fallback_on_none_args(self) -> None:
+ event = _call_event("grep", Grep, None)
+ update = tool_call_session_update(event)
+
+ assert isinstance(update, ToolCallStart)
+ assert update.kind == "search"
+ assert update.field_meta == {"tool_name": "grep"}
+
+
+class TestReadFileFieldMeta:
+ def test_call_location_has_offset_and_limit(self) -> None:
+ event = _call_event(
+ "read_file", ReadFile, ReadFileArgs(path="/tmp/f.txt", offset=10, limit=50)
+ )
+ update = tool_call_session_update(event)
+
+ assert isinstance(update, ToolCallStart)
+ assert update.locations is not None
+ loc = update.locations[0]
+ assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 50}
+ assert update.field_meta == {"tool_name": "read_file"}
+
+ def test_call_defaults_offset_zero_limit_none(self) -> None:
+ event = _call_event("read_file", ReadFile, ReadFileArgs(path="/tmp/f.txt"))
+ update = tool_call_session_update(event)
+
+ assert isinstance(update, ToolCallStart)
+ assert update.locations is not None
+ loc = update.locations[0]
+ assert loc.field_meta == {"type": "file_range", "offset": 0, "limit": None}
+
+ def test_result_location_has_offset_and_lines_read(self) -> None:
+ result = ReadFileResult(
+ path="/tmp/f.txt",
+ content="line1\nline2\nline3\n",
+ lines_read=3,
+ was_truncated=False,
+ offset=10,
+ )
+ event = _result_event("read_file", ReadFile, result)
+ update = tool_result_session_update(event)
+
+ assert isinstance(update, ToolCallProgress)
+ assert update.locations is not None
+ loc = update.locations[0]
+ assert loc.field_meta == {"type": "file_range", "offset": 10, "limit": 3}
+
+
+class TestWebSearchFieldMeta:
+ def test_call_meta_contains_query(self) -> None:
+ event = _call_event(
+ "web_search", WebSearch, WebSearchArgs(query="python async")
+ )
+ update = tool_call_session_update(event)
+
+ assert isinstance(update, ToolCallStart)
+ assert update.field_meta == {"tool_name": "web_search", "query": "python async"}
+ assert update.kind == "search"
+
+ def test_result_locations_are_source_urls_with_titles(self) -> None:
+ result = WebSearchResult(
+ answer="found it",
+ sources=[
+ WebSearchSource(title="Docs", url="https://docs.python.org"),
+ WebSearchSource(title="Blog", url="https://blog.example.com"),
+ ],
+ )
+ event = _result_event("web_search", WebSearch, result)
+ update = tool_result_session_update(event)
+
+ assert isinstance(update, ToolCallProgress)
+ assert update.locations is not None
+ assert len(update.locations) == 2
+ assert update.locations[0].path == "https://docs.python.org"
+ assert update.locations[0].field_meta == {"type": "url", "title": "Docs"}
+ assert update.locations[1].path == "https://blog.example.com"
+ assert update.locations[1].field_meta == {"type": "url", "title": "Blog"}
+
+
+class TestWebFetchFieldMeta:
+ def test_call_location_is_normalized_url(self) -> None:
+ event = _call_event("web_fetch", WebFetch, WebFetchArgs(url="example.com/page"))
+ update = tool_call_session_update(event)
+
+ assert isinstance(update, ToolCallStart)
+ assert update.kind == "fetch"
+ assert update.locations is not None
+ assert update.locations[0].path == "https://example.com/page"
+
+ def test_result_location_has_char_count_and_truncated(self) -> None:
+ result = WebFetchResult(
+ url="https://example.com",
+ content="hello world",
+ content_type="text/html",
+ was_truncated=True,
+ )
+ event = _result_event("web_fetch", WebFetch, result)
+ update = tool_result_session_update(event)
+
+ assert isinstance(update, ToolCallProgress)
+ assert update.locations is not None
+ loc = update.locations[0]
+ assert loc.path == "https://example.com"
+ assert loc.field_meta == {"type": "url", "char_count": 11, "truncated": True}
+
+
+class TestSkillFieldMeta:
+ def test_call_meta_contains_skill_name(self) -> None:
+ event = _call_event("skill", Skill, SkillArgs(name="debug"))
+ update = tool_call_session_update(event)
+
+ assert isinstance(update, ToolCallStart)
+ assert update.field_meta == {"tool_name": "skill", "skill_name": "debug"}
+ assert update.kind == "read"
+
+ def test_result_has_skill_dir_location(self) -> None:
+ result = SkillResult(
+ name="debug", content="do things", skill_dir="/home/user/.vibe/skills/debug"
+ )
+ event = _result_event("skill", Skill, result)
+ update = tool_result_session_update(event)
+
+ assert isinstance(update, ToolCallProgress)
+ assert update.locations is not None
+ assert len(update.locations) == 1
+ assert update.locations[0].path == str(
+ Path("/home/user/.vibe/skills/debug").resolve()
+ )
+ assert update.field_meta == {"tool_name": "skill", "skill_name": "debug"}
+
+ def test_result_no_location_when_no_skill_dir(self) -> None:
+ result = SkillResult(name="debug", content="do things", skill_dir=None)
+ event = _result_event("skill", Skill, result)
+ update = tool_result_session_update(event)
+
+ assert isinstance(update, ToolCallProgress)
+ assert update.locations is None
+
+
+class TestTaskFieldMeta:
+ def test_call_meta_contains_agent_and_task(self) -> None:
+ event = _call_event(
+ "task", Task, TaskArgs(task="explore codebase", agent="explore")
+ )
+ update = tool_call_session_update(event)
+
+ assert isinstance(update, ToolCallStart)
+ assert update.field_meta == {
+ "tool_name": "task",
+ "agent": "explore",
+ "task": "explore codebase",
+ }
+
+ def test_result_meta_contains_turn_count_and_response(self) -> None:
+ result = TaskResult(response="found 3 files", turns_used=5, completed=True)
+ event = _result_event("task", Task, result)
+ update = tool_result_session_update(event)
+
+ assert isinstance(update, ToolCallProgress)
+ assert update.status == "completed"
+ assert update.field_meta == {
+ "tool_name": "task",
+ "turn_count": 5,
+ "response": "found 3 files",
+ }
+
+ def test_result_failed_when_not_completed(self) -> None:
+ result = TaskResult(response="interrupted", turns_used=2, completed=False)
+ event = _result_event("task", Task, result)
+ update = tool_result_session_update(event)
+
+ assert isinstance(update, ToolCallProgress)
+ assert update.status == "failed"
+
+
+class TestWriteFileFieldMeta:
+ def test_call_meta_contains_tool_name(self) -> None:
+ event = _call_event(
+ "write_file", WriteFile, WriteFileArgs(path="out.txt", content="hello")
+ )
+ update = tool_call_session_update(event)
+
+ assert isinstance(update, ToolCallStart)
+ assert update.field_meta == {"tool_name": "write_file"}
+ assert update.kind == "edit"
+
+ def test_call_location_is_resolved_path(self) -> None:
+ event = _call_event(
+ "write_file", WriteFile, WriteFileArgs(path="out.txt", content="hello")
+ )
+ update = tool_call_session_update(event)
+
+ assert isinstance(update, ToolCallStart)
+ assert update.locations is not None
+ assert update.locations[0].path == str(Path("out.txt").resolve())
+
+ def test_result_location_is_resolved_path(self) -> None:
+ result = WriteFileResult(
+ path="out.txt", content="hello", bytes_written=5, file_existed=False
+ )
+ event = _result_event("write_file", WriteFile, result)
+ update = tool_result_session_update(event)
+
+ assert isinstance(update, ToolCallProgress)
+ assert update.locations is not None
+ assert update.locations[0].path == str(Path("out.txt").resolve())
diff --git a/tests/acp/test_write_file.py b/tests/acp/test_write_file.py
index aafa851..f627f7c 100644
--- a/tests/acp/test_write_file.py
+++ b/tests/acp/test_write_file.py
@@ -211,7 +211,7 @@ class TestAcpWriteFileSessionUpdates:
assert update.content[0].new_text == "Hello"
assert update.locations is not None
assert len(update.locations) == 1
- assert update.locations[0].path == "/tmp/test.txt"
+ assert update.locations[0].path == str(Path("/tmp/test.txt").resolve())
def test_tool_call_session_update_invalid_args(self) -> None:
from vibe.core.types import FunctionCall, ToolCall
@@ -232,7 +232,8 @@ class TestAcpWriteFileSessionUpdates:
)
update = WriteFile.tool_call_session_update(event)
- assert update is None
+ assert update is not None
+ assert update.title == "write_file"
def test_tool_result_session_update(self) -> None:
result = WriteFileResult(
@@ -260,7 +261,7 @@ class TestAcpWriteFileSessionUpdates:
assert update.content[0].new_text == "Hello"
assert update.locations is not None
assert len(update.locations) == 1
- assert update.locations[0].path == "/tmp/test.txt"
+ assert update.locations[0].path == str(Path("/tmp/test.txt").resolve())
def test_tool_result_session_update_invalid_result(self) -> None:
class InvalidResult:
@@ -274,4 +275,5 @@ class TestAcpWriteFileSessionUpdates:
)
update = WriteFile.tool_result_session_update(event)
- assert update is None
+ assert update is not None
+ assert update.status == "failed"
diff --git a/tests/cli/test_connector_auth_app.py b/tests/cli/test_connector_auth_app.py
new file mode 100644
index 0000000..d22fba9
--- /dev/null
+++ b/tests/cli/test_connector_auth_app.py
@@ -0,0 +1,368 @@
+from __future__ import annotations
+
+from typing import Any, cast
+from unittest.mock import MagicMock, patch
+
+import pytest
+from textual.widgets import OptionList
+
+from tests.stubs.fake_connector_registry import FakeConnectorRegistry
+from vibe.cli.textual_ui.widgets.connector_auth_app import (
+ ConnectorAuthApp,
+ _AuthOptionId,
+)
+from vibe.core.tools.connectors.connector_registry import RemoteTool
+
+
+def _make_registry(*, with_disconnected: bool = True) -> FakeConnectorRegistry:
+ connectors: dict[str, list[RemoteTool]] = {
+ "gmail": [RemoteTool(name="search", description="Search emails")]
+ }
+ if with_disconnected:
+ connectors["slack"] = []
+ return FakeConnectorRegistry(connectors=connectors)
+
+
+def _make_app(
+ connector_name: str = "slack", registry: FakeConnectorRegistry | None = None
+) -> ConnectorAuthApp:
+ reg = registry or _make_registry()
+ mgr = MagicMock()
+ return ConnectorAuthApp(
+ connector_name=connector_name, connector_registry=reg, tool_manager=mgr
+ )
+
+
+class TestConnectorAuthAppInit:
+ def test_widget_id(self) -> None:
+ app = _make_app()
+ assert app.id == "connectorauth-app"
+
+ def test_stores_connector_name(self) -> None:
+ app = _make_app(connector_name="gmail")
+ assert app._connector_name == "gmail"
+
+ def test_initial_state(self) -> None:
+ app = _make_app()
+ assert app._auth_url is None
+ assert app._auth_url_visible is False
+ assert app._status_message is None
+
+
+class TestAuthUrlFetched:
+ def test_auth_url_available_shows_menu(self) -> None:
+ app = _make_app()
+ option_list = MagicMock()
+ option_list.get_option_index.return_value = 2
+ detail = MagicMock()
+ help_widget = MagicMock()
+
+ def query(sel, *a, **kw): # type: ignore[no-untyped-def]
+ if sel is OptionList:
+ return option_list
+ s = str(sel)
+ if "detail" in s:
+ return detail
+ if "help" in s:
+ return help_widget
+ return MagicMock()
+
+ app.query_one = query # type: ignore[assignment]
+
+ app._on_auth_url_fetched("https://auth.example.com/oauth")
+
+ assert app._auth_url == "https://auth.example.com/oauth"
+ assert option_list.clear_options.called
+ assert option_list.add_option.call_count == 5
+ option_ids = [
+ call.args[0].id
+ for call in option_list.add_option.call_args_list
+ if hasattr(call.args[0], "id") and call.args[0].id
+ ]
+ assert _AuthOptionId.OPEN in option_ids
+ assert _AuthOptionId.COPY in option_ids
+ assert _AuthOptionId.SHOW in option_ids
+
+ def test_auth_url_none_shows_no_auth_message(self) -> None:
+ app = _make_app()
+ option_list = MagicMock()
+ detail = MagicMock()
+ help_widget = MagicMock()
+
+ def query(sel, *a, **kw): # type: ignore[no-untyped-def]
+ if sel is OptionList:
+ return option_list
+ s = str(sel)
+ if "detail" in s:
+ return detail
+ if "help" in s:
+ return help_widget
+ return MagicMock()
+
+ app.query_one = query # type: ignore[assignment]
+
+ app._on_auth_url_fetched(None)
+
+ assert app._auth_url is None
+ last_option = option_list.add_option.call_args_list[-1].args[0]
+ assert "does not provide authentication" in str(last_option.prompt)
+
+
+class TestAuthActions:
+ def test_open_browser_calls_webbrowser(self) -> None:
+ app = _make_app()
+ app._auth_url = "https://auth.example.com/oauth"
+ app.query_one = MagicMock()
+
+ with patch("vibe.cli.textual_ui.widgets.connector_auth_app.webbrowser") as wb:
+ app._open_browser()
+ wb.open.assert_called_once_with("https://auth.example.com/oauth")
+
+ assert app._status_message == "Opened in browser."
+
+ def test_open_browser_noop_without_url(self) -> None:
+ app = _make_app()
+ app._auth_url = None
+ with patch("vibe.cli.textual_ui.widgets.connector_auth_app.webbrowser") as wb:
+ app._open_browser()
+ wb.open.assert_not_called()
+
+ def test_copy_url_calls_clipboard(self) -> None:
+ app = cast(Any, _make_app())
+ app._auth_url = "https://auth.example.com/oauth"
+
+ with (
+ patch.object(
+ type(app), "app", new_callable=lambda: property(lambda s: MagicMock())
+ ),
+ patch(
+ "vibe.cli.textual_ui.widgets.connector_auth_app.copy_text_to_clipboard"
+ ) as copy_fn,
+ ):
+ app._copy_url()
+ copy_fn.assert_called_once()
+ assert copy_fn.call_args.args[1] == "https://auth.example.com/oauth"
+ assert (
+ copy_fn.call_args.kwargs["success_message"]
+ == "Auth URL copied to clipboard"
+ )
+
+ def test_copy_url_noop_without_url(self) -> None:
+ app = _make_app()
+ app._auth_url = None
+ with patch(
+ "vibe.cli.textual_ui.widgets.connector_auth_app.copy_text_to_clipboard"
+ ) as copy_fn:
+ app._copy_url()
+ copy_fn.assert_not_called()
+
+ def test_toggle_url_shows_then_hides(self) -> None:
+ app = cast(Any, _make_app())
+ app._auth_url = "https://auth.example.com/oauth"
+ detail = MagicMock()
+ app.query_one = lambda sel, *a, **kw: detail
+
+ assert app._auth_url_visible is False
+
+ app._toggle_url()
+ assert app._auth_url_visible is True
+ text = detail.update.call_args.args[0]
+ assert "https://auth.example.com/oauth" in text
+ assert "Once authenticated" in text
+
+ app._toggle_url()
+ assert app._auth_url_visible is False
+ text = detail.update.call_args.args[0]
+ assert "https://auth.example.com/oauth" not in text
+
+ def test_toggle_url_noop_without_url(self) -> None:
+ app = _make_app()
+ app._auth_url = None
+ app._toggle_url()
+ assert app._auth_url_visible is False
+
+
+class TestDetailText:
+ def test_without_url_visible(self) -> None:
+ app = cast(Any, _make_app())
+ app._auth_url = "https://auth.example.com/oauth"
+ app._auth_url_visible = False
+ detail = MagicMock()
+ app.query_one = lambda sel, *a, **kw: detail
+
+ app._update_detail_text()
+
+ text = detail.update.call_args.args[0]
+ assert "Once authenticated, press R to refresh" in text
+ assert "https://auth.example.com" not in text
+
+ def test_with_url_visible(self) -> None:
+ app = cast(Any, _make_app())
+ app._auth_url = "https://auth.example.com/oauth"
+ app._auth_url_visible = True
+ detail = MagicMock()
+ app.query_one = lambda sel, *a, **kw: detail
+
+ app._update_detail_text()
+
+ text = detail.update.call_args.args[0]
+ assert "https://auth.example.com/oauth" in text
+ assert "Once authenticated, press R to refresh" in text
+ # URL should come before the footer
+ url_pos = text.index("https://auth.example.com/oauth")
+ footer_pos = text.index("Once authenticated")
+ assert url_pos < footer_pos
+
+
+class TestCloseAndRefresh:
+ def test_action_close_posts_message(self) -> None:
+ app = _make_app()
+ app.post_message = MagicMock()
+
+ app.action_close()
+
+ msg = app.post_message.call_args.args[0]
+ assert isinstance(msg, ConnectorAuthApp.ConnectorAuthClosed)
+ assert msg.refreshed is False
+
+ def test_on_connector_refreshed_with_tools_posts_closed(self) -> None:
+ app = _make_app()
+ app.post_message = MagicMock()
+
+ app._on_connector_refreshed(3)
+
+ msg = app.post_message.call_args.args[0]
+ assert isinstance(msg, ConnectorAuthApp.ConnectorAuthClosed)
+ assert msg.refreshed is True
+ assert msg.connector_name == "slack"
+ assert "3 tools" in (app._status_message or "")
+
+ def test_on_connector_refreshed_without_tools_stays_on_page(self) -> None:
+ app = _make_app()
+ app.post_message = MagicMock()
+ app.query_one = MagicMock()
+
+ app._on_connector_refreshed(0)
+
+ app.post_message.assert_not_called()
+ assert app._status_message is not None
+ assert "pending" in app._status_message
+
+ @pytest.mark.asyncio
+ async def test_action_refresh_dispatches_worker(self) -> None:
+ app = _make_app()
+ app.run_worker = MagicMock()
+ app.query_one = MagicMock()
+
+ await app.action_refresh()
+
+ assert app._status_message == "Refreshing connector..."
+ app.run_worker.assert_called_once()
+ _, kwargs = app.run_worker.call_args
+ assert kwargs["group"] == "connector_refresh"
+
+
+class TestOptionSelection:
+ def test_auth_open_dispatches(self) -> None:
+ app = _make_app()
+ app._auth_url = "https://auth.example.com"
+ app.query_one = MagicMock()
+ event = MagicMock()
+ event.option.id = _AuthOptionId.OPEN
+
+ with patch("vibe.cli.textual_ui.widgets.connector_auth_app.webbrowser") as wb:
+ app.on_option_list_option_selected(event)
+ wb.open.assert_called_once()
+
+ def test_auth_copy_dispatches(self) -> None:
+ app = cast(Any, _make_app())
+ app._auth_url = "https://auth.example.com"
+ event = MagicMock()
+ event.option.id = _AuthOptionId.COPY
+
+ with (
+ patch.object(
+ type(app), "app", new_callable=lambda: property(lambda s: MagicMock())
+ ),
+ patch(
+ "vibe.cli.textual_ui.widgets.connector_auth_app.copy_text_to_clipboard"
+ ) as copy_fn,
+ ):
+ app.on_option_list_option_selected(event)
+ copy_fn.assert_called_once()
+
+ def test_auth_show_dispatches(self) -> None:
+ app = cast(Any, _make_app())
+ app._auth_url = "https://auth.example.com"
+ app.query_one = MagicMock()
+ event = MagicMock()
+ event.option.id = _AuthOptionId.SHOW
+
+ app.on_option_list_option_selected(event)
+ assert app._auth_url_visible is True
+
+ def test_unknown_option_is_noop(self) -> None:
+ app = _make_app()
+ event = MagicMock()
+ event.option.id = "something:else"
+ # Should not raise
+ app.on_option_list_option_selected(event)
+
+
+class TestWorkerStateChanged:
+ def test_auth_url_worker_dispatches(self) -> None:
+ app = _make_app()
+ app.query_one = MagicMock()
+ worker = MagicMock()
+ worker.group = "auth_url"
+ worker.is_finished = True
+ worker.result = "https://auth.example.com"
+ event = MagicMock()
+ event.worker = worker
+
+ app.on_worker_state_changed(event)
+
+ assert app._auth_url == "https://auth.example.com"
+
+ def test_connector_refresh_worker_with_tools_posts_closed(self) -> None:
+ app = _make_app()
+ app.post_message = MagicMock()
+ worker = MagicMock()
+ worker.group = "connector_refresh"
+ worker.is_finished = True
+ worker.result = 5
+ event = MagicMock()
+ event.worker = worker
+
+ app.on_worker_state_changed(event)
+
+ msg = app.post_message.call_args.args[0]
+ assert isinstance(msg, ConnectorAuthApp.ConnectorAuthClosed)
+ assert msg.refreshed is True
+
+ def test_connector_refresh_worker_without_tools_stays(self) -> None:
+ app = _make_app()
+ app.post_message = MagicMock()
+ app.query_one = MagicMock()
+ worker = MagicMock()
+ worker.group = "connector_refresh"
+ worker.is_finished = True
+ worker.result = 0
+ event = MagicMock()
+ event.worker = worker
+
+ app.on_worker_state_changed(event)
+
+ app.post_message.assert_not_called()
+ assert "pending" in (app._status_message or "")
+
+ def test_unrelated_worker_is_ignored(self) -> None:
+ app = _make_app()
+ worker = MagicMock()
+ worker.group = "other"
+ worker.is_finished = True
+ event = MagicMock()
+ event.worker = worker
+
+ # Should not raise
+ app.on_worker_state_changed(event)
diff --git a/tests/cli/test_mcp_app.py b/tests/cli/test_mcp_app.py
index 132d1b9..6b9c80e 100644
--- a/tests/cli/test_mcp_app.py
+++ b/tests/cli/test_mcp_app.py
@@ -8,7 +8,10 @@ import pytest
from tests.stubs.fake_connector_registry import FakeConnectorRegistry
from vibe.cli.textual_ui.widgets.mcp_app import (
+ _LIST_VIEW_HELP_AUTH,
+ _LIST_VIEW_HELP_TOOLS,
MCPApp,
+ MCPSourceKind,
_sort_connector_names_for_menu,
collect_mcp_tool_index,
)
@@ -258,3 +261,115 @@ class TestConnectorMenuOrdering:
)
assert ordered == ["alpha", "Beta", "Zeta"]
+
+
+class TestHelpBarChanges:
+ """The help bar should show 'Enter Authenticate' for disconnected connectors."""
+
+ def _make_app_with_connectors(self) -> MCPApp:
+ registry = FakeConnectorRegistry(
+ connectors={
+ "gmail": [RemoteTool(name="search", description="Search")],
+ "slack": [],
+ }
+ )
+ mgr = _make_tool_manager({})
+ return MCPApp(
+ mcp_servers=[MCPStdio(name="srv", transport="stdio", command="cmd")],
+ tool_manager=mgr,
+ connector_registry=registry,
+ )
+
+ def test_help_shows_show_tools_for_server(self) -> None:
+ app = self._make_app_with_connectors()
+ option = MagicMock()
+ option.id = "server:srv"
+ assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
+
+ def test_help_shows_show_tools_for_connected_connector(self) -> None:
+ app = self._make_app_with_connectors()
+ option = MagicMock()
+ option.id = "connector:gmail"
+ assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
+
+ def test_help_shows_authenticate_for_disconnected_connector(self) -> None:
+ app = self._make_app_with_connectors()
+ option = MagicMock()
+ option.id = "connector:slack"
+ assert app._list_help_for_option(option) == _LIST_VIEW_HELP_AUTH
+
+ def test_help_defaults_to_tools_for_unknown_option(self) -> None:
+ app = self._make_app_with_connectors()
+ option = MagicMock()
+ option.id = ""
+ assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
+
+ def test_help_shows_tools_without_registry(self) -> None:
+ mgr = _make_tool_manager({})
+ app = MCPApp(mcp_servers=[], tool_manager=mgr)
+ option = MagicMock()
+ option.id = "connector:slack"
+ assert app._list_help_for_option(option) == _LIST_VIEW_HELP_TOOLS
+
+
+class TestConnectorAuthRequested:
+ """Clicking a disconnected connector posts ConnectorAuthRequested."""
+
+ def test_disconnected_connector_posts_auth_requested(self) -> None:
+ registry = FakeConnectorRegistry(connectors={"slack": []})
+ mgr = _make_tool_manager({})
+ app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry)
+ app.query_one = MagicMock()
+ app.post_message = MagicMock()
+
+ option_list = MagicMock()
+ app._show_detail_view(
+ "slack", option_list, app._index, kind=MCPSourceKind.CONNECTOR
+ )
+
+ app.post_message.assert_called_once()
+ msg = app.post_message.call_args.args[0]
+ assert isinstance(msg, MCPApp.ConnectorAuthRequested)
+ assert msg.connector_name == "slack"
+ assert msg.connector_registry is registry
+ assert msg.tool_manager is mgr
+
+ def test_connected_connector_with_no_indexed_tools_shows_message(self) -> None:
+ registry = MagicMock()
+ registry.get_connector_names.return_value = ["slack"]
+ registry.is_connected.return_value = True
+ mgr = _make_tool_manager({})
+ app = MCPApp(mcp_servers=[], tool_manager=mgr, connector_registry=registry)
+ app.query_one = MagicMock()
+ app.post_message = MagicMock()
+
+ option_list = MagicMock()
+ app._show_detail_view(
+ "slack", option_list, app._index, kind=MCPSourceKind.CONNECTOR
+ )
+
+ app.post_message.assert_not_called()
+ option_list.add_option.assert_called_once()
+ assert "No tools discovered" in str(
+ option_list.add_option.call_args.args[0].prompt
+ )
+
+ def test_server_with_no_tools_shows_message(self) -> None:
+ mgr = _make_tool_manager({})
+ app = MCPApp(
+ mcp_servers=[MCPStdio(name="srv", transport="stdio", command="cmd")],
+ tool_manager=mgr,
+ )
+ app.query_one = MagicMock()
+ app.post_message = MagicMock()
+
+ option_list = MagicMock()
+ app._show_detail_view("srv", option_list, app._index, kind=MCPSourceKind.SERVER)
+
+ # Should NOT post ConnectorAuthRequested for servers
+ app.post_message.assert_not_called()
+ # Should add a "no tools" option instead
+ option_list.add_option.assert_called_once()
+ assert "No tools discovered" in str(
+ option_list.add_option.call_args.args[0].prompt
+ )
diff --git a/tests/core/test_config_patch_ops.py b/tests/core/test_config_patch_ops.py
new file mode 100644
index 0000000..c0089d9
--- /dev/null
+++ b/tests/core/test_config_patch_ops.py
@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import FrozenInstanceError
+from typing import Any, get_args
+
+import pytest
+
+from vibe.core.config import (
+ AppendToList,
+ DeleteField,
+ PatchOp,
+ RemoveFromList,
+ SetField,
+)
+
+
+def test_patch_op_union_contains_all_operations() -> None:
+ assert get_args(PatchOp) == (SetField, AppendToList, RemoveFromList, DeleteField)
+
+
+def test_set_field_accepts_top_level_key() -> None:
+ op = SetField("active_model", "devstral-small")
+
+ assert op.key == "active_model"
+ assert op.value == "devstral-small"
+
+
+def test_set_field_accepts_nested_key() -> None:
+ op = SetField("models.providers", {"mistral": {"region": "eu"}})
+
+ assert op.key == "models.providers"
+
+
+def test_append_to_list_accepts_nested_key() -> None:
+ op = AppendToList("tools.disabled_tools", ("bash", "python"))
+
+ assert op.key == "tools.disabled_tools"
+ assert op.items == ("bash", "python")
+
+
+def test_remove_from_list_accepts_nested_key() -> None:
+ op = RemoveFromList("models.available_models", ("codestral-latest",))
+
+ assert op.key == "models.available_models"
+ assert op.values == ("codestral-latest",)
+
+
+def test_delete_field_accepts_nested_key() -> None:
+ op = DeleteField("tools.deprecated_setting")
+
+ assert op.key == "tools.deprecated_setting"
+
+
+@pytest.mark.parametrize(
+ "factory",
+ [
+ lambda key: SetField(key, "value"),
+ lambda key: AppendToList(key, ("value",)),
+ lambda key: RemoveFromList(key, ("value",)),
+ lambda key: DeleteField(key),
+ ],
+)
+@pytest.mark.parametrize(
+ "invalid_key", ["", ".active_model", "active_model.", "tools..bash"]
+)
+def test_patch_operations_reject_invalid_key_paths(
+ factory: Callable[[str], object], invalid_key: str
+) -> None:
+ with pytest.raises(ValueError, match="dot-separated path|must not be empty"):
+ factory(invalid_key)
+
+
+@pytest.mark.parametrize(
+ "factory",
+ [
+ lambda key: SetField(key, "value"),
+ lambda key: AppendToList(key, ("value",)),
+ lambda key: RemoveFromList(key, ("value",)),
+ lambda key: DeleteField(key),
+ ],
+)
+def test_patch_operations_reject_non_string_keys(
+ factory: Callable[[Any], object],
+) -> None:
+ with pytest.raises(TypeError, match="Patch operation key must be a string"):
+ factory(1)
+
+
+def test_append_to_list_rejects_non_tuple_items() -> None:
+ bad_items: Any = ["bash"]
+
+ with pytest.raises(TypeError, match="AppendToList.items must be a tuple"):
+ AppendToList("tools.disabled_tools", bad_items)
+
+
+def test_remove_from_list_rejects_non_tuple_values() -> None:
+ bad_values: Any = ["bash"]
+
+ with pytest.raises(TypeError, match="RemoveFromList.values must be a tuple"):
+ RemoveFromList("tools.disabled_tools", bad_values)
+
+
+def test_patch_operations_are_frozen() -> None:
+ op = SetField("active_model", "devstral-small")
+
+ with pytest.raises(FrozenInstanceError):
+ op.__setattr__("key", "models.active_model")
+
+
+def test_scenario_mini_vibe_patch_operations() -> None:
+ operations: list[PatchOp] = [
+ SetField("active_model", "devstral-small"),
+ AppendToList("tools.disabled_tools", ("bash",)),
+ RemoveFromList("models.available_models", ("codestral-latest",)),
+ DeleteField("tools.deprecated_setting"),
+ ]
+
+ assert operations == [
+ SetField("active_model", "devstral-small"),
+ AppendToList("tools.disabled_tools", ("bash",)),
+ RemoveFromList("models.available_models", ("codestral-latest",)),
+ DeleteField("tools.deprecated_setting"),
+ ]
diff --git a/tests/core/test_config_resolution.py b/tests/core/test_config_resolution.py
index c982dee..973c57d 100644
--- a/tests/core/test_config_resolution.py
+++ b/tests/core/test_config_resolution.py
@@ -293,6 +293,241 @@ class TestMigrateLeavesFindInBashAllowlist:
assert "tools" not in result
+class TestMigrateMistralVibeCliLatestDefaults:
+ def test_updates_alias_temperature_and_thinking_for_default_model(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("VIBE_HOME", str(tmp_path))
+ config_file = tmp_path / "config.toml"
+ data = {
+ "models": [
+ {
+ "name": "mistral-vibe-cli-latest",
+ "provider": "mistral",
+ "alias": "devstral-2",
+ "temperature": 0.2,
+ "thinking": "off",
+ }
+ ]
+ }
+ with config_file.open("wb") as f:
+ tomli_w.dump(data, f)
+
+ reset_harness_files_manager()
+ init_harness_files_manager("user")
+ VibeConfig._migrate()
+
+ with config_file.open("rb") as f:
+ result = tomllib.load(f)
+ assert result["models"][0]["alias"] == "mistral-medium-3.5"
+ assert result["models"][0]["temperature"] == 1.0
+ assert result["models"][0]["input_price"] == 1.5
+ assert result["models"][0]["output_price"] == 7.5
+ assert result["models"][0]["thinking"] == "high"
+
+ def test_updates_active_model_when_devstral_2(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("VIBE_HOME", str(tmp_path))
+ config_file = tmp_path / "config.toml"
+ data = {
+ "active_model": "devstral-2",
+ "models": [
+ {
+ "name": "mistral-vibe-cli-latest",
+ "provider": "mistral",
+ "alias": "devstral-2",
+ }
+ ],
+ }
+ with config_file.open("wb") as f:
+ tomli_w.dump(data, f)
+
+ reset_harness_files_manager()
+ init_harness_files_manager("user")
+ VibeConfig._migrate()
+
+ with config_file.open("rb") as f:
+ result = tomllib.load(f)
+ assert result["active_model"] == "mistral-medium-3.5"
+
+ def test_adds_temperature_and_thinking_when_missing(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("VIBE_HOME", str(tmp_path))
+ config_file = tmp_path / "config.toml"
+ data = {
+ "models": [
+ {
+ "name": "mistral-vibe-cli-latest",
+ "provider": "mistral",
+ "alias": "devstral-2",
+ }
+ ]
+ }
+ with config_file.open("wb") as f:
+ tomli_w.dump(data, f)
+
+ reset_harness_files_manager()
+ init_harness_files_manager("user")
+ VibeConfig._migrate()
+
+ with config_file.open("rb") as f:
+ result = tomllib.load(f)
+ assert result["models"][0]["alias"] == "mistral-medium-3.5"
+ assert result["models"][0]["temperature"] == 1.0
+ assert result["models"][0]["input_price"] == 1.5
+ assert result["models"][0]["output_price"] == 7.5
+ assert result["models"][0]["thinking"] == "high"
+
+ def test_skips_model_with_customized_alias(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("VIBE_HOME", str(tmp_path))
+ config_file = tmp_path / "config.toml"
+ data = {
+ "models": [
+ {
+ "name": "mistral-vibe-cli-latest",
+ "provider": "mistral",
+ "alias": "my-custom-alias",
+ "temperature": 0.2,
+ "thinking": "off",
+ }
+ ]
+ }
+ with config_file.open("wb") as f:
+ tomli_w.dump(data, f)
+
+ reset_harness_files_manager()
+ init_harness_files_manager("user")
+ VibeConfig._migrate()
+
+ with config_file.open("rb") as f:
+ result = tomllib.load(f)
+ assert result["models"][0]["alias"] == "my-custom-alias"
+ assert result["models"][0]["temperature"] == 0.2
+ assert result["models"][0]["thinking"] == "off"
+
+ def test_does_not_touch_other_models(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("VIBE_HOME", str(tmp_path))
+ config_file = tmp_path / "config.toml"
+ data = {
+ "models": [
+ {
+ "name": "mistral-vibe-cli-latest",
+ "provider": "mistral",
+ "alias": "devstral-2",
+ },
+ {
+ "name": "other-model",
+ "provider": "mistral",
+ "alias": "devstral-2-clone",
+ "temperature": 0.5,
+ "thinking": "low",
+ },
+ ]
+ }
+ with config_file.open("wb") as f:
+ tomli_w.dump(data, f)
+
+ reset_harness_files_manager()
+ init_harness_files_manager("user")
+ VibeConfig._migrate()
+
+ with config_file.open("rb") as f:
+ result = tomllib.load(f)
+ assert result["models"][1]["alias"] == "devstral-2-clone"
+ assert result["models"][1]["temperature"] == 0.5
+ assert result["models"][1]["thinking"] == "low"
+
+ def test_noop_when_no_models_section(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("VIBE_HOME", str(tmp_path))
+ config_file = tmp_path / "config.toml"
+ data = {"theme": "dark"}
+ with config_file.open("wb") as f:
+ tomli_w.dump(data, f)
+
+ reset_harness_files_manager()
+ init_harness_files_manager("user")
+ VibeConfig._migrate()
+
+ with config_file.open("rb") as f:
+ result = tomllib.load(f)
+ assert result == {"theme": "dark"}
+
+ def test_idempotent_when_already_migrated(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("VIBE_HOME", str(tmp_path))
+ config_file = tmp_path / "config.toml"
+ data = {
+ "active_model": "mistral-medium-3.5",
+ "models": [
+ {
+ "name": "mistral-vibe-cli-latest",
+ "provider": "mistral",
+ "alias": "mistral-medium-3.5",
+ "temperature": 1.0,
+ "input_price": 1.5,
+ "output_price": 7.5,
+ "thinking": "high",
+ }
+ ],
+ }
+ with config_file.open("wb") as f:
+ tomli_w.dump(data, f)
+
+ reset_harness_files_manager()
+ init_harness_files_manager("user")
+ VibeConfig._migrate()
+ VibeConfig._migrate()
+
+ with config_file.open("rb") as f:
+ result = tomllib.load(f)
+ assert result["active_model"] == "mistral-medium-3.5"
+ assert result["models"][0]["alias"] == "mistral-medium-3.5"
+ assert result["models"][0]["temperature"] == 1.0
+ assert result["models"][0]["input_price"] == 1.5
+ assert result["models"][0]["output_price"] == 7.5
+ assert result["models"][0]["thinking"] == "high"
+
+ def test_migrates_model_and_active_model_together(
+ self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ ) -> None:
+ monkeypatch.setenv("VIBE_HOME", str(tmp_path))
+ config_file = tmp_path / "config.toml"
+ data = {
+ "active_model": "devstral-2",
+ "models": [
+ {
+ "name": "mistral-vibe-cli-latest",
+ "provider": "mistral",
+ "alias": "devstral-2",
+ }
+ ],
+ }
+ with config_file.open("wb") as f:
+ tomli_w.dump(data, f)
+
+ reset_harness_files_manager()
+ init_harness_files_manager("user")
+ VibeConfig._migrate()
+
+ with config_file.open("rb") as f:
+ result = tomllib.load(f)
+ assert result["active_model"] == "mistral-medium-3.5"
+ assert result["models"][0]["alias"] == "mistral-medium-3.5"
+ assert result["models"][0]["temperature"] == 1.0
+ assert result["models"][0]["input_price"] == 1.5
+ assert result["models"][0]["output_price"] == 7.5
+ assert result["models"][0]["thinking"] == "high"
+
+
class TestAutoCompactThresholdFallback:
def test_model_without_explicit_threshold_inherits_global(self) -> None:
model = ModelConfig(name="m", provider="p", alias="m")
diff --git a/tests/core/test_teleport_service.py b/tests/core/test_teleport_service.py
index e6cd8dc..a8e1998 100644
--- a/tests/core/test_teleport_service.py
+++ b/tests/core/test_teleport_service.py
@@ -357,6 +357,7 @@ class TestTeleportServiceExecute:
github_pending = MagicMock()
github_pending.connected = False
github_pending.oauth_url = "https://github.com/login/oauth"
+ github_pending.error = None
github_pending.status = GitHubStatus.WAITING_FOR_OAUTH
github_connected = MagicMock()
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg
new file mode 100644
index 0000000..926cabd
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_back_to_mcp.svg
@@ -0,0 +1,206 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_opens_on_disconnected.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_opens_on_disconnected.svg
new file mode 100644
index 0000000..9d60d6e
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_opens_on_disconnected.svg
@@ -0,0 +1,203 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_show_url.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_show_url.svg
new file mode 100644
index 0000000..09bc1f9
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_connector_auth_show_url.svg
@@ -0,0 +1,203 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg
index 1a2971c..687a88f 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_backspace_returns_to_overview.svg
@@ -162,7 +162,7 @@
-
+
@@ -181,22 +181,22 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp filesystem
-⎣MCP servers opened...
-
+
+┃/mcp filesystem
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│MCP Servers│
-││
-│Local MCP Servers│
-│ filesystem [stdio] 1 tool│
-│ search [http] 1 tool│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│MCP Servers│
+││
+│Local MCP Servers│
+│ filesystem [stdio] 1 tool│
+│ search [http] 1 tool│
│↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg
index 6f20c7a..04a3ed6 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_broken_server.svg
@@ -162,7 +162,7 @@
-
+
@@ -180,23 +180,23 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp
-⎣MCP servers opened...
-
+
+┃/mcp
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│MCP Servers│
-││
-│Local MCP Servers│
-│ filesystem [stdio] 1 tool│
-│ broken-server [stdio] no tools│
-│ search [http] 1 tool│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│MCP Servers│
+││
+│Local MCP Servers│
+│ filesystem [stdio] 1 tool│
+│ broken-server [stdio] no tools│
+│ search [http] 1 tool│
│↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg
index 3b4c923..7a3c667 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connector_back_to_overview.svg
@@ -163,7 +163,7 @@
-
+
@@ -179,25 +179,25 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp
-⎣MCP servers opened...
-
+
+┃/mcp
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│MCP Servers & Connectors│
-││
-│Local MCP Servers│
-│ filesystem [stdio] 1 tool│
-││
-│Workspace Connectors│
-│ gmail [connector] 3 tools● connected│
-│ slack [connector] 2 tools● connected│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│MCP Servers & Connectors│
+││
+│Local MCP Servers│
+│ filesystem [stdio] 1 tool│
+││
+│Workspace Connectors│
+│ gmail [connector] 3 tools● connected│
+│ slack [connector] 2 tools● connected│
│↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg
index fe45260..3cd6643 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_only.svg
@@ -164,7 +164,7 @@
-
+
@@ -183,22 +183,22 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 0 MCP servers · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 0 MCP servers · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp
-⎣MCP servers opened...
-
+
+┃/mcp
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│MCP Servers & Connectors│
-││
-│Workspace Connectors│
-│ gmail [connector] 3 tools● connected│
-│ slack [connector] 2 tools● connected│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│MCP Servers & Connectors│
+││
+│Workspace Connectors│
+│ gmail [connector] 3 tools● connected│
+│ slack [connector] 2 tools● connected│
│↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg
index bb69b8d..1f59f1e 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_connectors_sorted_by_status.svg
@@ -163,7 +163,7 @@
-
+
@@ -181,23 +181,23 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 3 connectors · 0 MCP servers · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp
-⎣MCP servers opened...
-
+
+┃/mcp
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│MCP Servers & Connectors│
-││
-│Workspace Connectors│
-│ alpha [connector] 1 tool ● connected│
-│ beta [connector] no tools○ not connected│
-│ zeta [connector] no tools○ not connected│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│MCP Servers & Connectors│
+││
+│Workspace Connectors│
+│ alpha [connector] 1 tool ● connected│
+│ beta [connector] no tools○ not connected│
+│ zeta [connector] no tools○ not connected│
│↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg
index 59f7d9f..4d85cf0 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_drill_into_connector.svg
@@ -160,7 +160,7 @@
-
+
@@ -180,21 +180,21 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp
-⎣MCP servers opened...
-
+
+┃/mcp
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│Connector: slack│
-││
-│search_messages - Search Slack messages│
-│send_message - Send a Slack message│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│Connector: slack│
+││
+│search_messages - Search Slack messages│
+│send_message - Send a Slack message│
│↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg
index 16b75c9..43212c7 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_enter_drills_into_server.svg
@@ -160,7 +160,7 @@
-
+
@@ -181,20 +181,20 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp
-⎣MCP servers opened...
-
+
+┃/mcp
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│MCP Server: filesystem│
-││
-│fake_tool - A fake tool for filesystem│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│MCP Server: filesystem│
+││
+│fake_tool - A fake tool for filesystem│
│↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_authenticate.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_authenticate.svg
new file mode 100644
index 0000000..802fa4c
--- /dev/null
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_help_bar_shows_authenticate.svg
@@ -0,0 +1,206 @@
+
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg
index 70f1944..48cd5ea 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview.svg
@@ -162,7 +162,7 @@
-
+
@@ -181,22 +181,22 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp
-⎣MCP servers opened...
-
+
+┃/mcp
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│MCP Servers│
-││
-│Local MCP Servers│
-│ filesystem [stdio] 1 tool│
-│ search [http] 1 tool│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│MCP Servers│
+││
+│Local MCP Servers│
+│ filesystem [stdio] 1 tool│
+│ search [http] 1 tool│
│↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg
index 5694c37..26ecc77 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_overview_navigate_down.svg
@@ -162,7 +162,7 @@
-
+
@@ -181,22 +181,22 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp
-⎣MCP servers opened...
-
+
+┃/mcp
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│MCP Servers│
-││
-│Local MCP Servers│
-│ filesystem [stdio] 1 tool│
-│ search [http] 1 tool│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│MCP Servers│
+││
+│Local MCP Servers│
+│ filesystem [stdio] 1 tool│
+│ search [http] 1 tool│
│↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg
index 25716a9..1327316 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_refresh_shortcut.svg
@@ -162,7 +162,7 @@
-
+
@@ -181,22 +181,22 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp
-⎣MCP servers opened...
-
+
+┃/mcp
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│MCP Servers│
-││
-│Local MCP Servers│
-│ filesystem [stdio] 1 tool│
-│ search [http] 1 tool│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│MCP Servers│
+││
+│Local MCP Servers│
+│ filesystem [stdio] 1 tool│
+│ search [http] 1 tool│
│Refreshed. ↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg
index 703996f..27b442e 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_server_arg.svg
@@ -160,7 +160,7 @@
-
+
@@ -181,20 +181,20 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 MCP servers · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp filesystem
-⎣MCP servers opened...
-
+
+┃/mcp filesystem
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│MCP Server: filesystem│
-││
-│fake_tool - A fake tool for filesystem│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│MCP Server: filesystem│
+││
+│fake_tool - A fake tool for filesystem│
│↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg
index 3b4c923..7a3c667 100644
--- a/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg
+++ b/tests/snapshots/__snapshots__/test_ui_snapshot_mcp_command/test_snapshot_mcp_with_connectors_overview.svg
@@ -163,7 +163,7 @@
-
+
@@ -179,25 +179,25 @@
- ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
- ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills
- ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-
+
+ ⡠⣒⠄ ⡔⢄⠔⡄Mistral Vibe v0.0.0 · devstral-latest[off] · [Subscription] Pro
+ ⢸⠸⣀⡔⢉⠱⣃⡢⣂⡣1 model · 2 connectors · 1 MCP server · 0 skills
+ ⠉⠒⠣⠤⠵⠤⠬⠮⠆Type /help for more information
-┃/mcp
-⎣MCP servers opened...
-
+
+┃/mcp
+⎣MCP servers opened...
-┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
-│MCP Servers & Connectors│
-││
-│Local MCP Servers│
-│ filesystem [stdio] 1 tool│
-││
-│Workspace Connectors│
-│ gmail [connector] 3 tools● connected│
-│ slack [connector] 2 tools● connected│
-││
+
+┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
+│MCP Servers & Connectors│
+││
+│Local MCP Servers│
+│ filesystem [stdio] 1 tool│
+││
+│Workspace Connectors│
+│ gmail [connector] 3 tools● connected│
+│ slack [connector] 2 tools● connected│
│↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close│
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
/test/workdir0% of 200k tokens
diff --git a/tests/snapshots/test_ui_snapshot_mcp_command.py b/tests/snapshots/test_ui_snapshot_mcp_command.py
index e77c771..a89cb35 100644
--- a/tests/snapshots/test_ui_snapshot_mcp_command.py
+++ b/tests/snapshots/test_ui_snapshot_mcp_command.py
@@ -253,6 +253,93 @@ def test_snapshot_mcp_with_connectors_overview(snap_compare: SnapCompare) -> Non
)
+# ---------------------------------------------------------------------------
+# Connector auth app snapshot tests
+# ---------------------------------------------------------------------------
+
+
+@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
+def test_snapshot_connector_auth_opens_on_disconnected(
+ snap_compare: SnapCompare,
+) -> None:
+ """Clicking a disconnected connector opens the auth app."""
+
+ async def run_before(pilot: Pilot) -> None:
+ await _run_mcp_command(pilot, "/mcp")
+ # In mixed state: alpha (connected) is first, then beta, zeta (disconnected).
+ # Navigate to a disconnected connector and press enter.
+ await pilot.press("down") # beta
+ await pilot.pause(0.1)
+ await pilot.press("enter") # opens auth app
+ await pilot.pause(0.5) # wait for auth URL fetch worker
+
+ assert snap_compare(
+ "test_ui_snapshot_mcp_command.py:SnapshotTestAppConnectorsMixedState",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
+
+
+@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
+def test_snapshot_connector_auth_show_url(snap_compare: SnapCompare) -> None:
+ """Selecting 'Manually show the URL' reveals the auth URL."""
+
+ async def run_before(pilot: Pilot) -> None:
+ await _run_mcp_command(pilot, "/mcp")
+ await pilot.press("down") # beta (disconnected)
+ await pilot.pause(0.1)
+ await pilot.press("enter") # opens auth app
+ await pilot.pause(0.5) # wait for auth URL fetch
+ # Navigate to "Manually show the URL" (3rd selectable option)
+ await pilot.press("down", "down")
+ await pilot.pause(0.1)
+ await pilot.press("enter") # toggle URL display
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_mcp_command.py:SnapshotTestAppConnectorsMixedState",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
+
+
+@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
+def test_snapshot_connector_auth_back_to_mcp(snap_compare: SnapCompare) -> None:
+ """Pressing backspace in the auth app returns to the /mcp menu."""
+
+ async def run_before(pilot: Pilot) -> None:
+ await _run_mcp_command(pilot, "/mcp")
+ await pilot.press("down") # beta (disconnected)
+ await pilot.pause(0.1)
+ await pilot.press("enter") # opens auth app
+ await pilot.pause(0.5) # wait for auth URL fetch
+ await pilot.press("backspace") # back to /mcp
+ await pilot.pause(0.3)
+
+ assert snap_compare(
+ "test_ui_snapshot_mcp_command.py:SnapshotTestAppConnectorsMixedState",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
+
+
+@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
+def test_snapshot_mcp_help_bar_shows_authenticate(snap_compare: SnapCompare) -> None:
+ """Help bar shows 'Enter Authenticate' when a disconnected connector is highlighted."""
+
+ async def run_before(pilot: Pilot) -> None:
+ await _run_mcp_command(pilot, "/mcp")
+ # Navigate to a disconnected connector (beta or zeta)
+ await pilot.press("down") # beta (disconnected)
+ await pilot.pause(0.1)
+
+ assert snap_compare(
+ "test_ui_snapshot_mcp_command.py:SnapshotTestAppConnectorsMixedState",
+ terminal_size=(120, 36),
+ run_before=run_before,
+ )
+
+
@patch.dict("os.environ", {CONNECTORS_ENV_VAR: "1"})
def test_snapshot_mcp_connectors_only(snap_compare: SnapCompare) -> None:
diff --git a/tests/stubs/fake_connector_registry.py b/tests/stubs/fake_connector_registry.py
index 610a87b..f03749b 100644
--- a/tests/stubs/fake_connector_registry.py
+++ b/tests/stubs/fake_connector_registry.py
@@ -21,9 +21,11 @@ class FakeConnectorRegistry(ConnectorRegistry):
self._cache = {}
self._connector_names = []
self._connector_connected = {}
+ self._alias_to_id = {}
for connector_name, tools in self._fake_connectors.items():
alias = _normalize_name(connector_name)
connector_id = f"fake-id-{connector_name}"
+ self._alias_to_id[alias] = connector_id
tool_map: dict[str, type[BaseTool]] = {}
for remote in tools:
proxy_cls = create_connector_proxy_tool_class(
@@ -50,3 +52,13 @@ class FakeConnectorRegistry(ConnectorRegistry):
async def get_tools_async(self) -> dict[str, type[BaseTool]]:
return self.get_tools()
+
+ async def get_auth_url(self, alias: str) -> str | None:
+ """Return a fake auth URL for connectors that have no tools (not connected)."""
+ if not self.is_connected(alias):
+ return f"https://fake-auth.example.com/{alias}"
+ return None
+
+ async def refresh_connector_async(self, alias: str) -> dict[str, type[BaseTool]]:
+ """No-op refresh for tests."""
+ return {}
diff --git a/tests/test_agent_backend.py b/tests/test_agent_backend.py
index 68af45b..49db440 100644
--- a/tests/test_agent_backend.py
+++ b/tests/test_agent_backend.py
@@ -400,3 +400,28 @@ async def test_generic_provider_has_no_metadata_header() -> None:
headers = backend.requests_extra_headers[0]
assert headers is not None
assert "metadata" not in headers
+
+
+@pytest.mark.asyncio
+async def test_provider_extra_headers_are_forwarded() -> None:
+ backend = FakeBackend([mock_llm_chunk(content="Response")])
+ providers = [
+ ProviderConfig(
+ name="custom",
+ api_base="https://custom.example.com/v1",
+ extra_headers={"X-Custom-Auth": "token123", "X-Org-Id": "org-456"},
+ )
+ ]
+ models = [ModelConfig(name="test-model", provider="custom", alias="test")]
+ config = build_test_vibe_config(
+ active_model="test", models=models, providers=providers
+ )
+ agent = build_test_agent_loop(config=config, backend=backend)
+
+ [_ async for _ in agent.act("Hello")]
+
+ assert len(backend.requests_extra_headers) == 1
+ headers = backend.requests_extra_headers[0]
+ assert headers is not None
+ assert headers["X-Custom-Auth"] == "token123"
+ assert headers["X-Org-Id"] == "org-456"
diff --git a/uv.lock b/uv.lock
index 6c78cfa..38c1221 100644
--- a/uv.lock
+++ b/uv.lock
@@ -3,7 +3,7 @@ revision = 3
requires-python = ">=3.12"
[options]
-exclude-newer = "2026-04-21T14:17:22.54525Z"
+exclude-newer = "2026-04-22T13:12:26.10035Z"
exclude-newer-span = "P7D"
[options.exclude-newer-package]
@@ -808,7 +808,7 @@ wheels = [
[[package]]
name = "mistral-vibe"
-version = "2.9.0"
+version = "2.9.1"
source = { editable = "." }
dependencies = [
{ name = "agent-client-protocol" },
diff --git a/vibe/__init__.py b/vibe/__init__.py
index 3caa50d..db20725 100644
--- a/vibe/__init__.py
+++ b/vibe/__init__.py
@@ -3,4 +3,4 @@ from __future__ import annotations
from pathlib import Path
VIBE_ROOT = Path(__file__).parent
-__version__ = "2.9.0"
+__version__ = "2.9.1"
diff --git a/vibe/acp/acp_agent_loop.py b/vibe/acp/acp_agent_loop.py
index 6e92d6d..3a02fd8 100644
--- a/vibe/acp/acp_agent_loop.py
+++ b/vibe/acp/acp_agent_loop.py
@@ -367,7 +367,7 @@ class VibeAcpAgentLoop(AcpAgent):
)
def _get_acp_tool_overrides(self) -> list[Path]:
- overrides = ["todo"]
+ overrides = ["todo", "grep", "web_fetch", "web_search", "skill", "task"]
if self.client_capabilities:
if self.client_capabilities.terminal:
@@ -399,6 +399,9 @@ class VibeAcpAgentLoop(AcpAgent):
session.agent_loop.approve_always(tool_name, required_permissions)
return (ApprovalResponse.YES, None)
case ToolOption.REJECT_ONCE:
+ session.agent_loop.telemetry_client.send_user_cancelled_action(
+ "reject_approval"
+ )
return (
ApprovalResponse.NO,
"User rejected the tool call, provide an alternative plan",
@@ -946,6 +949,9 @@ class VibeAcpAgentLoop(AcpAgent):
@override
async def cancel(self, session_id: str, **kwargs: Any) -> None:
session = self._get_session(session_id)
+ session.agent_loop.telemetry_client.send_user_cancelled_action(
+ "interrupt_agent"
+ )
await session.cancel_prompt()
@override
diff --git a/vibe/acp/tools/builtins/bash.py b/vibe/acp/tools/builtins/bash.py
index 576db16..bcdee5b 100644
--- a/vibe/acp/tools/builtins/bash.py
+++ b/vibe/acp/tools/builtins/bash.py
@@ -5,7 +5,9 @@ from collections.abc import AsyncGenerator
from pathlib import Path
from acp.schema import (
+ ContentToolCallContent,
TerminalToolCallContent,
+ TextContentBlock,
ToolCallProgress,
ToolCallStart,
WaitForTerminalExitResponse,
@@ -13,6 +15,7 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
+from vibe.acp.tools.session_update import resolve_kind
from vibe.core.logger import logger
from vibe.core.tools.base import BaseToolState, InvokeContext, ToolError
from vibe.core.tools.builtins.bash import Bash as CoreBashTool, BashArgs, BashResult
@@ -116,9 +119,10 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
session_update="tool_call",
title="bash",
tool_call_id=event.tool_call_id,
- kind="execute",
+ kind=resolve_kind(event.tool_name),
content=None,
raw_input=None,
+ field_meta={"tool_name": event.tool_name},
)
if not isinstance(event.args, BashArgs):
raise ValueError(f"Unexpected tool args: {event.args}")
@@ -128,8 +132,9 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
title=Bash.get_summary(event.args),
content=None,
tool_call_id=event.tool_call_id,
- kind="execute",
+ kind=resolve_kind(event.tool_name),
raw_input=event.args.model_dump_json(),
+ field_meta={"tool_name": event.tool_name},
)
@classmethod
@@ -140,4 +145,14 @@ class Bash(CoreBashTool, BaseAcpTool[AcpBashState]):
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="failed" if event.error else "completed",
+ content=[
+ ContentToolCallContent(
+ type="content",
+ content=TextContentBlock(
+ type="text", text=cls.get_result_display(event).message
+ ),
+ )
+ ],
+ kind=resolve_kind(event.tool_name),
+ field_meta={"tool_name": event.tool_name},
)
diff --git a/vibe/acp/tools/builtins/grep.py b/vibe/acp/tools/builtins/grep.py
new file mode 100644
index 0000000..3e06a73
--- /dev/null
+++ b/vibe/acp/tools/builtins/grep.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from acp.helpers import SessionUpdate
+from acp.schema import (
+ ContentToolCallContent,
+ TextContentBlock,
+ ToolCallLocation,
+ ToolCallProgress,
+ ToolCallStart,
+)
+
+from vibe import VIBE_ROOT
+from vibe.acp.tools.base import (
+ ToolCallSessionUpdateProtocol,
+ ToolResultSessionUpdateProtocol,
+)
+from vibe.acp.tools.session_update import (
+ failed_tool_result,
+ fallback_tool_call,
+ resolve_kind,
+)
+from vibe.core.tools.builtins.grep import Grep as CoreGrepTool, GrepArgs, GrepResult
+from vibe.core.types import ToolCallEvent, ToolResultEvent
+
+
+class Grep(
+ CoreGrepTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol
+):
+ prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "grep.md"
+
+ @classmethod
+ def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
+ if not isinstance(event.args, GrepArgs):
+ return fallback_tool_call(event, "grep")
+
+ search_path = str(Path(event.args.path).resolve())
+
+ return ToolCallStart(
+ session_update="tool_call",
+ title=cls.get_call_display(event).summary,
+ tool_call_id=event.tool_call_id,
+ kind=resolve_kind(event.tool_name),
+ raw_input=event.args.model_dump_json(),
+ locations=[ToolCallLocation(path=search_path)],
+ field_meta={"tool_name": event.tool_name, "query": event.args.pattern},
+ )
+
+ @classmethod
+ def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
+ if failure := failed_tool_result(event, GrepResult):
+ return failure
+
+ result = event.result
+ assert isinstance(result, GrepResult)
+
+ locations = [
+ ToolCallLocation(path=m.path, line=m.line) for m in result.parsed_matches
+ ]
+
+ return ToolCallProgress(
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
+ status="completed",
+ content=[
+ ContentToolCallContent(
+ type="content",
+ content=TextContentBlock(
+ type="text", text=cls.get_result_display(event).message
+ ),
+ )
+ ],
+ kind=resolve_kind(event.tool_name),
+ raw_output=result.model_dump_json(),
+ locations=locations if locations else None,
+ field_meta={"tool_name": event.tool_name},
+ )
diff --git a/vibe/acp/tools/builtins/read_file.py b/vibe/acp/tools/builtins/read_file.py
index af20697..66b017f 100644
--- a/vibe/acp/tools/builtins/read_file.py
+++ b/vibe/acp/tools/builtins/read_file.py
@@ -2,8 +2,27 @@ from __future__ import annotations
from pathlib import Path
+from acp.helpers import SessionUpdate
+from acp.schema import (
+ ContentToolCallContent,
+ TextContentBlock,
+ ToolCallLocation,
+ ToolCallProgress,
+ ToolCallStart,
+)
+
from vibe import VIBE_ROOT
-from vibe.acp.tools.base import AcpToolState, BaseAcpTool
+from vibe.acp.tools.base import (
+ AcpToolState,
+ BaseAcpTool,
+ ToolCallSessionUpdateProtocol,
+ ToolResultSessionUpdateProtocol,
+)
+from vibe.acp.tools.session_update import (
+ failed_tool_result,
+ fallback_tool_call,
+ resolve_kind,
+)
from vibe.core.tools.base import ToolError
from vibe.core.tools.builtins.read_file import (
ReadFile as CoreReadFileTool,
@@ -12,6 +31,7 @@ from vibe.core.tools.builtins.read_file import (
ReadFileState,
_ReadResult,
)
+from vibe.core.types import ToolCallEvent, ToolResultEvent
ReadFileResult = ReadFileResult
@@ -20,7 +40,12 @@ class AcpReadFileState(ReadFileState, AcpToolState):
pass
-class ReadFile(CoreReadFileTool, BaseAcpTool[AcpReadFileState]):
+class ReadFile(
+ CoreReadFileTool,
+ BaseAcpTool[AcpReadFileState],
+ ToolCallSessionUpdateProtocol,
+ ToolResultSessionUpdateProtocol,
+):
state: AcpReadFileState
prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "read_file.md"
@@ -28,6 +53,69 @@ class ReadFile(CoreReadFileTool, BaseAcpTool[AcpReadFileState]):
def _get_tool_state_class(cls) -> type[AcpReadFileState]:
return AcpReadFileState
+ @classmethod
+ def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
+ if not isinstance(event.args, ReadFileArgs):
+ return fallback_tool_call(event, "read_file")
+
+ resolved = str(Path(event.args.path).resolve())
+
+ return ToolCallStart(
+ session_update="tool_call",
+ title=cls.format_call_display(event.args).summary,
+ tool_call_id=event.tool_call_id,
+ kind=resolve_kind(event.tool_name),
+ raw_input=event.args.model_dump_json(),
+ locations=[
+ ToolCallLocation(
+ path=resolved,
+ field_meta={
+ "type": "file_range",
+ "offset": event.args.offset,
+ "limit": event.args.limit,
+ },
+ )
+ ],
+ field_meta={"tool_name": event.tool_name},
+ )
+
+ @classmethod
+ def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
+ if failure := failed_tool_result(event, ReadFileResult):
+ return failure
+
+ result = event.result
+ assert isinstance(result, ReadFileResult)
+ resolved = str(Path(result.path).resolve())
+ locations = [
+ ToolCallLocation(
+ path=resolved,
+ field_meta={
+ "type": "file_range",
+ "offset": result.offset,
+ "limit": result.lines_read,
+ },
+ )
+ ]
+
+ return ToolCallProgress(
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
+ status="completed",
+ content=[
+ ContentToolCallContent(
+ type="content",
+ content=TextContentBlock(
+ type="text", text=cls.get_result_display(event).message
+ ),
+ )
+ ],
+ kind=resolve_kind(event.tool_name),
+ raw_output=result.model_dump_json(),
+ locations=locations,
+ field_meta={"tool_name": event.tool_name},
+ )
+
async def _read_file(self, args: ReadFileArgs, file_path: Path) -> _ReadResult:
client, session_id, _ = self._load_state()
diff --git a/vibe/acp/tools/builtins/search_replace.py b/vibe/acp/tools/builtins/search_replace.py
index f8557be..acb06ce 100644
--- a/vibe/acp/tools/builtins/search_replace.py
+++ b/vibe/acp/tools/builtins/search_replace.py
@@ -12,6 +12,11 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
+from vibe.acp.tools.session_update import (
+ failed_tool_result,
+ fallback_tool_call,
+ resolve_kind,
+)
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.search_replace import (
SearchReplace as CoreSearchReplaceTool,
@@ -75,18 +80,10 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
+ if not isinstance(event.args, SearchReplaceArgs):
+ return fallback_tool_call(event, "search_replace")
+
args = event.args
- if args is None:
- return ToolCallStart(
- session_update="tool_call",
- title="search_replace",
- tool_call_id=event.tool_call_id,
- kind="edit",
- content=None,
- raw_input=None,
- )
- if not isinstance(args, SearchReplaceArgs):
- return None
blocks = cls._parse_search_replace_blocks(args.content)
@@ -94,7 +91,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
session_update="tool_call",
title=cls.get_call_display(event).summary,
tool_call_id=event.tool_call_id,
- kind="edit",
+ kind=resolve_kind(event.tool_name),
content=[
FileEditToolCallContent(
type="diff",
@@ -104,22 +101,18 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
)
for block in blocks
],
- locations=[ToolCallLocation(path=args.file_path)],
+ locations=[ToolCallLocation(path=str(Path(args.file_path).resolve()))],
raw_input=args.model_dump_json(),
+ field_meta={"tool_name": event.tool_name},
)
@classmethod
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
- if event.error:
- return ToolCallProgress(
- session_update="tool_call_update",
- tool_call_id=event.tool_call_id,
- status="failed",
- )
+ if failure := failed_tool_result(event, SearchReplaceResult):
+ return failure
result = event.result
- if not isinstance(result, SearchReplaceResult):
- return None
+ assert isinstance(result, SearchReplaceResult)
blocks = cls._parse_search_replace_blocks(result.content)
@@ -127,6 +120,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="completed",
+ kind=resolve_kind(event.tool_name),
content=[
FileEditToolCallContent(
type="diff",
@@ -136,6 +130,7 @@ class SearchReplace(CoreSearchReplaceTool, BaseAcpTool[AcpSearchReplaceState]):
)
for block in blocks
],
- locations=[ToolCallLocation(path=result.file)],
+ locations=[ToolCallLocation(path=str(Path(result.file).resolve()))],
raw_output=result.model_dump_json(),
+ field_meta={"tool_name": event.tool_name},
)
diff --git a/vibe/acp/tools/builtins/skill.py b/vibe/acp/tools/builtins/skill.py
new file mode 100644
index 0000000..966dc4a
--- /dev/null
+++ b/vibe/acp/tools/builtins/skill.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from acp.helpers import SessionUpdate
+from acp.schema import (
+ ContentToolCallContent,
+ TextContentBlock,
+ ToolCallLocation,
+ ToolCallProgress,
+ ToolCallStart,
+)
+
+from vibe import VIBE_ROOT
+from vibe.acp.tools.base import (
+ ToolCallSessionUpdateProtocol,
+ ToolResultSessionUpdateProtocol,
+)
+from vibe.acp.tools.session_update import (
+ failed_tool_result,
+ fallback_tool_call,
+ resolve_kind,
+)
+from vibe.core.tools.builtins.skill import (
+ Skill as CoreSkillTool,
+ SkillArgs,
+ SkillResult,
+)
+from vibe.core.types import ToolCallEvent, ToolResultEvent
+
+
+class Skill(
+ CoreSkillTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol
+):
+ prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "skill.md"
+
+ @classmethod
+ def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
+ if not isinstance(event.args, SkillArgs):
+ return fallback_tool_call(event, "skill")
+
+ return ToolCallStart(
+ session_update="tool_call",
+ title=cls.get_call_display(event).summary,
+ tool_call_id=event.tool_call_id,
+ kind=resolve_kind(event.tool_name),
+ raw_input=event.args.model_dump_json(),
+ field_meta={"tool_name": event.tool_name, "skill_name": event.args.name},
+ )
+
+ @classmethod
+ def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
+ if failure := failed_tool_result(event, SkillResult):
+ return failure
+
+ result = event.result
+ assert isinstance(result, SkillResult)
+
+ locations: list[ToolCallLocation] | None = None
+ if result.skill_dir:
+ locations = [ToolCallLocation(path=str(Path(result.skill_dir).resolve()))]
+
+ return ToolCallProgress(
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
+ status="completed",
+ content=[
+ ContentToolCallContent(
+ type="content",
+ content=TextContentBlock(
+ type="text", text=cls.get_result_display(event).message
+ ),
+ )
+ ],
+ kind=resolve_kind(event.tool_name),
+ raw_output=result.model_dump_json(),
+ locations=locations,
+ field_meta={"tool_name": event.tool_name, "skill_name": result.name},
+ )
diff --git a/vibe/acp/tools/builtins/task.py b/vibe/acp/tools/builtins/task.py
new file mode 100644
index 0000000..05eafd7
--- /dev/null
+++ b/vibe/acp/tools/builtins/task.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from acp.helpers import SessionUpdate
+from acp.schema import (
+ ContentToolCallContent,
+ TextContentBlock,
+ ToolCallProgress,
+ ToolCallStart,
+)
+
+from vibe import VIBE_ROOT
+from vibe.acp.tools.base import (
+ ToolCallSessionUpdateProtocol,
+ ToolResultSessionUpdateProtocol,
+)
+from vibe.acp.tools.session_update import (
+ failed_tool_result,
+ fallback_tool_call,
+ resolve_kind,
+)
+from vibe.core.tools.builtins.task import Task as CoreTaskTool, TaskArgs, TaskResult
+from vibe.core.types import ToolCallEvent, ToolResultEvent
+
+
+class Task(
+ CoreTaskTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol
+):
+ prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "task.md"
+
+ @classmethod
+ def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
+ if not isinstance(event.args, TaskArgs):
+ return fallback_tool_call(event, "task")
+
+ return ToolCallStart(
+ session_update="tool_call",
+ title=cls.get_call_display(event).summary,
+ tool_call_id=event.tool_call_id,
+ kind=resolve_kind(event.tool_name),
+ raw_input=event.args.model_dump_json(),
+ field_meta={
+ "tool_name": event.tool_name,
+ "agent": event.args.agent,
+ "task": event.args.task,
+ },
+ )
+
+ @classmethod
+ def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
+ if failure := failed_tool_result(event, TaskResult):
+ return failure
+
+ result = event.result
+ assert isinstance(result, TaskResult)
+
+ return ToolCallProgress(
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
+ status="completed" if result.completed else "failed",
+ content=[
+ ContentToolCallContent(
+ type="content",
+ content=TextContentBlock(
+ type="text", text=cls.get_result_display(event).message
+ ),
+ )
+ ],
+ kind=resolve_kind(event.tool_name),
+ raw_output=result.model_dump_json(),
+ field_meta={
+ "tool_name": event.tool_name,
+ "turn_count": result.turns_used,
+ "response": result.response,
+ },
+ )
diff --git a/vibe/acp/tools/builtins/web_fetch.py b/vibe/acp/tools/builtins/web_fetch.py
new file mode 100644
index 0000000..25ba9e4
--- /dev/null
+++ b/vibe/acp/tools/builtins/web_fetch.py
@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+from acp.helpers import SessionUpdate
+from acp.schema import (
+ ContentToolCallContent,
+ TextContentBlock,
+ ToolCallLocation,
+ ToolCallProgress,
+ ToolCallStart,
+)
+
+from vibe import VIBE_ROOT
+from vibe.acp.tools.base import (
+ ToolCallSessionUpdateProtocol,
+ ToolResultSessionUpdateProtocol,
+)
+from vibe.acp.tools.session_update import (
+ failed_tool_result,
+ fallback_tool_call,
+ resolve_kind,
+)
+from vibe.core.tools.builtins.webfetch import (
+ WebFetch as CoreWebFetchTool,
+ WebFetchArgs,
+ WebFetchResult,
+)
+from vibe.core.types import ToolCallEvent, ToolResultEvent
+
+
+class WebFetch(
+ CoreWebFetchTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol
+):
+ prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "webfetch.md"
+
+ @classmethod
+ def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
+ if not isinstance(event.args, WebFetchArgs):
+ return fallback_tool_call(event, "web_fetch")
+
+ url = cls._normalize_url(event.args.url)
+
+ return ToolCallStart(
+ session_update="tool_call",
+ title=cls.get_call_display(event).summary,
+ tool_call_id=event.tool_call_id,
+ kind=resolve_kind(event.tool_name),
+ raw_input=event.args.model_dump_json(),
+ locations=[ToolCallLocation(path=url, field_meta={"type": "url"})],
+ field_meta={"tool_name": event.tool_name},
+ )
+
+ @classmethod
+ def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
+ if failure := failed_tool_result(event, WebFetchResult):
+ return failure
+
+ result = event.result
+ assert isinstance(result, WebFetchResult)
+
+ return ToolCallProgress(
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
+ status="completed",
+ content=[
+ ContentToolCallContent(
+ type="content",
+ content=TextContentBlock(
+ type="text", text=cls.get_result_display(event).message
+ ),
+ )
+ ],
+ kind=resolve_kind(event.tool_name),
+ raw_output=result.model_dump_json(),
+ locations=[
+ ToolCallLocation(
+ path=result.url,
+ field_meta={
+ "type": "url",
+ "char_count": len(result.content),
+ "truncated": result.was_truncated,
+ },
+ )
+ ],
+ field_meta={"tool_name": event.tool_name},
+ )
diff --git a/vibe/acp/tools/builtins/web_search.py b/vibe/acp/tools/builtins/web_search.py
new file mode 100644
index 0000000..c6a0b5f
--- /dev/null
+++ b/vibe/acp/tools/builtins/web_search.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from acp.helpers import SessionUpdate
+from acp.schema import (
+ ContentToolCallContent,
+ TextContentBlock,
+ ToolCallLocation,
+ ToolCallProgress,
+ ToolCallStart,
+)
+
+from vibe import VIBE_ROOT
+from vibe.acp.tools.base import (
+ ToolCallSessionUpdateProtocol,
+ ToolResultSessionUpdateProtocol,
+)
+from vibe.acp.tools.session_update import (
+ failed_tool_result,
+ fallback_tool_call,
+ resolve_kind,
+)
+from vibe.core.tools.builtins.websearch import (
+ WebSearch as CoreWebSearchTool,
+ WebSearchArgs,
+ WebSearchResult,
+)
+from vibe.core.types import ToolCallEvent, ToolResultEvent
+
+
+class WebSearch(
+ CoreWebSearchTool, ToolCallSessionUpdateProtocol, ToolResultSessionUpdateProtocol
+):
+ prompt_path = VIBE_ROOT / "core" / "tools" / "builtins" / "prompts" / "websearch.md"
+
+ @classmethod
+ def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
+ if not isinstance(event.args, WebSearchArgs):
+ return fallback_tool_call(event, "web_search")
+
+ return ToolCallStart(
+ session_update="tool_call",
+ title=cls.get_call_display(event).summary,
+ tool_call_id=event.tool_call_id,
+ kind=resolve_kind(event.tool_name),
+ raw_input=event.args.model_dump_json(),
+ field_meta={"tool_name": event.tool_name, "query": event.args.query},
+ )
+
+ @classmethod
+ def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
+ if failure := failed_tool_result(event, WebSearchResult):
+ return failure
+
+ result = event.result
+ assert isinstance(result, WebSearchResult)
+
+ locations = [
+ ToolCallLocation(
+ path=source.url, field_meta={"type": "url", "title": source.title}
+ )
+ for source in result.sources
+ ]
+
+ return ToolCallProgress(
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
+ status="completed",
+ content=[
+ ContentToolCallContent(
+ type="content",
+ content=TextContentBlock(
+ type="text", text=cls.get_result_display(event).message
+ ),
+ )
+ ],
+ kind=resolve_kind(event.tool_name),
+ raw_output=result.model_dump_json(),
+ locations=locations if locations else None,
+ field_meta={"tool_name": event.tool_name},
+ )
diff --git a/vibe/acp/tools/builtins/write_file.py b/vibe/acp/tools/builtins/write_file.py
index 97fcbcf..f254544 100644
--- a/vibe/acp/tools/builtins/write_file.py
+++ b/vibe/acp/tools/builtins/write_file.py
@@ -12,6 +12,11 @@ from acp.schema import (
from vibe import VIBE_ROOT
from vibe.acp.tools.base import AcpToolState, BaseAcpTool
+from vibe.acp.tools.session_update import (
+ failed_tool_result,
+ fallback_tool_call,
+ resolve_kind,
+)
from vibe.core.tools.base import BaseToolState, ToolError
from vibe.core.tools.builtins.write_file import (
WriteFile as CoreWriteFileTool,
@@ -49,50 +54,40 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
@classmethod
def tool_call_session_update(cls, event: ToolCallEvent) -> SessionUpdate | None:
- args = event.args
- if args is None:
- return ToolCallStart(
- session_update="tool_call",
- title="write_file",
- tool_call_id=event.tool_call_id,
- kind="edit",
- content=None,
- raw_input=None,
- )
- if not isinstance(args, WriteFileArgs):
- return None
+ if not isinstance(event.args, WriteFileArgs):
+ return fallback_tool_call(event, "write_file")
return ToolCallStart(
session_update="tool_call",
- title=cls.get_call_display(event).summary,
+ title=cls.format_call_display(event.args).summary,
tool_call_id=event.tool_call_id,
- kind="edit",
+ kind=resolve_kind(event.tool_name),
content=[
FileEditToolCallContent(
- type="diff", path=args.path, old_text=None, new_text=args.content
+ type="diff",
+ path=event.args.path,
+ old_text=None,
+ new_text=event.args.content,
)
],
- locations=[ToolCallLocation(path=args.path)],
- raw_input=args.model_dump_json(),
+ locations=[ToolCallLocation(path=str(Path(event.args.path).resolve()))],
+ raw_input=event.args.model_dump_json(),
+ field_meta={"tool_name": event.tool_name},
)
@classmethod
def tool_result_session_update(cls, event: ToolResultEvent) -> SessionUpdate | None:
- if event.error:
- return ToolCallProgress(
- session_update="tool_call_update",
- tool_call_id=event.tool_call_id,
- status="failed",
- )
+ if failure := failed_tool_result(event, WriteFileResult):
+ return failure
result = event.result
- if not isinstance(result, WriteFileResult):
- return None
+ assert isinstance(result, WriteFileResult)
return ToolCallProgress(
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status="completed",
+ kind=resolve_kind(event.tool_name),
content=[
FileEditToolCallContent(
type="diff",
@@ -101,6 +96,7 @@ class WriteFile(CoreWriteFileTool, BaseAcpTool[AcpWriteFileState]):
new_text=result.content,
)
],
- locations=[ToolCallLocation(path=result.path)],
+ locations=[ToolCallLocation(path=str(Path(result.path).resolve()))],
raw_output=result.model_dump_json(),
+ field_meta={"tool_name": event.tool_name},
)
diff --git a/vibe/acp/tools/session_update.py b/vibe/acp/tools/session_update.py
index 378dc09..c8f9337 100644
--- a/vibe/acp/tools/session_update.py
+++ b/vibe/acp/tools/session_update.py
@@ -8,6 +8,7 @@ from acp.schema import (
ToolCallStart,
ToolKind,
)
+from pydantic import BaseModel
from vibe.acp.tools.base import (
ToolCallSessionUpdateProtocol,
@@ -17,16 +18,75 @@ from vibe.core.tools.ui import ToolUIDataAdapter
from vibe.core.types import ToolCallEvent, ToolResultEvent
from vibe.core.utils import TaggedText, is_user_cancellation_event
-TOOL_KIND: dict[str, ToolKind] = {
- "grep": "search",
+
+def _cancellation_raw_output(event: ToolResultEvent) -> str | None:
+ if event.skip_reason:
+ return TaggedText.from_string(event.skip_reason).message
+ if event.error:
+ return TaggedText.from_string(event.error).message
+ return None
+
+
+TOOL_KIND_MAP: dict[str, ToolKind] = {
"read_file": "read",
- # Right now, jetbrains implementation of "edit" tool kind is broken
- # Leading to the tool not appearing in the chat
- # "write_file": "edit",
- # "search_replace": "edit",
+ "grep": "search",
+ "web_search": "search",
+ "web_fetch": "fetch",
+ "write_file": "edit",
+ "search_replace": "edit",
+ "bash": "execute",
+ "skill": "read",
}
+def resolve_kind(tool_name: str) -> ToolKind:
+ return TOOL_KIND_MAP.get(tool_name, "other")
+
+
+def failed_tool_result(
+ event: ToolResultEvent, expected_type: type[BaseModel]
+) -> ToolCallProgress | None:
+ """Return a failed ToolCallProgress if event is cancelled or has unexpected result type.
+
+ Returns None when the result is valid (caller handles the success path).
+ """
+ kind = resolve_kind(event.tool_name)
+
+ if is_user_cancellation_event(event):
+ return ToolCallProgress(
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
+ status="failed",
+ kind=kind,
+ raw_output=_cancellation_raw_output(event),
+ field_meta={"tool_name": event.tool_name},
+ )
+
+ if not isinstance(event.result, expected_type):
+ return ToolCallProgress(
+ session_update="tool_call_update",
+ tool_call_id=event.tool_call_id,
+ status="failed",
+ kind=kind,
+ raw_output=event.error or event.skip_reason,
+ field_meta={"tool_name": event.tool_name},
+ )
+
+ return None
+
+
+def fallback_tool_call(event: ToolCallEvent, title: str) -> ToolCallStart:
+ """Default ToolCallStart when args are None or an unexpected type."""
+ return ToolCallStart(
+ session_update="tool_call",
+ title=title,
+ tool_call_id=event.tool_call_id,
+ kind=resolve_kind(event.tool_name),
+ raw_input=None,
+ field_meta={"tool_name": event.tool_name},
+ )
+
+
def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
if issubclass(event.tool_class, ToolCallSessionUpdateProtocol):
return event.tool_class.tool_call_session_update(event)
@@ -49,8 +109,9 @@ def tool_call_session_update(event: ToolCallEvent) -> SessionUpdate | None:
title=display.summary,
content=content,
tool_call_id=event.tool_call_id,
- kind=TOOL_KIND.get(event.tool_name, "other"),
+ kind=resolve_kind(event.tool_name),
raw_input=event.args.model_dump_json() if event.args else None,
+ field_meta={"tool_name": event.tool_name},
)
@@ -116,6 +177,8 @@ def tool_result_session_update(event: ToolResultEvent) -> SessionUpdate | None:
session_update="tool_call_update",
tool_call_id=event.tool_call_id,
status=tool_status,
+ kind=resolve_kind(event.tool_name),
raw_output=raw_output,
content=content,
+ field_meta={"tool_name": event.tool_name},
)
diff --git a/vibe/acp/utils.py b/vibe/acp/utils.py
index 9482f77..29544c1 100644
--- a/vibe/acp/utils.py
+++ b/vibe/acp/utils.py
@@ -305,6 +305,7 @@ def create_tool_call_replay(
tool_call_id=tool_call_id,
kind="other",
raw_input=arguments,
+ field_meta={"tool_name": tool_name},
)
diff --git a/vibe/cli/textual_ui/app.py b/vibe/cli/textual_ui/app.py
index 4a13cc9..9d43f7b 100644
--- a/vibe/cli/textual_ui/app.py
+++ b/vibe/cli/textual_ui/app.py
@@ -57,6 +57,7 @@ from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
+from vibe.cli.textual_ui.widgets.connector_auth_app import ConnectorAuthApp
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
from vibe.cli.textual_ui.widgets.debug_console import DebugConsole
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
@@ -198,6 +199,7 @@ class BottomApp(StrEnum):
Approval = auto()
Config = auto()
+ ConnectorAuth = auto()
Input = auto()
MCP = auto()
ModelPicker = auto()
@@ -808,6 +810,27 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(MCPApp).refresh_index()
self._refresh_banner()
+ async def on_mcpapp_connector_auth_requested(
+ self, message: MCPApp.ConnectorAuthRequested
+ ) -> None:
+ await self._switch_to_input_app()
+ await self._switch_from_input(
+ ConnectorAuthApp(
+ connector_name=message.connector_name,
+ connector_registry=message.connector_registry,
+ tool_manager=message.tool_manager,
+ )
+ )
+
+ async def on_connector_auth_app_connector_auth_closed(
+ self, message: ConnectorAuthApp.ConnectorAuthClosed
+ ) -> None:
+ if message.refreshed:
+ await self.agent_loop.refresh_system_prompt()
+ self._refresh_banner()
+ await self._switch_to_input_app()
+ await self._show_mcp(cmd_args=message.connector_name)
+
async def on_proxy_setup_app_proxy_setup_closed(
self, message: ProxySetupApp.ProxySetupClosed
) -> None:
@@ -1336,9 +1359,9 @@ class VibeApp(App): # noqa: PLR0904
teleport_msg.set_status("Teleporting...")
case TeleportWaitingForGitHubEvent():
teleport_msg.set_status("Connecting to GitHub...")
- case TeleportAuthRequiredEvent(oauth_url=url):
+ case TeleportAuthRequiredEvent(oauth_url=url, message=msg):
webbrowser.open(url)
- teleport_msg.set_status("Authorizing GitHub...")
+ teleport_msg.set_status(msg or "Authorizing GitHub...")
case TeleportAuthCompleteEvent():
teleport_msg.set_status("GitHub authorized")
case TeleportFetchingUrlEvent():
@@ -2059,6 +2082,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(SessionPickerApp).focus()
case BottomApp.MCP:
self.query_one(MCPApp).focus()
+ case BottomApp.ConnectorAuth:
+ self.query_one(ConnectorAuthApp).focus()
case BottomApp.Rewind:
self.query_one(RewindApp).focus()
case BottomApp.Voice:
@@ -2335,7 +2360,7 @@ class VibeApp(App): # noqa: PLR0904
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
def _handle_bottom_app_close_escape(
- self, widget_type: type[MCPApp] | type[ProxySetupApp]
+ self, widget_type: type[MCPApp] | type[ProxySetupApp] | type[ConnectorAuthApp]
) -> None:
try:
self.query_one(widget_type).action_close()
@@ -2350,6 +2375,8 @@ class VibeApp(App): # noqa: PLR0904
self._handle_voice_app_escape()
elif self._current_bottom_app == BottomApp.MCP:
self._handle_bottom_app_close_escape(MCPApp)
+ elif self._current_bottom_app == BottomApp.ConnectorAuth:
+ self._handle_bottom_app_close_escape(ConnectorAuthApp)
elif self._current_bottom_app == BottomApp.ProxySetup:
self._handle_bottom_app_close_escape(ProxySetupApp)
elif self._current_bottom_app == BottomApp.Approval:
diff --git a/vibe/cli/textual_ui/app.tcss b/vibe/cli/textual_ui/app.tcss
index 3912416..22b9fba 100644
--- a/vibe/cli/textual_ui/app.tcss
+++ b/vibe/cli/textual_ui/app.tcss
@@ -691,7 +691,7 @@ StatusMessage {
}
-#config-app, #voice-app, #mcp-app {
+#config-app, #voice-app, #mcp-app, #connectorauth-app {
width: 100%;
height: auto;
background: transparent;
@@ -700,7 +700,7 @@ StatusMessage {
margin: 0;
}
-#config-content, #voice-content, #mcp-content {
+#config-content, #voice-content, #mcp-content, #connectorauth-content {
width: 100%;
height: auto;
}
@@ -711,16 +711,20 @@ StatusMessage {
color: ansi_blue;
}
-#config-options, #mcp-options {
+#config-options, #mcp-options, #connectorauth-options {
width: 100%;
max-height: 50vh;
border: none;
}
-#config-options:focus, #mcp-options:focus {
+#config-options:focus, #mcp-options:focus, #connectorauth-options:focus {
border: none;
}
+#connectorauth-detail {
+ margin: 1 0;
+}
+
.settings-help {
height: auto;
color: ansi_bright_black;
diff --git a/vibe/cli/textual_ui/widgets/connector_auth_app.py b/vibe/cli/textual_ui/widgets/connector_auth_app.py
new file mode 100644
index 0000000..983d4d8
--- /dev/null
+++ b/vibe/cli/textual_ui/widgets/connector_auth_app.py
@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+from enum import StrEnum, auto
+from typing import TYPE_CHECKING, ClassVar
+import webbrowser
+
+from rich.text import Text
+from textual.app import ComposeResult
+from textual.binding import Binding, BindingType
+from textual.containers import Container, Vertical
+from textual.events import DescendantBlur
+from textual.message import Message
+from textual.widgets import OptionList
+from textual.widgets.option_list import Option
+from textual.worker import Worker
+
+from vibe.cli.clipboard import copy_text_to_clipboard
+from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
+from vibe.core.tools.connectors import ConnectorRegistry
+
+if TYPE_CHECKING:
+ from vibe.core.tools.manager import ToolManager
+
+_HELP = "Backspace Back"
+_OPTION_PADDING = " "
+
+
+class _AuthOptionId(StrEnum):
+ OPEN = auto()
+ COPY = auto()
+ SHOW = auto()
+
+
+class ConnectorAuthApp(Container):
+ """Bottom-panel app for authenticating a workspace connector."""
+
+ can_focus_children = True
+ BINDINGS: ClassVar[list[BindingType]] = [
+ Binding("escape", "close", "Close", show=False),
+ Binding("backspace", "close", "Back", show=False),
+ Binding("r", "refresh", "Refresh", show=False),
+ ]
+
+ class ConnectorAuthClosed(Message):
+ def __init__(
+ self, *, refreshed: bool = False, connector_name: str = ""
+ ) -> None:
+ super().__init__()
+ self.refreshed = refreshed
+ self.connector_name = connector_name
+
+ def __init__(
+ self,
+ connector_name: str,
+ connector_registry: ConnectorRegistry,
+ tool_manager: ToolManager,
+ ) -> None:
+ super().__init__(id="connectorauth-app")
+ self._connector_name = connector_name
+ self._connector_registry = connector_registry
+ self._tool_manager = tool_manager
+ self._auth_url: str | None = None
+ self._auth_url_visible = False
+ self._status_message: str | None = None
+
+ def compose(self) -> ComposeResult:
+ with Vertical(id="connectorauth-content"):
+ yield NoMarkupStatic("", id="connectorauth-title", classes="settings-title")
+ yield NoMarkupStatic("")
+ yield OptionList(id="connectorauth-options")
+ yield NoMarkupStatic("", id="connectorauth-detail")
+ yield NoMarkupStatic("", id="connectorauth-help", classes="settings-help")
+
+ def on_mount(self) -> None:
+ self.query_one("#connectorauth-title", NoMarkupStatic).update(
+ f"Connector: {self._connector_name}"
+ )
+ option_list = self.query_one(OptionList)
+ option_list.add_option(Option("Fetching authentication info...", disabled=True))
+ self._set_help_text(_HELP)
+ option_list.focus()
+ self.run_worker(self._fetch_auth_url(), exclusive=True, group="auth_url")
+
+ def on_descendant_blur(self, _event: DescendantBlur) -> None:
+ self.query_one(OptionList).focus()
+
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
+ option_id = event.option.id or ""
+ if option_id == _AuthOptionId.OPEN:
+ self._open_browser()
+ elif option_id == _AuthOptionId.COPY:
+ self._copy_url()
+ elif option_id == _AuthOptionId.SHOW:
+ self._toggle_url()
+
+ def action_close(self) -> None:
+ self.post_message(self.ConnectorAuthClosed())
+
+ async def action_refresh(self) -> None:
+ self._status_message = "Refreshing connector..."
+ self._set_help_text(_HELP)
+ self.run_worker(
+ self._refresh_connector(), exclusive=True, group="connector_refresh"
+ )
+
+ # ── workers ──────────────────────────────────────────────────────
+
+ async def _fetch_auth_url(self) -> str | None:
+ return await self._connector_registry.get_auth_url(self._connector_name)
+
+ async def _refresh_connector(self) -> int:
+ """Refresh connector tools. Returns the number of tools discovered."""
+ from vibe.core.tools.manager import ToolManager
+
+ new_tools = await self._connector_registry.refresh_connector_async(
+ self._connector_name
+ )
+ if isinstance(self._tool_manager, ToolManager):
+ await self._tool_manager.integrate_connectors_async()
+ return len(new_tools)
+
+ def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
+ if event.worker.group == "auth_url" and event.worker.is_finished:
+ self._on_auth_url_fetched(event.worker.result)
+ elif event.worker.group == "connector_refresh" and event.worker.is_finished:
+ tool_count = (
+ event.worker.result if isinstance(event.worker.result, int) else 0
+ )
+ self._on_connector_refreshed(tool_count)
+
+ # ── auth UI ──────────────────────────────────────────────────────
+
+ def _on_auth_url_fetched(self, result: object) -> None:
+ option_list = self.query_one(OptionList)
+ option_list.clear_options()
+ auth_url = result if isinstance(result, str) else None
+ if auth_url:
+ self._auth_url = auth_url
+ option_list.add_option(
+ Option(
+ Text("This connector requires authentication", no_wrap=True),
+ disabled=True,
+ )
+ )
+ option_list.add_option(Option("", disabled=True))
+ option_list.add_option(
+ Option(
+ Text(
+ f"{_OPTION_PADDING}Press enter to open auth in your browser",
+ no_wrap=True,
+ ),
+ id=_AuthOptionId.OPEN,
+ )
+ )
+ option_list.add_option(
+ Option(
+ Text(f"{_OPTION_PADDING}Copy URL to clipboard", no_wrap=True),
+ id=_AuthOptionId.COPY,
+ )
+ )
+ option_list.add_option(
+ Option(
+ Text(f"{_OPTION_PADDING}Manually show the URL", no_wrap=True),
+ id=_AuthOptionId.SHOW,
+ )
+ )
+ option_list.highlighted = option_list.get_option_index(_AuthOptionId.OPEN)
+ self._update_detail_text()
+ else:
+ self.query_one("#connectorauth-detail", NoMarkupStatic).update("")
+ option_list.add_option(
+ Option("This connector does not provide authentication", disabled=True)
+ )
+ self._set_help_text(_HELP)
+
+ def _on_connector_refreshed(self, tool_count: int) -> None:
+ if tool_count > 0:
+ self._status_message = f"{tool_count} tools discovered."
+ self.post_message(
+ self.ConnectorAuthClosed(
+ refreshed=True, connector_name=self._connector_name
+ )
+ )
+ else:
+ self._status_message = (
+ "No tools discovered. Authentication may still be pending, "
+ "try again in a moment."
+ )
+ self._set_help_text(_HELP)
+
+ # ── actions ──────────────────────────────────────────────────────
+
+ def _open_browser(self) -> None:
+ if self._auth_url is None:
+ return
+ webbrowser.open(self._auth_url)
+ self._status_message = "Opened in browser."
+ self._set_help_text(_HELP)
+
+ def _copy_url(self) -> None:
+ if self._auth_url is None:
+ return
+ copy_text_to_clipboard(
+ self.app, self._auth_url, success_message="Auth URL copied to clipboard"
+ )
+
+ def _toggle_url(self) -> None:
+ if self._auth_url is None:
+ return
+ self._auth_url_visible = not self._auth_url_visible
+ self._update_detail_text()
+
+ # ── helpers ──────────────────────────────────────────────────────
+
+ def _update_detail_text(self) -> None:
+ detail = self.query_one("#connectorauth-detail", NoMarkupStatic)
+ parts: list[str] = []
+ if self._auth_url_visible and self._auth_url:
+ parts.append(self._auth_url)
+ parts.append("")
+ parts.append("Once authenticated, press R to refresh")
+ detail.update("\n".join(parts))
+
+ def _set_help_text(self, text: str) -> None:
+ if self._status_message:
+ text = f"{self._status_message} {text}"
+ self.query_one("#connectorauth-help", NoMarkupStatic).update(text)
diff --git a/vibe/cli/textual_ui/widgets/mcp_app.py b/vibe/cli/textual_ui/widgets/mcp_app.py
index b066c9b..159f507 100644
--- a/vibe/cli/textual_ui/widgets/mcp_app.py
+++ b/vibe/cli/textual_ui/widgets/mcp_app.py
@@ -62,9 +62,12 @@ def collect_mcp_tool_index(
return MCPToolIndex(server_tools, connector_tools, enabled_tools=available)
-_LIST_VIEW_HELP = (
+_LIST_VIEW_HELP_TOOLS = (
"↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close"
)
+_LIST_VIEW_HELP_AUTH = (
+ "↑↓ Navigate Enter Authenticate D Disable E Enable R Refresh Esc Close"
+)
_DETAIL_VIEW_HELP = (
"↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close"
)
@@ -99,6 +102,20 @@ class MCPApp(Container):
self.disabled = disabled
self.tool_name = tool_name
+ class ConnectorAuthRequested(Message):
+ """Posted when a disconnected connector needs authentication."""
+
+ def __init__(
+ self,
+ connector_name: str,
+ connector_registry: ConnectorRegistry,
+ tool_manager: ToolManager,
+ ) -> None:
+ super().__init__()
+ self.connector_name = connector_name
+ self.connector_registry = connector_registry
+ self.tool_manager = tool_manager
+
def __init__(
self,
mcp_servers: Sequence[MCPServer],
@@ -134,7 +151,6 @@ class MCPApp(Container):
yield NoMarkupStatic("", id="mcp-title", classes="settings-title")
yield NoMarkupStatic("")
yield OptionList(id="mcp-options")
- yield NoMarkupStatic("")
yield NoMarkupStatic("", id="mcp-help", classes="settings-help")
def on_mount(self) -> None:
@@ -175,6 +191,9 @@ class MCPApp(Container):
# it are disabled headers, scroll to top so the header stays visible.
if all(option_list.get_option_at_index(i).disabled for i in range(highlighted)):
option_list.scroll_to(y=0, animate=False, force=True, immediate=True)
+ # Update help text based on whether highlighted connector needs auth.
+ if self._viewing_server is None:
+ self._set_help_text(self._list_help_for_option(event.option))
def action_back(self) -> None:
if self._refreshing:
@@ -192,7 +211,7 @@ class MCPApp(Container):
return
self._status_message = "Refreshing..."
- help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP
+ help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP_TOOLS
self._set_help_text(help)
self._refreshing = True
@@ -212,6 +231,15 @@ class MCPApp(Container):
self._status_message = result if isinstance(result, str) else "Refreshed."
self.refresh_index()
+ def _list_help_for_option(self, option: Option) -> str:
+ """Return the appropriate list-view help text for the given option."""
+ option_id = option.id or ""
+ if option_id.startswith("connector:") and self._connector_registry:
+ name = option_id.removeprefix("connector:")
+ if not self._connector_registry.is_connected(name):
+ return _LIST_VIEW_HELP_AUTH
+ return _LIST_VIEW_HELP_TOOLS
+
def _set_help_text(self, text: str) -> None:
if self._status_message:
text = f"{self._status_message} {text}"
@@ -385,7 +413,7 @@ class MCPApp(Container):
has_connectors = connectors_enabled() and bool(self._connector_names)
title = "MCP Servers & Connectors" if has_connectors else "MCP Servers"
self.query_one("#mcp-title", NoMarkupStatic).update(title)
- self._set_help_text(_LIST_VIEW_HELP)
+ self._set_help_text(_LIST_VIEW_HELP_TOOLS)
has_servers = bool(self._mcp_servers)
@@ -495,9 +523,22 @@ class MCPApp(Container):
tools_source = index.connector_tools if is_connector else index.server_tools
all_tools = sorted(tools_source.get(server_name, []), key=lambda t: t[0])
if not all_tools:
- option_list.add_option(
- Option("No tools discovered for this server", disabled=True)
- )
+ if (
+ is_connector
+ and self._connector_registry
+ and not self._connector_registry.is_connected(server_name)
+ ):
+ self.post_message(
+ self.ConnectorAuthRequested(
+ connector_name=server_name,
+ connector_registry=self._connector_registry,
+ tool_manager=self._tool_manager,
+ )
+ )
+ else:
+ option_list.add_option(
+ Option("No tools discovered for this server", disabled=True)
+ )
return
for tool_name, cls in all_tools:
is_tool_enabled = tool_name in index.enabled_tools
diff --git a/vibe/core/agent_loop.py b/vibe/core/agent_loop.py
index 3d4d29a..87960d9 100644
--- a/vibe/core/agent_loop.py
+++ b/vibe/core/agent_loop.py
@@ -727,10 +727,9 @@ class AgentLoop:
self, provider: ProviderConfig | None = None
) -> dict[str, str]:
provider = self.config.get_active_provider() if provider is None else provider
- headers: dict[str, str] = {
- "user-agent": get_user_agent(provider.backend),
- "x-affinity": self.session_id,
- }
+ headers: dict[str, str] = {**provider.extra_headers}
+ headers["user-agent"] = get_user_agent(provider.backend)
+ headers["x-affinity"] = self.session_id
return headers
async def _conversation_loop(
@@ -1423,6 +1422,7 @@ class AgentLoop:
self.session_logger.reset_session(
self.session_id, parent_session_id=old_session_id
)
+ self.emit_new_session_telemetry()
async def fork(self, message_id: str | None = None) -> AgentLoop:
messages = self._messages_for_fork(message_id)
diff --git a/vibe/core/config/__init__.py b/vibe/core/config/__init__.py
index aae05b3..7877bdf 100644
--- a/vibe/core/config/__init__.py
+++ b/vibe/core/config/__init__.py
@@ -41,6 +41,13 @@ from vibe.core.config.layer import (
TrustResolutionError,
UntrustedLayerError,
)
+from vibe.core.config.patch import (
+ AppendToList,
+ DeleteField,
+ PatchOp,
+ RemoveFromList,
+ SetField,
+)
from vibe.core.config.schema import (
DuplicateMergeMetadataError,
MergeFieldMetadata,
@@ -60,9 +67,11 @@ __all__ = [
"DEFAULT_TTS_MODELS",
"DEFAULT_TTS_PROVIDERS",
"THINKING_LEVELS",
+ "AppendToList",
"ConfigLayer",
"ConfigLayerError",
"ConnectorConfig",
+ "DeleteField",
"DuplicateMergeMetadataError",
"EmptyLayerError",
"LayerImplementationError",
@@ -75,10 +84,13 @@ __all__ = [
"MissingPromptFileError",
"ModelConfig",
"OtelSpanExporterConfig",
+ "PatchOp",
"ProjectContextConfig",
"ProviderConfig",
"RawConfig",
+ "RemoveFromList",
"SessionLoggingConfig",
+ "SetField",
"TTSClient",
"TTSModelConfig",
"TTSProviderConfig",
diff --git a/vibe/core/config/_settings.py b/vibe/core/config/_settings.py
index 50b574b..f7482b5 100644
--- a/vibe/core/config/_settings.py
+++ b/vibe/core/config/_settings.py
@@ -173,6 +173,7 @@ class ProviderConfig(BaseModel):
reasoning_field_name: str = "reasoning_content"
project_id: str = ""
region: str = ""
+ extra_headers: dict[str, str] = Field(default_factory=dict)
def _is_legacy_mistral_provider_without_backend(self) -> bool:
return (
@@ -434,9 +435,11 @@ DEFAULT_MODELS = [
ModelConfig(
name="mistral-vibe-cli-latest",
provider="mistral",
- alias="devstral-2",
- input_price=0.4,
- output_price=2.0,
+ alias="mistral-medium-3.5",
+ temperature=1.0,
+ input_price=1.5,
+ output_price=7.5,
+ thinking="high",
),
ModelConfig(
name="devstral-small-latest",
@@ -973,14 +976,33 @@ class VibeConfig(BaseSettings):
except (FileNotFoundError, tomllib.TOMLDecodeError, OSError):
return
+ changed = False
+
bash_tools = data.get("tools", {}).get("bash", {})
allowlist = bash_tools.get("allowlist")
- if allowlist is None or "find" in allowlist:
- return
+ if allowlist is not None and "find" not in allowlist:
+ allowlist.append("find")
+ allowlist.sort()
+ changed = True
- allowlist.append("find")
- allowlist.sort()
- cls.dump_config(data)
+ for model in data.get("models", []):
+ if (
+ model.get("name") == "mistral-vibe-cli-latest"
+ and model.get("alias") == "devstral-2"
+ ):
+ model["alias"] = "mistral-medium-3.5"
+ model["temperature"] = 1.0
+ model["input_price"] = 1.5
+ model["output_price"] = 7.5
+ model["thinking"] = "high"
+ changed = True
+
+ if data.get("active_model") == "devstral-2":
+ data["active_model"] = "mistral-medium-3.5"
+ changed = True
+
+ if changed:
+ cls.dump_config(data)
@classmethod
def load(cls, **overrides: Any) -> VibeConfig:
diff --git a/vibe/core/config/patch.py b/vibe/core/config/patch.py
new file mode 100644
index 0000000..caea8db
--- /dev/null
+++ b/vibe/core/config/patch.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass(frozen=True, slots=True)
+class SetField:
+ """Set a top-level or nested field to a value."""
+
+ key: str
+ value: Any
+
+ def __post_init__(self) -> None:
+ _validate_key_path(self.key)
+
+
+@dataclass(frozen=True, slots=True)
+class AppendToList:
+ """Append items to a list field."""
+
+ key: str
+ items: tuple[Any, ...]
+
+ def __post_init__(self) -> None:
+ _validate_key_path(self.key)
+ _validate_tuple_value("AppendToList.items", self.items)
+
+
+@dataclass(frozen=True, slots=True)
+class RemoveFromList:
+ """Remove items from a list field by value."""
+
+ key: str
+ values: tuple[Any, ...]
+
+ def __post_init__(self) -> None:
+ _validate_key_path(self.key)
+ _validate_tuple_value("RemoveFromList.values", self.values)
+
+
+@dataclass(frozen=True, slots=True)
+class DeleteField:
+ """Remove a field entirely from the config."""
+
+ key: str
+
+ def __post_init__(self) -> None:
+ _validate_key_path(self.key)
+
+
+PatchOp = SetField | AppendToList | RemoveFromList | DeleteField
+
+
+def _validate_key_path(key: object) -> None:
+ if not isinstance(key, str):
+ raise TypeError(
+ f"Patch operation key must be a string, got {type(key).__name__}"
+ )
+ if not key:
+ raise ValueError("Patch operation key must not be empty")
+ if any(not segment for segment in key.split(".")):
+ raise ValueError(
+ "Patch operation key must be a dot-separated path without empty segments"
+ )
+
+
+def _validate_tuple_value(field_name: str, value: object) -> None:
+ if not isinstance(value, tuple):
+ raise TypeError(f"{field_name} must be a tuple, got {type(value).__name__}")
diff --git a/vibe/core/output_formatters.py b/vibe/core/output_formatters.py
index 031f8fe..c91a407 100644
--- a/vibe/core/output_formatters.py
+++ b/vibe/core/output_formatters.py
@@ -64,8 +64,8 @@ class TextOutputFormatter(OutputFormatter):
self._print("Teleporting...")
case TeleportWaitingForGitHubEvent():
self._print("Connecting to GitHub...")
- case TeleportAuthRequiredEvent(oauth_url=url):
- self._print(f"Open to authorize GitHub: {url}")
+ case TeleportAuthRequiredEvent(oauth_url=url, message=msg):
+ self._print(msg or f"Open to authorize GitHub: {url}")
case TeleportAuthCompleteEvent():
self._print("GitHub authorized")
case TeleportFetchingUrlEvent():
diff --git a/vibe/core/skills/builtins/vibe.py b/vibe/core/skills/builtins/vibe.py
index bbbe283..c9c99a0 100644
--- a/vibe/core/skills/builtins/vibe.py
+++ b/vibe/core/skills/builtins/vibe.py
@@ -57,7 +57,7 @@ environment variables with the `VIBE_` prefix (e.g., `VIBE_ACTIVE_MODEL=local`).
```toml
# Model selection
-active_model = "devstral-2" # Model alias to use (see [[models]])
+active_model = "mistral-medium-3.5" # Model alias to use (see [[models]])
# UI preferences
vim_keybindings = false
@@ -103,6 +103,7 @@ backend = "mistral"
name = "llamacpp"
api_base = "http://127.0.0.1:8080/v1"
api_key_env_var = ""
+extra_headers = { "X-Custom-Header" = "value" } # optional per-provider HTTP headers
```
### Models
@@ -111,11 +112,11 @@ api_key_env_var = ""
[[models]]
name = "mistral-vibe-cli-latest"
provider = "mistral"
-alias = "devstral-2"
-temperature = 0.2
-input_price = 0.4
-output_price = 2.0
-thinking = "off" # "off", "low", "medium", "high", "max"
+alias = "mistral-medium-3.5"
+temperature = 1.0
+input_price = 1.5
+output_price = 7.5
+thinking = "high" # "off", "low", "medium", "high", "max"
auto_compact_threshold = 200000
[[models]]
diff --git a/vibe/core/teleport/teleport.py b/vibe/core/teleport/teleport.py
index dd3e652..5dffaa1 100644
--- a/vibe/core/teleport/teleport.py
+++ b/vibe/core/teleport/teleport.py
@@ -188,7 +188,9 @@ class TeleportService:
if not github_data.connected:
if github_data.oauth_url:
- yield TeleportAuthRequiredEvent(oauth_url=github_data.oauth_url)
+ yield TeleportAuthRequiredEvent(
+ oauth_url=github_data.oauth_url, message=github_data.error
+ )
await self._nuage_client.wait_for_github_connection(execution_id)
yield TeleportAuthCompleteEvent()
diff --git a/vibe/core/teleport/types.py b/vibe/core/teleport/types.py
index 3eb0f33..28e9b13 100644
--- a/vibe/core/teleport/types.py
+++ b/vibe/core/teleport/types.py
@@ -5,6 +5,7 @@ from vibe.core.types import BaseEvent
class TeleportAuthRequiredEvent(BaseEvent):
oauth_url: str
+ message: str | None = None
class TeleportAuthCompleteEvent(BaseEvent):
diff --git a/vibe/core/tools/builtins/grep.py b/vibe/core/tools/builtins/grep.py
index bf927e6..a6c0316 100644
--- a/vibe/core/tools/builtins/grep.py
+++ b/vibe/core/tools/builtins/grep.py
@@ -94,6 +94,43 @@ class GrepArgs(BaseModel):
)
+class GrepMatch(BaseModel):
+ path: str
+ line: int | None = None
+
+ @classmethod
+ def from_output_line(cls, raw: str) -> GrepMatch | None:
+ """Parse a single grep/rg output line in `file:line:content` format.
+
+ Handles Windows drive-letter paths like ``C:\\repo\\file.py:10:match``
+ by skipping a single-letter first segment.
+ """
+ parts = raw.split(":", 3)
+ MIN_MATCH_PARTS = 2
+ if len(parts) < MIN_MATCH_PARTS:
+ return None
+
+ # Windows drive letter: first part is a single letter (e.g. "C")
+ MIN_WINDOWS_PARTS = 3
+ is_windows_path = (
+ len(parts[0]) == 1
+ and parts[0].isalpha()
+ and len(parts) >= MIN_WINDOWS_PARTS
+ )
+ if is_windows_path:
+ file_path = f"{parts[0]}:{parts[1]}"
+ line_str = parts[2]
+ else:
+ file_path = parts[0]
+ line_str = parts[1]
+
+ try:
+ line_num = int(line_str) if line_str else None
+ except (ValueError, TypeError):
+ line_num = None
+ return cls(path=str(Path(file_path).resolve()), line=line_num)
+
+
class GrepResult(BaseModel):
matches: str
match_count: int
@@ -101,6 +138,14 @@ class GrepResult(BaseModel):
description="True if output was cut short by max_matches or max_output_bytes."
)
+ @property
+ def parsed_matches(self) -> list[GrepMatch]:
+ results: list[GrepMatch] = []
+ for line in self.matches.splitlines():
+ if match := GrepMatch.from_output_line(line):
+ results.append(match)
+ return results
+
class Grep(
BaseTool[GrepArgs, GrepResult, GrepToolConfig, BaseToolState],
diff --git a/vibe/core/tools/builtins/read_file.py b/vibe/core/tools/builtins/read_file.py
index 414b377..0d16a53 100644
--- a/vibe/core/tools/builtins/read_file.py
+++ b/vibe/core/tools/builtins/read_file.py
@@ -48,6 +48,7 @@ class ReadFileArgs(BaseModel):
class ReadFileResult(BaseModel):
path: str
content: str
+ offset: int = 0
lines_read: int
was_truncated: bool = Field(
description="True if the reading was stopped due to the max_read_bytes limit."
@@ -90,6 +91,7 @@ class ReadFile(
yield ReadFileResult(
path=str(file_path),
content="".join(read_result.lines),
+ offset=args.offset,
lines_read=len(read_result.lines),
was_truncated=read_result.was_truncated,
)
diff --git a/vibe/core/tools/connectors/connector_registry.py b/vibe/core/tools/connectors/connector_registry.py
index ca5582b..1ca6bb9 100644
--- a/vibe/core/tools/connectors/connector_registry.py
+++ b/vibe/core/tools/connectors/connector_registry.py
@@ -220,6 +220,7 @@ class ConnectorRegistry:
self._cache: dict[str, dict[str, type[BaseTool]]] | None = None
self._connector_names: list[str] = []
self._connector_connected: dict[str, bool] = {}
+ self._alias_to_id: dict[str, str] = {}
self._discover_lock = asyncio.Lock()
def _get_client(self) -> Mistral:
@@ -269,6 +270,7 @@ class ConnectorRegistry:
self._cache = {}
self._connector_names = []
self._connector_connected = {}
+ self._alias_to_id = {}
return {}
# Build results locally to avoid publishing incomplete state.
@@ -324,6 +326,7 @@ class ConnectorRegistry:
# lock will see the completed cache.
self._connector_names = connector_names
self._connector_connected = connector_connected
+ self._alias_to_id = {alias: cid for cid, alias, _ in unique_connectors}
self._cache = cache
return all_tools
@@ -411,7 +414,63 @@ class ConnectorRegistry:
def is_connected(self, name: str) -> bool:
return self._connector_connected.get(name, False)
+ def get_connector_id(self, alias: str) -> str | None:
+ """Return the API connector ID for a given alias, or None."""
+ return self._alias_to_id.get(alias)
+
+ async def refresh_connector_async(self, alias: str) -> dict[str, type[BaseTool]]:
+ """Re-fetch tools for a single connector by alias.
+
+ Updates the internal cache for that connector only. Returns
+ the new tool map (empty dict on failure).
+ """
+ connector_id = self._alias_to_id.get(alias)
+ if connector_id is None:
+ return {}
+
+ try:
+ async with self._get_client() as client:
+ connector = await client.beta.connectors.get_async(
+ connector_id_or_name=connector_id
+ )
+ tools_map = await self._fetch_connector_tools(client, connector, alias)
+ except Exception:
+ logger.debug("Failed to refresh connector %s", alias)
+ tools_map = None
+
+ if self._cache is None:
+ self._cache = {}
+
+ if tools_map is None:
+ self._cache.pop(connector_id, None)
+ self._connector_connected[alias] = False
+ return {}
+
+ self._cache[connector_id] = tools_map
+ self._connector_connected[alias] = bool(tools_map)
+ return tools_map
+
+ async def get_auth_url(self, alias: str) -> str | None:
+ """Return the OAuth authorization URL for a connector, or None.
+
+ Returns None when the connector does not support OAuth or the
+ alias is unknown.
+ """
+ connector_id = self._alias_to_id.get(alias)
+ if connector_id is None:
+ return None
+ try:
+ async with self._get_client() as client:
+ result = await client.beta.connectors.get_auth_url_async(
+ connector_id_or_name=connector_id
+ )
+ return result.auth_url
+ except Exception:
+ logger.debug("Failed to get auth URL for connector %s", alias)
+ return None
+
def clear(self) -> None:
self._cache = None
self._connector_names = []
self._connector_connected = {}
+ self._alias_to_id = {}
diff --git a/vibe/whats_new.md b/vibe/whats_new.md
index b5906cf..4d325bf 100644
--- a/vibe/whats_new.md
+++ b/vibe/whats_new.md
@@ -1,3 +1,8 @@
+# What's new in v2.9.1
+
+- **Default model**: Migrated to `mistral-medium-3.5`.
+- **Connector OAuth**: Authenticate connectors via OAuth directly from the `/mcp` menu.
+
# What's new in v2.9.0
- **Scratchpad directory**: Model's autonomous workspace for temporary files, intermediate results, and drafts shared with subagents
- **`/copy` command**: New slash command to copy content directly from the conversation