Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Clément Sirieix 2026-04-21 15:19:59 +02:00 committed by GitHub
parent 95336528f6
commit 04305bd77c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 3473 additions and 1764 deletions

View file

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

View file

@ -295,6 +295,7 @@ class VibeAcpAgentLoop(AcpAgent):
agent_name=BuiltinAgentName.DEFAULT,
enable_streaming=True,
entrypoint_metadata=self._build_entrypoint_metadata(),
defer_heavy_init=True,
)
agent_loop.agent_manager.register_agent(CHAT_AGENT)
# NOTE: For now, we pin session.id to agent_loop.session_id right after init time.
@ -541,6 +542,7 @@ class VibeAcpAgentLoop(AcpAgent):
agent_name=BuiltinAgentName.DEFAULT,
enable_streaming=True,
entrypoint_metadata=self._build_entrypoint_metadata(),
defer_heavy_init=True,
)
agent_loop.agent_manager.register_agent(CHAT_AGENT)

View file

@ -213,8 +213,6 @@ def run_cli(args: argparse.Namespace) -> None:
if loaded_session:
_resume_previous_session(agent_loop, *loaded_session)
agent_loop.start_deferred_init()
run_textual_ui(
agent_loop=agent_loop,
startup=StartupOptions(

View file

@ -65,11 +65,6 @@ class CommandRegistry:
handler="_exit_app",
exits=True,
),
"terminal-setup": Command(
aliases=frozenset(["/terminal-setup"]),
description="Configure Shift+Enter for newlines",
handler="_setup_terminal",
),
"status": Command(
aliases=frozenset(["/status"]),
description="Display agent statistics",
@ -91,15 +86,13 @@ class CommandRegistry:
handler="_show_session_picker",
),
"mcp": Command(
aliases=frozenset(["/mcp"]),
description="Display available MCP servers. Pass the name of a server to list its tools",
aliases=frozenset(["/mcp", "/connectors"]),
description=(
"Display available MCP servers and connectors. "
"Pass a name to list its tools"
),
handler="_show_mcp",
),
"connectors": Command(
aliases=frozenset(["/connectors"]),
description="Manage workspace connectors. Subcommands: refresh",
handler="_handle_connectors",
),
"voice": Command(
aliases=frozenset(["/voice"]),
description="Configure voice settings",

View file

@ -0,0 +1,83 @@
from __future__ import annotations
from enum import Enum
import os
from typing import Literal
class Terminal(Enum):
VSCODE = "vscode"
VSCODE_INSIDERS = "vscode_insiders"
CURSOR = "cursor"
JETBRAINS = "jetbrains"
ITERM2 = "iterm2"
WEZTERM = "wezterm"
GHOSTTY = "ghostty"
ALACRITTY = "alacritty"
KITTY = "kitty"
HYPER = "hyper"
WINDOWS_TERMINAL = "windows_terminal"
UNKNOWN = "unknown"
def _is_cursor() -> bool:
path_indicators = [
"VSCODE_GIT_ASKPASS_NODE",
"VSCODE_GIT_ASKPASS_MAIN",
"VSCODE_IPC_HOOK_CLI",
"VSCODE_NLS_CONFIG",
]
for var in path_indicators:
val = os.environ.get(var, "").lower()
if "cursor" in val:
return True
return False
def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDERS]:
term_version = os.environ.get("TERM_PROGRAM_VERSION", "").lower()
if term_version.endswith("-insider"):
return Terminal.VSCODE_INSIDERS
return Terminal.VSCODE
def _detect_terminal_from_env() -> Terminal | None:
env_markers: dict[str, Terminal] = {
"WEZTERM_PANE": Terminal.WEZTERM,
"GHOSTTY_RESOURCES_DIR": Terminal.GHOSTTY,
"KITTY_WINDOW_ID": Terminal.KITTY,
"ALACRITTY_SOCKET": Terminal.ALACRITTY,
"ALACRITTY_LOG": Terminal.ALACRITTY,
"WT_SESSION": Terminal.WINDOWS_TERMINAL,
}
for var, terminal in env_markers.items():
if os.environ.get(var):
return terminal
if "jetbrains" in os.environ.get("TERMINAL_EMULATOR", "").lower():
return Terminal.JETBRAINS
return None
def detect_terminal() -> Terminal:
term_program = os.environ.get("TERM_PROGRAM", "").lower()
if term_program == "vscode":
if _is_cursor():
return Terminal.CURSOR
return _detect_vscode_terminal()
term_map: dict[str, Terminal] = {
"iterm.app": Terminal.ITERM2,
"wezterm": Terminal.WEZTERM,
"ghostty": Terminal.GHOSTTY,
"alacritty": Terminal.ALACRITTY,
"kitty": Terminal.KITTY,
"hyper": Terminal.HYPER,
}
if term_program in term_map:
return term_map[term_program]
return _detect_terminal_from_env() or Terminal.UNKNOWN

View file

@ -1,338 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
import json
import os
from pathlib import Path
import platform
import subprocess
from typing import Any, Literal
from vibe.core.utils.io import read_safe
class Terminal(Enum):
VSCODE = "vscode"
VSCODE_INSIDERS = "vscode_insiders"
CURSOR = "cursor"
JETBRAINS = "jetbrains"
ITERM2 = "iterm2"
WEZTERM = "wezterm"
GHOSTTY = "ghostty"
UNKNOWN = "unknown"
@dataclass
class SetupResult:
success: bool
terminal: Terminal
message: str
requires_restart: bool = False
def _is_cursor() -> bool:
path_indicators = [
"VSCODE_GIT_ASKPASS_NODE",
"VSCODE_GIT_ASKPASS_MAIN",
"VSCODE_IPC_HOOK_CLI",
"VSCODE_NLS_CONFIG",
]
for var in path_indicators:
val = os.environ.get(var, "").lower()
if "cursor" in val:
return True
return False
def _detect_vscode_terminal() -> Literal[Terminal.VSCODE, Terminal.VSCODE_INSIDERS]:
term_version = os.environ.get("TERM_PROGRAM_VERSION", "").lower()
if term_version.endswith("-insider"):
return Terminal.VSCODE_INSIDERS
return Terminal.VSCODE
def _detect_terminal_from_env() -> Terminal | None:
if os.environ.get("WEZTERM_PANE"):
return Terminal.WEZTERM
if os.environ.get("GHOSTTY_RESOURCES_DIR"):
return Terminal.GHOSTTY
if "jetbrains" in os.environ.get("TERMINAL_EMULATOR", "").lower():
return Terminal.JETBRAINS
return None
def detect_terminal() -> Terminal:
term_program = os.environ.get("TERM_PROGRAM", "").lower()
if term_program == "vscode":
if _is_cursor():
return Terminal.CURSOR
return _detect_vscode_terminal()
term_map = {
"iterm.app": Terminal.ITERM2,
"wezterm": Terminal.WEZTERM,
"ghostty": Terminal.GHOSTTY,
}
if term_program in term_map:
return term_map[term_program]
return _detect_terminal_from_env() or Terminal.UNKNOWN
def _get_vscode_keybindings_path(is_stable: bool) -> Path | None:
system = platform.system()
app_name = "Code" if is_stable else "Code - Insiders"
if system == "Darwin":
base = Path.home() / "Library" / "Application Support" / app_name / "User"
elif system == "Linux":
base = Path.home() / ".config" / app_name / "User"
elif system == "Windows":
appdata = os.environ.get("APPDATA", "")
if appdata:
base = Path(appdata) / app_name / "User"
else:
return None
else:
return None
return base / "keybindings.json"
def _get_cursor_keybindings_path() -> Path | None:
system = platform.system()
if system == "Darwin":
base = Path.home() / "Library" / "Application Support" / "Cursor" / "User"
elif system == "Linux":
base = Path.home() / ".config" / "Cursor" / "User"
elif system == "Windows":
appdata = os.environ.get("APPDATA", "")
if appdata:
base = Path(appdata) / "Cursor" / "User"
else:
return None
else:
return None
return base / "keybindings.json"
def _parse_keybindings(content: str) -> list[dict[str, Any]]:
content = content.strip()
if not content or content.startswith("//"):
return []
lines = [line for line in content.split("\n") if not line.strip().startswith("//")]
clean_content = "\n".join(lines)
try:
return json.loads(clean_content)
except json.JSONDecodeError:
return []
def _setup_vscode_like_terminal(terminal: Terminal) -> SetupResult:
"""Setup keybindings for VS Code or Cursor."""
if terminal == Terminal.CURSOR:
keybindings_path = _get_cursor_keybindings_path()
editor_name = "Cursor"
else:
keybindings_path = _get_vscode_keybindings_path(terminal == Terminal.VSCODE)
editor_name = "VS Code" if terminal == Terminal.VSCODE else "VS Code Insiders"
if keybindings_path is None:
return SetupResult(
success=False,
terminal=terminal,
message=f"Could not determine keybindings path for {editor_name}",
)
new_binding = {
"key": "shift+enter",
"command": "workbench.action.terminal.sendSequence",
"args": {"text": "\u001b[13;2u"},
"when": "terminalFocus",
}
try:
keybindings = _read_existing_keybindings(keybindings_path)
if _has_shift_enter_binding(keybindings):
return SetupResult(
success=True,
terminal=terminal,
message=f"Shift+Enter already configured in {editor_name}",
)
keybindings.append(new_binding)
keybindings_path.write_text(
json.dumps(keybindings, indent=2, ensure_ascii=False) + "\n"
)
return SetupResult(
success=True,
terminal=terminal,
message=f"Added Shift+Enter binding to {keybindings_path}",
requires_restart=True,
)
except Exception as e:
return SetupResult(
success=False,
terminal=terminal,
message=f"Failed to configure {editor_name}: {e}",
)
def _read_existing_keybindings(keybindings_path: Path) -> list[dict[str, Any]]:
if keybindings_path.exists():
content = read_safe(keybindings_path).text
return _parse_keybindings(content)
keybindings_path.parent.mkdir(parents=True, exist_ok=True)
return []
def _has_shift_enter_binding(keybindings: list[dict[str, Any]]) -> bool:
for binding in keybindings:
if (
binding.get("key") == "shift+enter"
and binding.get("command") == "workbench.action.terminal.sendSequence"
and binding.get("when") == "terminalFocus"
):
return True
return False
def _setup_iterm2() -> SetupResult:
if platform.system() != "Darwin":
return SetupResult(
success=False,
terminal=Terminal.ITERM2,
message="iTerm2 is only available on macOS",
)
plist_key = "0xd-0x20000-0x24"
plist_value = """<dict>
<key>Text</key>
<string>\\n</string>
<key>Action</key>
<integer>12</integer>
<key>Version</key>
<integer>1</integer>
<key>Keycode</key>
<integer>13</integer>
<key>Modifiers</key>
<integer>131072</integer>
</dict>"""
try:
result = subprocess.run(
["defaults", "read", "com.googlecode.iterm2", "GlobalKeyMap"],
capture_output=True,
text=True,
)
if plist_key in result.stdout:
return SetupResult(
success=True,
terminal=Terminal.ITERM2,
message="Shift+Enter already configured in iTerm2",
)
subprocess.run(
[
"defaults",
"write",
"com.googlecode.iterm2",
"GlobalKeyMap",
"-dict-add",
plist_key,
plist_value,
],
check=True,
capture_output=True,
)
return SetupResult(
success=True,
terminal=Terminal.ITERM2,
message="Added Shift+Enter binding to iTerm2 preferences",
requires_restart=True,
)
except subprocess.CalledProcessError as e:
return SetupResult(
success=False,
terminal=Terminal.ITERM2,
message=f"Failed to configure iTerm2: {e.stderr}",
)
except Exception as e:
return SetupResult(
success=False,
terminal=Terminal.ITERM2,
message=f"Failed to configure iTerm2: {e}",
)
def _setup_wezterm() -> SetupResult:
return SetupResult(
success=True,
terminal=Terminal.WEZTERM,
message="Please manually add the following to your .wezterm.lua:\n"
"local wezterm = require 'wezterm'\n"
"local config = wezterm.config_builder()\n\n"
"config.keys = {\n"
" {\n"
' key = "Enter",\n'
' mods = "SHIFT",\n'
' action = wezterm.action.SendString("\\x1b[13;2u"),\n'
" }\n"
"}\n\n"
"return config",
)
def _setup_ghostty() -> SetupResult:
return SetupResult(
success=True,
terminal=Terminal.GHOSTTY,
message="Shift+Enter is already configured in Ghostty",
)
def setup_terminal() -> SetupResult:
terminal = detect_terminal()
match terminal:
case Terminal.VSCODE | Terminal.VSCODE_INSIDERS | Terminal.CURSOR:
return _setup_vscode_like_terminal(terminal)
case Terminal.JETBRAINS:
return SetupResult(
success=False,
terminal=Terminal.JETBRAINS,
message="Jetbrains terminal is not supported.\n"
"You can manually configure Shift+Enter to send: \\x1b[13;2u",
)
case Terminal.ITERM2:
return _setup_iterm2()
case Terminal.WEZTERM:
return _setup_wezterm()
case Terminal.GHOSTTY:
return _setup_ghostty()
case Terminal.UNKNOWN:
return SetupResult(
success=False,
terminal=Terminal.UNKNOWN,
message="Could not detect terminal. Supported terminals:\n"
"- VS Code\n"
"- Cursor\n"
"- iTerm2\n"
"- WezTerm\n"
"- Ghostty\n\n"
"You can manually configure Shift+Enter to send: \\x1b[13;2u",
)

View file

@ -41,7 +41,6 @@ from vibe.cli.plan_offer.decide_plan_offer import (
resolve_api_key_for_plan,
)
from vibe.cli.plan_offer.ports.whoami_gateway import WhoAmIGateway, WhoAmIPlanType
from vibe.cli.terminal_setup import setup_terminal
from vibe.cli.textual_ui.handlers.event_handler import EventHandler
from vibe.cli.textual_ui.notifications import (
NotificationContext,
@ -145,8 +144,7 @@ from vibe.core.tools.builtins.ask_user_question import (
Choice,
Question,
)
from vibe.core.tools.connectors import CONNECTORS_ENV_VAR, connectors_enabled
from vibe.core.tools.mcp.tools import MCPTool
from vibe.core.tools.connectors import connectors_enabled
from vibe.core.tools.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client
from vibe.core.types import (
@ -334,8 +332,6 @@ class VibeApp(App): # noqa: PLR0904
excluded_commands = []
if not self.config.nuage_enabled:
excluded_commands.append("teleport")
if not connectors_enabled():
excluded_commands.append("connectors")
self.commands = CommandRegistry(excluded_commands=excluded_commands)
self._chat_input_container: ChatInputContainer | None = None
@ -478,7 +474,7 @@ class VibeApp(App): # noqa: PLR0904
if not self.agent_loop.is_initialized:
await self._ensure_loading_widget("Initializing")
init_widget = self._loading_widget
await self.agent_loop.wait_for_init()
await self.agent_loop.wait_until_ready()
except Exception as e:
await self._mount_and_scroll(
ErrorMessage(
@ -788,15 +784,7 @@ class VibeApp(App): # noqa: PLR0904
if not self.agent_loop:
return False
try:
skill = self.agent_loop.skill_manager.parse_skill_command(user_input)
except OSError as e:
await self._mount_and_scroll(
ErrorMessage(
f"Failed to read skill file: {e}", collapsed=self._tools_collapsed
)
)
return True
skill = self.agent_loop.skill_manager.parse_skill_command(user_input)
if skill is None:
return False
@ -1104,7 +1092,7 @@ class VibeApp(App): # noqa: PLR0904
show_init_spinner = not self.agent_loop.is_initialized
if show_init_spinner:
await self._ensure_loading_widget("Initializing")
await self.agent_loop.wait_for_init()
await self.agent_loop.wait_until_ready()
if show_init_spinner:
await self._remove_loading_widget()
self._refresh_banner()
@ -1328,6 +1316,12 @@ class VibeApp(App): # noqa: PLR0904
help_text = self.commands.get_help_text()
await self._mount_and_scroll(UserCommandMessage(help_text))
async def _refresh_mcp_browser(self) -> str:
await self.agent_loop.tool_manager.refresh_remote_tools_async()
await self.agent_loop.refresh_system_prompt()
self._refresh_banner()
return "Refreshed."
async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None:
mcp_servers = self.config.mcp_servers
connector_registry = (
@ -1371,51 +1365,10 @@ class VibeApp(App): # noqa: PLR0904
tool_manager=self.agent_loop.tool_manager,
initial_server=name,
connector_registry=connector_registry,
refresh_callback=self._refresh_mcp_browser,
)
)
async def _handle_connectors(self, cmd_args: str = "", **kwargs: Any) -> None:
if not self._connectors_enabled:
await self._mount_and_scroll(
UserCommandMessage(
f"Connectors are disabled. Set {CONNECTORS_ENV_VAR}=1 to enable."
)
)
return
registry = self.agent_loop.connector_registry
assert registry is not None # guaranteed by _connectors_enabled
subcmd = cmd_args.strip().lower()
match subcmd:
case "refresh":
registry.clear()
tool_manager = self.agent_loop.tool_manager
await tool_manager.integrate_connectors_async()
self.agent_loop.refresh_system_prompt()
self._refresh_banner()
count = registry.connector_count
tools = sum(
1
for cls in tool_manager.registered_tools.values()
if issubclass(cls, MCPTool) and cls.is_connector()
)
await self._mount_and_scroll(
UserCommandMessage(
f"Connectors refreshed: {count} connectors, {tools} tools"
)
)
case "":
await self._mount_and_scroll(
UserCommandMessage("Usage: /connectors refresh")
)
case _:
await self._mount_and_scroll(
ErrorMessage(
f"Unknown subcommand: {subcmd}. Available: refresh",
collapsed=self._tools_collapsed,
)
)
async def _show_status(self, **kwargs: Any) -> None:
stats = self.agent_loop.stats
status_text = f"""## Agent Statistics
@ -1820,25 +1773,6 @@ class VibeApp(App): # noqa: PLR0904
await self._narrator_manager.close()
self.exit(result=self._get_session_resume_info())
async def _setup_terminal(self, **kwargs: Any) -> None:
result = setup_terminal()
if result.success:
if result.requires_restart:
message = f"{result.message or 'Set up Shift+Enter keybind'} (You may need to restart your terminal.)"
await self._mount_and_scroll(
UserCommandMessage(f"{result.terminal.value}: {message}")
)
else:
message = result.message or "Shift+Enter keybind already set up"
await self._mount_and_scroll(
WarningMessage(f"{result.terminal.value}: {message}")
)
else:
await self._mount_and_scroll(
ErrorMessage(result.message, collapsed=self._tools_collapsed)
)
def _make_default_voice_manager(self) -> VoiceManager:
try:
model = self.config.get_active_transcribe_model()
@ -2427,7 +2361,7 @@ class VibeApp(App): # noqa: PLR0904
self.call_after_refresh(schedule_switch)
async def action_toggle_debug_console(self) -> None:
async def action_toggle_debug_console(self, **kwargs: Any) -> None:
if self._debug_console is not None:
await self._debug_console.remove()
self._debug_console = None

View file

@ -53,7 +53,7 @@ class Banner(Static):
models_count=len(config.models),
mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers),
connectors_count=_connector_count(connector_registry),
skills_count=len(skill_manager.available_skills),
skills_count=skill_manager.custom_skills_count,
plan_description=None,
)
self._animated = not config.disable_welcome_banner_animation
@ -80,7 +80,7 @@ class Banner(Static):
self.state = self._initial_state
def watch_state(self) -> None:
if not self.is_mounted:
if not self.is_attached:
return
self.query_one("#banner-model", NoMarkupStatic).update(self.state.active_model)
self.query_one("#banner-meta-counts", NoMarkupStatic).update(
@ -105,7 +105,7 @@ class Banner(Static):
models_count=len(config.models),
mcp_servers_count=mcp_registry.count_loaded(config.mcp_servers),
connectors_count=_connector_count(connector_registry),
skills_count=len(skill_manager.available_skills),
skills_count=skill_manager.custom_skills_count,
plan_description=plan_description,
)

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Sequence
from collections.abc import Awaitable, Callable, Sequence
from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple
from rich.text import Text
@ -11,6 +11,7 @@ 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.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
@ -58,6 +59,7 @@ class MCPApp(Container):
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "close", "Close", show=False),
Binding("backspace", "back", "Back", show=False),
Binding("r", "refresh", "Refresh", show=False),
]
class MCPClosed(Message):
@ -69,6 +71,7 @@ class MCPApp(Container):
tool_manager: ToolManager,
initial_server: str = "",
connector_registry: ConnectorRegistry | None = None,
refresh_callback: Callable[[], Awaitable[str]] | None = None,
) -> None:
super().__init__(id="mcp-app")
self._mcp_servers = mcp_servers
@ -83,6 +86,9 @@ class MCPApp(Container):
# disambiguate entries that share the same normalised name.
self._viewing_server: str | None = initial_server.strip() or None
self._viewing_kind: str | None = None
self._refresh_callback = refresh_callback
self._status_message: str | None = None
self._refreshing = False
def compose(self) -> ComposeResult:
with Vertical(id="mcp-content"):
@ -116,12 +122,44 @@ class MCPApp(Container):
self._refresh_view(option_id.removeprefix("connector:"), kind="connector")
def action_back(self) -> None:
if self._refreshing:
return
if self._viewing_server is not None:
self._refresh_view(None)
def action_close(self) -> None:
if self._refreshing:
return
self.post_message(self.MCPClosed())
async def action_refresh(self) -> None:
if self._refresh_callback is None:
return
self._status_message = "Refreshing..."
self._refresh_view(self._viewing_server, kind=self._viewing_kind)
self._refreshing = True
self.run_worker(self._run_refresh(), exclusive=True, group="refresh")
async def _run_refresh(self) -> str:
assert self._refresh_callback is not None
return await self._refresh_callback()
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
if event.worker.group != "refresh":
return
if event.worker.is_finished:
self._refreshing = False
result = event.worker.result
self._status_message = result if isinstance(result, str) else "Refreshed."
self.refresh_index()
def _set_help_text(self, text: str) -> None:
if self._status_message:
text = f"{self._status_message} {text}"
self.query_one("#mcp-help", NoMarkupStatic).update(text)
# ── list view ────────────────────────────────────────────────────
def _refresh_view(
@ -153,9 +191,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.query_one("#mcp-help", NoMarkupStatic).update(
"↑↓ Navigate Enter Show tools Esc Close"
)
self._set_help_text("↑↓ Navigate Enter Show tools R Refresh Esc Close")
has_servers = bool(self._mcp_servers)
@ -180,47 +216,11 @@ class MCPApp(Container):
# ── Workspace Connectors ──
if has_connectors:
max_name = max(len(n) for n in self._connector_names)
type_tag = "[connector]"
type_width = len(type_tag)
tool_texts = {
n: _tool_count_text(
sum(
1
for t, _ in index.connector_tools.get(n, [])
if t in index.enabled_tools
)
)
for n in self._connector_names
}
max_tools = max(len(t) for t in tool_texts.values())
if has_servers:
option_list.add_option(Option(Text("", no_wrap=True), disabled=True))
option_list.add_option(
Option(
Text("Workspace Connectors", style="bold", no_wrap=True),
disabled=True,
)
self._add_connector_options(
option_list, index, self._connector_names, self._connector_registry
)
for cname in self._connector_names:
connected = (
self._connector_registry.is_connected(cname)
if self._connector_registry
else False
)
label = Text(no_wrap=True)
label.append(f" {cname:<{max_name}}")
label.append(f" {type_tag:<{type_width}}", style="dim")
label.append(f" {tool_texts[cname]:<{max_tools}}", style="dim")
if connected:
label.append(" ")
label.append("", style="green")
label.append(" connected", style="dim")
else:
label.append(" ")
label.append("", style="dim")
label.append(" not connected", style="dim")
option_list.add_option(Option(label, id=f"connector:{cname}"))
if not has_servers and not has_connectors:
empty_msg = (
@ -237,6 +237,55 @@ class MCPApp(Container):
)
option_list.highlighted = first_enabled
def _add_connector_options(
self,
option_list: OptionList,
index: MCPToolIndex,
connector_names: Sequence[str],
connector_registry: ConnectorRegistry | None,
) -> None:
ordered_connector_names = _sort_connector_names_for_menu(
connector_names, connector_registry
)
max_name = max(len(name) for name in ordered_connector_names)
type_tag = "[connector]"
type_width = len(type_tag)
tool_texts = {
name: _tool_count_text(
sum(
1
for tool_name, _ in index.connector_tools.get(name, [])
if tool_name in index.enabled_tools
)
)
for name in ordered_connector_names
}
max_tools = max(len(text) for text in tool_texts.values())
option_list.add_option(
Option(
Text("Workspace Connectors", style="bold", no_wrap=True), disabled=True
)
)
for connector_name in ordered_connector_names:
connected = (
connector_registry.is_connected(connector_name)
if connector_registry
else False
)
label = Text(no_wrap=True)
label.append(f" {connector_name:<{max_name}}")
label.append(f" {type_tag:<{type_width}}", style="dim")
label.append(f" {tool_texts[connector_name]:<{max_tools}}", style="dim")
if connected:
label.append(" ")
label.append("", style="green")
label.append(" connected", style="dim")
else:
label.append(" ")
label.append("", style="dim")
label.append(" not connected", style="dim")
option_list.add_option(Option(label, id=f"connector:{connector_name}"))
# ── detail view ──────────────────────────────────────────────────
def _show_detail_view(
@ -254,9 +303,7 @@ class MCPApp(Container):
self.query_one("#mcp-title", NoMarkupStatic).update(
f"{title_prefix}: {server_name}"
)
self.query_one("#mcp-help", NoMarkupStatic).update(
"↑↓ Navigate Backspace Back Esc Close"
)
self._set_help_text("↑↓ Navigate Backspace Back R Refresh Esc Close")
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])
visible_tools = [(n, c) for n, c in all_tools if n in index.enabled_tools]
@ -284,3 +331,15 @@ def _tool_count_text(count: int) -> str:
return "no tools"
noun = "tool" if count == 1 else "tools"
return f"{count} {noun}"
def _sort_connector_names_for_menu(
connector_names: Sequence[str], connector_registry: ConnectorRegistry | None
) -> list[str]:
return sorted(
connector_names,
key=lambda name: (
not connector_registry.is_connected(name) if connector_registry else True,
name.lower(),
),
)

View file

@ -5,7 +5,9 @@ from collections.abc import AsyncGenerator, Callable, Generator
import contextlib
import copy
from enum import StrEnum, auto
from functools import wraps
from http import HTTPStatus
import inspect
import os
from pathlib import Path
import threading
@ -17,7 +19,7 @@ from uuid import uuid4
from opentelemetry import trace
from pydantic import BaseModel
from vibe.cli.terminal_setup import detect_terminal
from vibe.cli.terminal_detect import detect_terminal
from vibe.core.agents.manager import AgentManager
from vibe.core.agents.models import AgentProfile, BuiltinAgentName
from vibe.core.config import ModelConfig, ProviderConfig, VibeConfig
@ -117,7 +119,6 @@ except ImportError:
_TeleportService = None
if TYPE_CHECKING:
from vibe.core.teleport.nuage import TeleportSession
from vibe.core.teleport.teleport import TeleportService
from vibe.core.teleport.types import TeleportPushResponseEvent, TeleportYieldEvent
@ -153,6 +154,33 @@ def _should_raise_rate_limit_error(e: Exception) -> bool:
return isinstance(e, BackendError) and e.status == HTTPStatus.TOO_MANY_REQUESTS
def requires_init(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator that awaits deferred initialization before executing the method."""
if inspect.isasyncgenfunction(fn):
@wraps(fn)
async def gen_wrapper(self: AgentLoop, *args: Any, **kwargs: Any) -> Any:
await self.wait_until_ready()
agen = fn(self, *args, **kwargs)
sent: Any = None
try:
while True:
sent = yield await agen.asend(sent)
except StopAsyncIteration:
return
finally:
await agen.aclose()
return gen_wrapper
@wraps(fn)
async def wrapper(self: AgentLoop, *args: Any, **kwargs: Any) -> Any:
await self.wait_until_ready()
return await fn(self, *args, **kwargs)
return wrapper
class AgentLoop:
def __init__(
self,
@ -169,7 +197,10 @@ class AgentLoop:
defer_heavy_init: bool = False,
) -> None:
self._base_config = config
self._init_complete = threading.Event()
self._defer_heavy_init = defer_heavy_init
self._deferred_init_thread: threading.Thread | None = None
self._deferred_init_lock = threading.Lock()
self._init_error: Exception | None = None
self.mcp_registry = MCPRegistry()
@ -213,9 +244,6 @@ class AgentLoop:
system_message = LLMMessage(role=Role.system, content=system_prompt)
self.messages = MessageList(initial=[system_message], observer=message_observer)
if not defer_heavy_init:
self._init_complete.set()
self.stats = AgentStats()
self.approval_callback: ApprovalCallback | None = None
self.user_input_callback: UserInputCallback | None = None
@ -246,32 +274,39 @@ class AgentLoop:
)
self._teleport_service: TeleportService | None = None
thread = Thread(
Thread(
target=migrate_sessions_entrypoint,
args=(config.session_logging,),
daemon=True,
name="migrate_sessions",
)
thread.start()
).start()
def start_deferred_init(self) -> threading.Thread:
"""Spawn a daemon thread that finishes deferred heavy I/O.
if defer_heavy_init:
self._start_deferred_init()
Returns the started thread so callers can join if needed.
"""
thread = threading.Thread(
target=self._complete_init, daemon=True, name="agent_loop_init"
)
thread.start()
return thread
def _start_deferred_init(self) -> threading.Thread:
"""Spawn a daemon thread that finishes deferred heavy I/O once."""
with self._deferred_init_lock:
if self._deferred_init_thread is not None:
return self._deferred_init_thread
thread = threading.Thread(
target=self._complete_init, daemon=True, name="agent_loop_init"
)
self._deferred_init_thread = thread
thread.start()
return thread
@property
def is_initialized(self) -> bool:
"""Whether deferred initialization has completed (successfully or not)."""
return self._init_complete.is_set()
if not self._defer_heavy_init:
return True
thread = self._deferred_init_thread
return thread is not None and not thread.is_alive()
def _complete_init(self) -> None:
"""Run deferred heavy I/O: MCP discovery, connectors, and git status.
"""Run deferred heavy I/O: MCP and connector discovery.
Intended to be called from a background thread when
``defer_heavy_init=True`` was passed to ``__init__``.
@ -284,22 +319,13 @@ class AgentLoop:
self.messages.update_system_prompt(system_prompt)
except Exception as exc:
self._init_error = exc
finally:
self._init_complete.set()
async def wait_for_init(self) -> None:
"""Await deferred initialization from an async context.
If deferred init failed, all callers raise the stored error.
A copy of the stored exception is raised each time so that concurrent
callers do not share (and mutate) the same ``__traceback__`` object.
"""
if self.is_initialized:
if err := self._init_error:
raise copy.copy(err).with_traceback(err.__traceback__)
async def wait_until_ready(self) -> None:
"""Await deferred initialization from an async context."""
if not self._defer_heavy_init:
return
await asyncio.to_thread(self._init_complete.wait)
thread = self._start_deferred_init()
await asyncio.to_thread(thread.join)
if err := self._init_error:
raise copy.copy(err).with_traceback(err.__traceback__)
@ -424,7 +450,8 @@ class AgentLoop:
server_url = get_server_url_from_api_base(provider.api_base)
return ConnectorRegistry(api_key=api_key, server_url=server_url)
def refresh_system_prompt(self) -> None:
@requires_init
async def refresh_system_prompt(self) -> None:
"""Rebuild and replace the system prompt with current tool/skill state."""
system_prompt = get_universal_system_prompt(
self.tool_manager, self.config, self.skill_manager, self.agent_manager
@ -446,10 +473,12 @@ class AgentLoop:
self.agent_profile,
)
@requires_init
async def inject_user_context(self, content: str) -> None:
self.messages.append(LLMMessage(role=Role.user, content=content, injected=True))
await self._save_messages()
@requires_init
async def act(
self, msg: str, client_message_id: str | None = None
) -> AsyncGenerator[BaseEvent, None]:
@ -486,9 +515,11 @@ class AgentLoop:
)
return self._teleport_service
def teleport_to_vibe_nuage(
@requires_init
async def teleport_to_vibe_nuage(
self, prompt: str | None
) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]:
from vibe.core.teleport.errors import ServiceTeleportError
from vibe.core.teleport.nuage import TeleportSession
session = TeleportSession(
@ -499,13 +530,6 @@ class AgentLoop:
},
messages=[msg.model_dump(exclude_none=True) for msg in self.messages[1:]],
)
return self._teleport_generator(prompt, session)
async def _teleport_generator(
self, prompt: str | None, session: TeleportSession
) -> AsyncGenerator[TeleportYieldEvent, TeleportPushResponseEvent | None]:
from vibe.core.teleport.errors import ServiceTeleportError
try:
async with self.teleport_service:
gen = self.teleport_service.execute(prompt=prompt, session=session)
@ -979,6 +1003,7 @@ class AgentLoop:
self.telemetry_client.send_tool_call_finished(
tool_call=tool_call,
agent_profile_name=self.agent_profile.name,
model=self.config.active_model,
status=status,
decision=decision,
result=result,
@ -1011,6 +1036,23 @@ class AgentLoop:
available_tools = self.format_handler.get_available_tools(self.tool_manager)
tool_choice = self.format_handler.get_tool_choice()
last_user_message = next(
(
m
for m in reversed(self.messages)
if m.role == Role.user and not m.injected
),
None,
)
self.telemetry_client.send_request_sent(
model=active_model.alias,
nb_context_chars=sum(len(m.content or "") for m in self.messages),
nb_context_messages=len(self.messages),
nb_prompt_chars=len(last_user_message.content or "")
if last_user_message
else 0,
)
try:
start_time = time.perf_counter()
result = await self.backend.complete(
@ -1056,6 +1098,24 @@ class AgentLoop:
available_tools = self.format_handler.get_available_tools(self.tool_manager)
tool_choice = self.format_handler.get_tool_choice()
last_user_message = next(
(
m
for m in reversed(self.messages)
if m.role == Role.user and not m.injected
),
None,
)
self.telemetry_client.send_request_sent(
model=active_model.alias,
nb_context_chars=sum(len(m.content or "") for m in self.messages),
nb_context_messages=len(self.messages),
nb_prompt_chars=len(last_user_message.content or "")
if last_user_message
else 0,
)
try:
start_time = time.perf_counter()
usage = LLMUsage()
@ -1239,6 +1299,7 @@ class AgentLoop:
self.session_id = str(uuid4())
self.session_logger.reset_session(self.session_id)
@requires_init
async def clear_history(self) -> None:
await self.session_logger.save_interaction(
self.messages,
@ -1264,6 +1325,7 @@ class AgentLoop:
self.tool_manager.reset_all()
self._reset_session()
@requires_init
async def compact(self) -> str:
try:
self._clean_message_history()
@ -1332,12 +1394,14 @@ class AgentLoop:
)
raise
@requires_init
async def switch_agent(self, agent_name: str) -> None:
if agent_name == self.agent_profile.name:
return
self.agent_manager.switch_profile(agent_name)
await self.reload_with_initial_messages(reset_middleware=False)
@requires_init
async def reload_with_initial_messages(
self,
base_config: VibeConfig | None = None,

View file

@ -29,6 +29,15 @@ from vibe.core.config._settings import (
VibeConfig,
load_dotenv_values,
)
from vibe.core.config.schema import (
DuplicateMergeMetadataError,
MergeFieldMetadata,
WithConcatMerge,
WithConflictMerge,
WithReplaceMerge,
WithShallowMerge,
WithUnionMerge,
)
__all__ = [
"DEFAULT_MISTRAL_API_ENV_KEY",
@ -38,10 +47,12 @@ __all__ = [
"DEFAULT_TRANSCRIBE_PROVIDERS",
"DEFAULT_TTS_MODELS",
"DEFAULT_TTS_PROVIDERS",
"DuplicateMergeMetadataError",
"MCPHttp",
"MCPServer",
"MCPStdio",
"MCPStreamableHttp",
"MergeFieldMetadata",
"MissingAPIKeyError",
"MissingPromptFileError",
"ModelConfig",
@ -57,5 +68,10 @@ __all__ = [
"TranscribeModelConfig",
"TranscribeProviderConfig",
"VibeConfig",
"WithConcatMerge",
"WithConflictMerge",
"WithReplaceMerge",
"WithShallowMerge",
"WithUnionMerge",
"load_dotenv_values",
]

View file

@ -290,6 +290,9 @@ class MCPStdio(_MCPBase):
default_factory=dict,
description="Environment variables to set for the MCP server process.",
)
cwd: str | None = Field(
default=None, description="Working directory for the MCP server process."
)
def argv(self) -> list[str]:
base = (

View file

@ -0,0 +1,85 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pydantic.fields import FieldInfo
from vibe.core.utils.merge import MergeStrategy
class DuplicateMergeMetadataError(TypeError):
"""Raised when a field declares more than one MergeFieldMetadata marker."""
@dataclass(frozen=True)
class MergeFieldMetadata:
"""Base Pydantic Annotated marker that declares how a config field merges across layers.
Usage::
active_model: Annotated[str, WithReplaceMerge()] = "devstral-2"
models: Annotated[list[M], WithUnionMerge(merge_key="alias")]
Use the concrete subclasses (``WithReplaceMerge``, ``WithConcatMerge``, etc.)
rather than instantiating this base class directly.
"""
merge_strategy: MergeStrategy
merge_key: str | None = None
@classmethod
def from_field(cls, field_info: FieldInfo) -> MergeFieldMetadata | None:
"""Extract MergeFieldMetadata from a FieldInfo's metadata, if present.
Raises DuplicateMergeMetadataError if more than one marker is found.
"""
result: MergeFieldMetadata | None = None
for item in field_info.metadata:
if isinstance(item, cls):
if result is not None:
raise DuplicateMergeMetadataError(
f"Field has multiple MergeFieldMetadata markers: "
f"{result} and {item}"
)
result = item
return result
@dataclass(frozen=True)
class WithReplaceMerge(MergeFieldMetadata):
"""Higher layer wins outright."""
merge_strategy: MergeStrategy = field(default=MergeStrategy.REPLACE, init=False)
@dataclass(frozen=True)
class WithConcatMerge(MergeFieldMetadata):
"""Lists appended in layer order."""
merge_strategy: MergeStrategy = field(default=MergeStrategy.CONCAT, init=False)
@dataclass(frozen=True)
class WithUnionMerge(MergeFieldMetadata):
"""Lists merged by key, higher layer wins per-key."""
merge_strategy: MergeStrategy = field(default=MergeStrategy.UNION, init=False)
merge_key: str = ""
def __post_init__(self) -> None:
if not self.merge_key:
raise TypeError("WithUnionMerge requires merge_key")
@dataclass(frozen=True)
class WithShallowMerge(MergeFieldMetadata):
"""Dicts shallow-merged, absent keys preserved."""
merge_strategy: MergeStrategy = field(default=MergeStrategy.MERGE, init=False)
@dataclass(frozen=True)
class WithConflictMerge(MergeFieldMetadata):
"""Raises error if more than one layer provides a value."""
merge_strategy: MergeStrategy = field(default=MergeStrategy.CONFLICT, init=False)

View file

@ -0,0 +1,6 @@
from __future__ import annotations
from vibe.core.skills.builtins.vibe import SKILL as VIBE_SKILL
from vibe.core.skills.models import SkillInfo
BUILTIN_SKILLS: dict[str, SkillInfo] = {skill.name: skill for skill in [VIBE_SKILL]}

View file

@ -0,0 +1,350 @@
from __future__ import annotations
from vibe.core.skills.models import SkillInfo
SKILL = SkillInfo(
name="vibe",
description="Understand the Vibe CLI application internals: configuration, VIBE_HOME structure, available parameters, agents, skills, tools, and how to inspect or update the user's setup. Use this skill when the user asks about how Vibe works, wants to configure it, or when you need to understand the runtime environment.",
prompt="""# Vibe CLI Self-Awareness
You are running inside **Mistral Vibe**, a CLI coding agent built by Mistral AI.
This skill gives you full knowledge of the application internals so you can help
the user understand, configure, and troubleshoot their Vibe installation.
## VIBE_HOME
The user's Vibe home directory defaults to `~/.vibe` but can be overridden via
the `VIBE_HOME` environment variable. All user-level configuration, skills, tools,
agents, prompts, logs, and session data live here.
### Directory Structure
```
~/.vibe/
config.toml # Main configuration file (TOML format)
.env # API keys and credentials (dotenv format)
vibehistory # Command history
trusted_folders.toml # Trust database for project folders
agents/ # Custom agent profiles (*.toml)
prompts/ # Custom system prompts (*.md)
skills/ # User-level skills (each skill is a subdirectory with SKILL.md)
tools/ # Custom tool definitions
logs/
vibe.log # Main log file
session/ # Session log files
plans/ # Session plans
```
### Project-Local Configuration
When in a trusted folder, Vibe also looks for project-local configuration:
- `.vibe/config.toml` - Project-specific config (overrides user config)
- `.vibe/skills/` - Project-specific skills
- `.vibe/tools/` - Project-specific tools
- `.vibe/agents/` - Project-specific agents
- `.vibe/prompts/` - Project-specific prompts
- `.agents/skills/` - Standard agent skills directory
## Configuration (config.toml)
The configuration file uses TOML format. Settings can also be overridden via
environment variables with the `VIBE_` prefix (e.g., `VIBE_ACTIVE_MODEL=local`).
### Key Settings
```toml
# Model selection
active_model = "devstral-2" # Model alias to use (see [[models]])
# UI preferences
vim_keybindings = false
disable_welcome_banner_animation = false
autocopy_to_clipboard = true
file_watcher_for_autocomplete = false
# Behavior
auto_approve = false # Skip tool approval prompts
system_prompt_id = "cli" # System prompt: "cli", "lean", or custom .md filename
enable_telemetry = true
enable_update_checks = true
enable_auto_update = true
enable_notifications = true
api_timeout = 720.0 # API request timeout in seconds
auto_compact_threshold = 200000 # Token count before auto-compaction
# Git commit behavior
include_commit_signature = true # Add "Co-Authored-By" to commits
# System prompt composition
include_model_info = true # Include model name in system prompt
include_project_context = true # Include project context (git info, cwd) in system prompt
include_prompt_detail = true # Include OS info, tool prompts, skills, and agents in system prompt
# Voice features
voice_mode_enabled = false
narrator_enabled = false
active_transcribe_model = "voxtral-realtime"
active_tts_model = "voxtral-tts"
```
### Providers
```toml
[[providers]]
name = "mistral"
api_base = "https://api.mistral.ai/v1"
api_key_env_var = "MISTRAL_API_KEY"
backend = "mistral"
[[providers]]
name = "llamacpp"
api_base = "http://127.0.0.1:8080/v1"
api_key_env_var = ""
```
### Models
```toml
[[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"
auto_compact_threshold = 200000
[[models]]
name = "devstral-small-latest"
provider = "mistral"
alias = "devstral-small"
input_price = 0.1
output_price = 0.3
[[models]]
name = "devstral"
provider = "llamacpp"
alias = "local"
```
### Tool Configuration
```toml
# Additional tool search paths
tool_paths = ["/path/to/custom/tools"]
# Enable only specific tools (glob and regex supported)
enabled_tools = ["bash", "read_file", "grep"]
# Disable specific tools
disabled_tools = ["webfetch"]
# Per-tool configuration
[tools.bash]
allowlist = ["git", "npm", "python"]
```
### Skill Configuration
```toml
# Additional skill search paths
skill_paths = ["/path/to/custom/skills"]
# Enable only specific skills
enabled_skills = ["vibe", "custom-*"]
# Disable specific skills
disabled_skills = ["experimental-*"]
```
### Agent Configuration
```toml
# Additional agent search paths
agent_paths = ["/path/to/custom/agents"]
# Enable/disable agents
enabled_agents = ["default", "plan"]
disabled_agents = ["auto-approve"]
# Opt-in builtin agents (only affects agents with install_required=True, e.g. lean)
installed_agents = ["lean"]
```
### MCP Servers
```toml
[[mcp_servers]]
name = "my-server"
transport = "stdio"
command = "npx"
args = ["-y", "@my/mcp-server"]
[[mcp_servers]]
name = "remote-server"
transport = "http"
url = "https://mcp.example.com"
api_key_env = "MCP_API_KEY"
```
### Session Logging
```toml
[session_logging]
enabled = true
save_dir = "" # Defaults to ~/.vibe/logs/session
session_prefix = "session"
```
### Pattern Matching
Tool, skill, and agent names support three matching modes:
- **Exact**: `"bash"`, `"read_file"`
- **Glob**: `"bash*"`, `"mcp_*"`
- **Regex**: `"re:^serena_.*$"` (full match, case-insensitive)
## CLI Parameters
```
vibe [PROMPT] # Start interactive session with optional prompt
vibe -p TEXT / --prompt TEXT # Programmatic mode (auto-approve, one-shot, exit)
vibe --agent NAME # Select agent profile
vibe --workdir DIR # Change working directory
vibe -c / --continue # Continue most recent session
vibe --resume [SESSION_ID] # Resume a specific session
vibe -v / --version # Show version
vibe --setup # Run onboarding/setup
vibe --max-turns N # Max assistant turns (programmatic mode)
vibe --max-price DOLLARS # Max cost limit (programmatic mode)
vibe --enabled-tools TOOL # Enable specific tools (repeatable)
vibe --output text|json|streaming # Output format (programmatic mode)
```
## Built-in Agents
There are two kinds of agents:
- **Agents** are user-facing profiles selectable via `--agent` or `Shift+Tab`.
They configure the model's behavior, tools, and system prompt.
- **Subagents** are model-facing: the model can spawn them autonomously to delegate
subtasks (e.g. exploring the codebase). Users cannot select subagents directly.
### Agents
- **default**: Standard interactive agent
- **plan**: Planning-focused agent
- **accept-edits**: Auto-approves file edits but asks for other tools
- **auto-approve**: Auto-approves all tool calls
- **lean**: Specialized Lean 4 proof assistant. Not available by default must be
installed with `/leanstall` (removed with `/unleanstall`)
### Subagents
- **explore**: Read-only codebase exploration subagent (grep + read_file only).
Spawned by the model, not selectable by the user.
Custom agents are TOML files in `~/.vibe/agents/NAME.toml`.
## Built-in Slash Commands
- `/help` - Show help message
- `/config` - Edit config settings
- `/model` - Select active model
- `/reload` - Reload configuration, agent instructions, and skills from disk
- `/clear` - Clear conversation history
- `/log` - Show path to current interaction log file
- `/debug` - Toggle debug console
- `/compact` - Compact conversation history by summarizing
- `/status` - Display agent statistics
- `/voice` - Configure voice settings
- `/mcp` - Display available MCP servers (pass a server name to list its tools)
- `/resume` (or `/continue`) - Browse and resume past sessions
- `/rewind` - Rewind to a previous message
- `/terminal-setup` - Configure Shift+Enter for newlines
- `/proxy-setup` - Configure proxy and SSL certificate settings
- `/leanstall` - Install the Lean 4 agent (leanstral)
- `/unleanstall` - Uninstall the Lean 4 agent
- `/data-retention` - Show data retention information
- `/teleport` - Teleport session to Vibe Nuage (only available when Nuage is enabled)
- `/exit` - Exit the application
## Skills System
Skills are specialized instruction sets the model can load on demand.
Each skill is a directory containing a `SKILL.md` file with YAML frontmatter.
### Skill File Format
```markdown
---
name: my-skill
description: What this skill does and when to use it.
user-invocable: true
allowed-tools: bash read_file
---
# Skill Instructions
Detailed instructions for the model...
```
### Skill Search Order (first match wins)
1. `skill_paths` from config.toml
2. `.vibe/skills/` in trusted project directory
3. `.agents/skills/` in trusted project directory
4. `~/.vibe/skills/` (user global)
## Environment Variables
- `VIBE_HOME` - Override the Vibe home directory (default: `~/.vibe`)
- `MISTRAL_API_KEY` - API key for Mistral provider
- `VIBE_ACTIVE_MODEL` - Override active model
- `VIBE_*` - Any config field can be overridden with the `VIBE_` prefix
## API Keys (.env file)
The `.env` file in VIBE_HOME stores API keys in dotenv format:
```
MISTRAL_API_KEY=your-key-here
```
This file is loaded on startup and its values are injected into the environment.
## Trusted Folders
Vibe uses a trust system to prevent executing project-local config from untrusted
directories. The trust database is stored in `~/.vibe/trusted_folders.toml`.
Project-local config (`.vibe/` directory) is only loaded when the current
directory is explicitly trusted.
## Sensitive Files — DO NOT READ OR EDIT
NEVER read, display, or edit any of these files:
- `~/.vibe/.env` (or `$VIBE_HOME/.env`) contains API keys and secrets
- Any `.env`, `.env.*` file in the project or VIBE_HOME
If the user asks to set or change an API key, instruct them to edit the `.env`
file themselves. Do not offer to read it, write it, or display its contents.
Do not use tools (read_file, write_file, bash cat/echo, etc.) to access these files.
## How to Modify Configuration
To help the user modify their Vibe configuration:
1. **Read current config**: Read the file at `~/.vibe/config.toml` (or the path
from `VIBE_HOME` env var if set)
2. **Create a backup**: Before any edit, copy the file to `config.toml.bak` in the
same directory (e.g. `cp ~/.vibe/config.toml ~/.vibe/config.toml.bak`). This
applies to any config file you are about to modify (`config.toml`,
`trusted_folders.toml`, agent TOML files, etc.)
3. **Edit the TOML file**: Make changes using the search_replace or write_file tool
4. **Reload**: The user can run `/reload` to apply changes without restarting
For API keys, tell the user to edit `~/.vibe/.env` directly never read or
write that file yourself.
For project-specific configuration, create/edit `.vibe/config.toml` in the
project root (the folder must be trusted first).""",
)

View file

@ -1,13 +1,15 @@
from __future__ import annotations
from collections.abc import Callable
from collections.abc import Callable, Mapping
from pathlib import Path
from types import MappingProxyType
from typing import TYPE_CHECKING
from vibe.core.config.harness_files import get_harness_files_manager
from vibe.core.logger import logger
from vibe.core.skills.builtins import BUILTIN_SKILLS
from vibe.core.skills.models import ParsedSkillCommand, SkillInfo, SkillMetadata
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
from vibe.core.skills.parser import SkillParseError, parse_skill_markdown
from vibe.core.utils import name_matches
from vibe.core.utils.io import read_safe
@ -19,12 +21,14 @@ class SkillManager:
def __init__(self, config_getter: Callable[[], VibeConfig]) -> None:
self._config_getter = config_getter
self._search_paths = self._compute_search_paths(self._config)
self._available: dict[str, SkillInfo] = self._discover_skills()
self.available_skills: Mapping[str, SkillInfo] = MappingProxyType(
self._apply_filters(self._discover_skills())
)
if self._available:
if self.available_skills:
logger.info(
"Discovered %d skill(s) from %d search path(s)",
len(self._available),
len(self.available_skills),
len(self._search_paths),
)
@ -32,21 +36,20 @@ class SkillManager:
def _config(self) -> VibeConfig:
return self._config_getter()
@property
def available_skills(self) -> dict[str, SkillInfo]:
def _apply_filters(self, skills: dict[str, SkillInfo]) -> dict[str, SkillInfo]:
if self._config.enabled_skills:
return {
name: info
for name, info in self._available.items()
for name, info in skills.items()
if name_matches(name, self._config.enabled_skills)
}
if self._config.disabled_skills:
return {
name: info
for name, info in self._available.items()
for name, info in skills.items()
if not name_matches(name, self._config.disabled_skills)
}
return dict(self._available)
return dict(skills)
@staticmethod
def _compute_search_paths(config: VibeConfig) -> list[Path]:
@ -69,7 +72,7 @@ class SkillManager:
return unique
def _discover_skills(self) -> dict[str, SkillInfo]:
skills: dict[str, SkillInfo] = {}
skills: dict[str, SkillInfo] = {**BUILTIN_SKILLS}
for base in self._search_paths:
if not base.is_dir():
continue
@ -93,8 +96,24 @@ class SkillManager:
skill_file = skill_dir / "SKILL.md"
if not skill_file.is_file():
continue
if (skill_info := self._try_load_skill(skill_file)) is not None:
skills[skill_info.name] = skill_info
if (skill_info := self._try_load_skill(skill_file)) is None:
continue
if skill_info.name in BUILTIN_SKILLS:
logger.debug(
"Skipping skill '%s' at %s because builtin skill names are reserved",
skill_info.name,
skill_info.skill_path,
)
continue
if skill_info.name in skills:
logger.debug(
"Skipping duplicate skill '%s' at %s (already loaded from %s)",
skill_info.name,
skill_info.skill_path,
skills[skill_info.name].skill_path,
)
continue
skills[skill_info.name] = skill_info
return skills
def _try_load_skill(self, skill_file: Path) -> SkillInfo | None:
@ -111,7 +130,7 @@ class SkillManager:
except OSError as e:
raise SkillParseError(f"Cannot read file: {e}") from e
frontmatter, _ = parse_frontmatter(content)
frontmatter, body = parse_skill_markdown(content)
metadata = SkillMetadata.model_validate(frontmatter)
skill_name_from_dir = skill_path.parent.name
@ -123,7 +142,11 @@ class SkillManager:
skill_path,
)
return SkillInfo.from_metadata(metadata, skill_path)
return SkillInfo.from_metadata(metadata, skill_path, prompt=body.strip())
@property
def custom_skills_count(self) -> int:
return sum(name not in BUILTIN_SKILLS for name in self.available_skills)
def get_skill(self, name: str) -> SkillInfo | None:
return self.available_skills.get(name)
@ -142,12 +165,11 @@ class SkillManager:
if skill_info is None:
return None
skill_content = read_safe(skill_info.skill_path).text
extra_instructions = parts[1] if len(parts) > 1 else None
return ParsedSkillCommand(
name=skill_name,
content=skill_content,
content=skill_info.prompt,
extra_instructions=extra_instructions,
)

View file

@ -70,16 +70,21 @@ class SkillInfo(BaseModel):
metadata: dict[str, str] = Field(default_factory=dict)
allowed_tools: list[str] = Field(default_factory=list)
user_invocable: bool = True
skill_path: Path
skill_path: Path | None = None
prompt: str
model_config = {"arbitrary_types_allowed": True}
@property
def skill_dir(self) -> Path:
def skill_dir(self) -> Path | None:
if self.skill_path is None:
return None
return self.skill_path.parent.resolve()
@classmethod
def from_metadata(cls, meta: SkillMetadata, skill_path: Path) -> SkillInfo:
def from_metadata(
cls, meta: SkillMetadata, skill_path: Path, prompt: str
) -> SkillInfo:
return cls(
name=meta.name,
description=meta.description,
@ -89,6 +94,7 @@ class SkillInfo(BaseModel):
allowed_tools=meta.allowed_tools,
user_invocable=meta.user_invocable,
skill_path=skill_path.resolve(),
prompt=prompt,
)

View file

@ -15,7 +15,7 @@ class SkillParseError(Exception):
FM_BOUNDARY = re.compile(r"^-{3,}\s*$", re.MULTILINE)
def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
def parse_skill_markdown(content: str) -> tuple[dict[str, Any], str]:
splits = FM_BOUNDARY.split(content, 2)
if len(splits) < 3 or splits[0].strip(): # noqa: PLR2004
raise SkillParseError(

View file

@ -207,7 +207,7 @@ def _get_available_skills_section(skill_manager: SkillManager) -> str:
"# Available Skills",
"",
"You have access to the following skills. When a task matches a skill's description,",
"use the `skill` tool if available to load the full skill instructions, if it is not available, read the files manually.",
"use the `skill` tool if available to load the full skill instructions, if it is not available, read the files manually if they exist.",
"",
"<available_skills>",
]
@ -218,7 +218,8 @@ def _get_available_skills_section(skill_manager: SkillManager) -> str:
lines.append(
f" <description>{html.escape(str(info.description))}</description>"
)
lines.append(f" <path>{html.escape(str(info.skill_path))}</path>")
if info.skill_path is not None:
lines.append(f" <path>{html.escape(str(info.skill_path))}</path>")
lines.append(" </skill>")
lines.append("</available_skills>")

View file

@ -157,6 +157,7 @@ class TelemetryClient:
status: Literal["success", "failure", "skipped"],
decision: ToolDecision | None,
agent_profile_name: str,
model: str,
result: dict[str, Any] | None = None,
) -> None:
verdict_value = decision.verdict.value if decision else None
@ -172,6 +173,7 @@ class TelemetryClient:
"decision": verdict_value,
"approval_type": approval_type_value,
"agent_profile_name": agent_profile_name,
"model": model,
"nb_files_created": nb_files_created,
"nb_files_modified": nb_files_modified,
}
@ -224,6 +226,22 @@ class TelemetryClient:
"vibe.onboarding_api_key_added", {"version": __version__}
)
def send_request_sent(
self,
*,
model: str,
nb_context_chars: int,
nb_context_messages: int,
nb_prompt_chars: int,
) -> None:
payload = {
"model": model,
"nb_context_chars": nb_context_chars,
"nb_context_messages": nb_context_messages,
"nb_prompt_chars": nb_prompt_chars,
}
self.send_telemetry_event("vibe.request_sent", payload)
def send_user_rating_feedback(self, rating: int, model: str) -> None:
self.send_telemetry_event(
"vibe.user_rating_feedback",

View file

@ -5,7 +5,6 @@ from typing import ClassVar
from pydantic import BaseModel, Field
from vibe.core.skills.parser import SkillParseError, parse_frontmatter
from vibe.core.tools.base import (
BaseTool,
BaseToolConfig,
@ -14,14 +13,9 @@ from vibe.core.tools.base import (
ToolError,
ToolPermission,
)
from vibe.core.tools.permissions import (
PermissionContext,
PermissionScope,
RequiredPermission,
)
from vibe.core.tools.permissions import PermissionContext
from vibe.core.tools.ui import ToolCallDisplay, ToolResultDisplay, ToolUIData
from vibe.core.types import ToolResultEvent, ToolStreamEvent
from vibe.core.utils.io import read_safe
_MAX_LISTED_FILES = 10
@ -33,11 +27,13 @@ class SkillArgs(BaseModel):
class SkillResult(BaseModel):
name: str = Field(description="The name of the loaded skill")
content: str = Field(description="The full skill content block")
skill_dir: str = Field(description="Absolute path to the skill directory")
skill_dir: str | None = Field(
default=None, description="Absolute path to the skill directory when available"
)
class SkillToolConfig(BaseToolConfig):
permission: ToolPermission = ToolPermission.ASK
permission: ToolPermission = ToolPermission.ALWAYS
class Skill(
@ -71,17 +67,7 @@ class Skill(
return "Loading skill"
def resolve_permission(self, args: SkillArgs) -> PermissionContext | None:
return PermissionContext(
permission=self.config.permission,
required_permissions=[
RequiredPermission(
scope=PermissionScope.FILE_PATTERN,
invocation_pattern=args.name,
session_pattern=args.name,
label=f"Load skill: {args.name}",
)
],
)
return PermissionContext(permission=ToolPermission.ALWAYS)
async def run(
self, args: SkillArgs, ctx: InvokeContext | None = None
@ -98,36 +84,36 @@ class Skill(
f'Skill "{args.name}" not found. Available skills: {available or "none"}'
)
try:
raw = read_safe(skill_info.skill_path).text
_, body = parse_frontmatter(raw)
except (OSError, SkillParseError) as e:
raise ToolError(f"Cannot load skill file: {e}") from e
skill_dir = skill_info.skill_dir
files: list[str] = []
try:
for entry in sorted(skill_dir.rglob("*")):
if not entry.is_file():
continue
if entry.name == "SKILL.md":
continue
files.append(str(entry.relative_to(skill_dir)))
if len(files) >= _MAX_LISTED_FILES:
break
except OSError:
pass
if skill_dir is not None:
try:
for entry in sorted(skill_dir.rglob("*")):
if not entry.is_file():
continue
if entry.name == "SKILL.md":
continue
files.append(str(entry.relative_to(skill_dir)))
if len(files) >= _MAX_LISTED_FILES:
break
except OSError:
pass
file_lines = "\n".join(f"<file>{f}</file>" for f in files)
base_dir_lines: list[str] = []
if skill_dir is not None:
base_dir_lines = [
f"Base directory for this skill: {skill_dir}",
"Relative paths in this skill are relative to this base directory.",
]
output = "\n".join([
f'<skill_content name="{args.name}">',
f"# Skill: {args.name}",
"",
body.strip(),
skill_info.prompt.strip(),
"",
f"Base directory for this skill: {skill_dir}",
"Relative paths in this skill are relative to this base directory.",
*base_dir_lines,
"Note: file list is sampled.",
"",
"<skill_files>",
@ -136,4 +122,5 @@ class Skill(
"</skill_content>",
])
yield SkillResult(name=args.name, content=output, skill_dir=str(skill_dir))
resolved_skill_dir = None if skill_dir is None else str(skill_dir)
yield SkillResult(name=args.name, content=output, skill_dir=resolved_skill_dir)

View file

@ -135,6 +135,7 @@ class Task(
agent_name=args.agent,
entrypoint_metadata=ctx.entrypoint_metadata,
is_subagent=True,
defer_heavy_init=True,
)
if ctx and ctx.approval_callback:

View file

@ -254,6 +254,17 @@ class ToolManager:
self._available.pop(key, None)
self._instances.pop(key, None)
def _purge_mcp_state(self) -> None:
"""Remove stale MCP tool classes and cached instances."""
stale_keys = [
name
for name, cls in self._available.items()
if issubclass(cls, MCPTool) and not cls.is_connector()
]
for key in stale_keys:
self._available.pop(key, None)
self._instances.pop(key, None)
def integrate_connectors(self) -> None:
"""Discover and register connector tools (sync wrapper)."""
run_sync(self.integrate_connectors_async())
@ -279,6 +290,22 @@ class ToolManager:
self._available.update(connector_tools)
logger.info(f"Connector integration registered {len(connector_tools)} tools")
async def refresh_remote_tools_async(self) -> None:
"""Force MCP and connector re-discovery for the current config."""
with self._lock:
self._mcp_registry.clear()
self._purge_mcp_state()
self._mcp_integrated = False
self._purge_connector_state()
if self._connector_registry is not None:
self._connector_registry.clear()
await self._integrate_all_async()
def refresh_remote_tools(self) -> None:
"""Sync wrapper for :meth:`refresh_remote_tools_async`."""
run_sync(self.refresh_remote_tools_async())
def integrate_all(self, *, raise_on_mcp_failure: bool = False) -> None:
"""Discover MCP and connector tools in parallel.

View file

@ -139,7 +139,10 @@ class MCPRegistry:
try:
remotes = await list_tools_stdio(
cmd, env=srv.env or None, startup_timeout_sec=srv.startup_timeout_sec
cmd,
env=srv.env or None,
cwd=srv.cwd,
startup_timeout_sec=srv.startup_timeout_sec,
)
except Exception as exc:
logger.warning("MCP stdio discovery failed for %r: %s", cmd, exc)
@ -154,6 +157,7 @@ class MCPRegistry:
alias=srv.name,
server_hint=srv.prompt,
env=srv.env or None,
cwd=srv.cwd,
startup_timeout_sec=srv.startup_timeout_sec,
tool_timeout_sec=srv.tool_timeout_sec,
sampling_enabled=srv.sampling_enabled,

View file

@ -297,9 +297,12 @@ async def list_tools_stdio(
command: list[str],
*,
env: dict[str, str] | None = None,
cwd: str | None = None,
startup_timeout_sec: float | None = None,
) -> list[RemoteTool]:
params = StdioServerParameters(command=command[0], args=command[1:], env=env)
params = StdioServerParameters(
command=command[0], args=command[1:], env=env, cwd=cwd
)
timeout = timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
async with (
_mcp_stderr_capture() as errlog,
@ -317,11 +320,14 @@ async def call_tool_stdio(
arguments: dict[str, Any],
*,
env: dict[str, str] | None = None,
cwd: str | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_callback: MCPSamplingHandler | None = None,
) -> MCPToolResult:
params = StdioServerParameters(command=command[0], args=command[1:], env=env)
params = StdioServerParameters(
command=command[0], args=command[1:], env=env, cwd=cwd
)
init_timeout = (
timedelta(seconds=startup_timeout_sec) if startup_timeout_sec else None
)
@ -350,6 +356,7 @@ def create_mcp_stdio_proxy_tool_class(
alias: str | None = None,
server_hint: str | None = None,
env: dict[str, str] | None = None,
cwd: str | None = None,
startup_timeout_sec: float | None = None,
tool_timeout_sec: float | None = None,
sampling_enabled: bool = True,
@ -378,6 +385,7 @@ def create_mcp_stdio_proxy_tool_class(
_remote_name: ClassVar[str] = remote.name
_input_schema: ClassVar[dict[str, Any]] = remote.input_schema
_env: ClassVar[dict[str, str] | None] = env
_cwd: ClassVar[str | None] = cwd
_startup_timeout_sec: ClassVar[float | None] = startup_timeout_sec
_tool_timeout_sec: ClassVar[float | None] = tool_timeout_sec
_sampling_enabled: ClassVar[bool] = sampling_enabled
@ -403,6 +411,7 @@ def create_mcp_stdio_proxy_tool_class(
self._remote_name,
payload,
env=self._env,
cwd=self._cwd,
startup_timeout_sec=self._startup_timeout_sec,
tool_timeout_sec=self._tool_timeout_sec,
sampling_callback=sampling_callback,

View file

@ -491,7 +491,7 @@ class MessageList(Sequence[LLMMessage]):
Called from a background thread during deferred init. A single
list-item assignment is atomic under CPython's GIL, and the
``_init_complete`` event ensures no ``act()`` call reads the
``@requires_init`` decorator ensures no ``act()`` call reads the
prompt concurrently, so no additional lock is needed here.
"""
self._data[0] = LLMMessage(role=Role.system, content=new)

View file

@ -18,6 +18,16 @@ class MergeConflictError(Exception):
self.field_name = field_name
class MergeKeyError(KeyError):
"""Raised when a UNION merge item is missing the expected merge key."""
def __init__(self, key: str, item: Any) -> None:
msg = f"UNION merge key {key!r} not found in item {item!r}"
super().__init__(msg)
self.key = key
self.item = item
class MergeStrategy(StrEnum):
REPLACE = auto()
CONCAT = auto()
@ -81,10 +91,11 @@ class MergeStrategy(StrEnum):
f"UNION requires list operands, got {type(base).__name__} and {type(override).__name__}"
)
merged: dict[str, Any] = {}
for item in base:
merged[key_fn(item)] = item
for item in override:
merged[key_fn(item)] = item
for item in [*base, *override]:
try:
merged[key_fn(item)] = item
except KeyError as exc:
raise MergeKeyError(exc.args[0], item) from exc
return list(merged.values())
def _merge(self, base: Any, override: Any) -> Any:

View file

@ -1,3 +1,8 @@
# What's new in v2.7.6
# What's new in v2.8.0
- **Streaming fix**: Fixed markdown fence context loss causing streaming rendering problems
- **Builtin skills system**: Added self-awareness skill for enhanced functionality
- **Copy command**: Introduced `/copy` slash command to copy messages to clipboard
- **Git branch display**: Current git branch and GitHub PR now shown in the bottom bar
- **Diff view**: Added diff view for `write_file` overwrites in approval and result widgets
- **Exit command**: Added `exit` (without slash) to exit the application
- **Glob tool**: Added glob tool for file pattern matching