v2.8.0 (#616)
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:
parent
95336528f6
commit
04305bd77c
68 changed files with 3473 additions and 1764 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
83
vibe/cli/terminal_detect.py
Normal file
83
vibe/cli/terminal_detect.py
Normal 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
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
85
vibe/core/config/schema.py
Normal file
85
vibe/core/config/schema.py
Normal 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)
|
||||
6
vibe/core/skills/builtins/__init__.py
Normal file
6
vibe/core/skills/builtins/__init__.py
Normal 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]}
|
||||
350
vibe/core/skills/builtins/vibe.py
Normal file
350
vibe/core/skills/builtins/vibe.py
Normal 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).""",
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue