Co-authored-by: Brice Carpentier <brice.carpentier@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-04-29 17:20:27 +02:00 committed by GitHub
parent 632ea8c032
commit 1fd7eea289
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 3482 additions and 301 deletions

View file

@ -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"

View file

@ -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

View file

@ -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},
)

View file

@ -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},
)

View file

@ -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()

View file

@ -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},
)

View file

@ -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},
)

View file

@ -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,
},
)

View file

@ -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},
)

View file

@ -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},
)

View file

@ -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},
)

View file

@ -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},
)

View file

@ -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},
)

View file

@ -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:

View file

@ -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;

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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",

View file

@ -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:

70
vibe/core/config/patch.py Normal file
View file

@ -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__}")

View file

@ -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():

View file

@ -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]]

View file

@ -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()

View file

@ -5,6 +5,7 @@ from vibe.core.types import BaseEvent
class TeleportAuthRequiredEvent(BaseEvent):
oauth_url: str
message: str | None = None
class TeleportAuthCompleteEvent(BaseEvent):

View file

@ -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],

View file

@ -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,
)

View file

@ -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 = {}

View file

@ -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