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
|
|
@ -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(),
|
||||
),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue