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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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