Co-authored-by: Clément Sirieix <clement.sirieix@mistral.ai>
Co-authored-by: Kim-Adeline Miguel <51720070+kimadeline@users.noreply.github.com>
Co-authored-by: Mathias Gesbert <mathias.gesbert@mistral.ai>
Co-authored-by: Maxime Dolores <maxime.dolores@ext.mistral.ai>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Paul Cacheux <paul.cacheux@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com>
Co-authored-by: angelapopopo <angele.lenglemetz@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Pierre Rossinès 2026-04-16 11:10:26 +02:00 committed by GitHub
parent e1a25caa52
commit 95336528f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 3992 additions and 403 deletions

View file

@ -95,6 +95,11 @@ class CommandRegistry:
description="Display available MCP servers. Pass the name of a server 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

@ -126,6 +126,7 @@ from vibe.core.session.resume_sessions import (
short_session_id,
)
from vibe.core.session.session_loader import SessionLoader
from vibe.core.skills.manager import SkillManager
from vibe.core.teleport.types import (
TeleportAuthCompleteEvent,
TeleportAuthRequiredEvent,
@ -144,6 +145,8 @@ 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.permissions import RequiredPermission
from vibe.core.transcribe import make_transcribe_client
from vibe.core.types import (
@ -161,7 +164,6 @@ from vibe.core.utils import (
get_user_cancellation_message,
is_dangerous_directory,
)
from vibe.core.utils.io import read_safe
class BottomApp(StrEnum):
@ -332,6 +334,8 @@ 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
@ -376,10 +380,17 @@ class VibeApp(App): # noqa: PLR0904
def config(self) -> VibeConfig:
return self.agent_loop.config
@property
def _connectors_enabled(self) -> bool:
return connectors_enabled() and self.agent_loop.connector_registry is not None
def compose(self) -> ComposeResult:
with ChatScroll(id="chat"):
self._banner = Banner(
self.config, self.agent_loop.skill_manager, self.agent_loop.mcp_registry
config=self.config,
skill_manager=self.agent_loop.skill_manager,
mcp_registry=self.agent_loop.mcp_registry,
connector_registry=self.agent_loop.connector_registry,
)
yield self._banner
yield VerticalGroup(id="messages")
@ -774,25 +785,11 @@ class VibeApp(App): # noqa: PLR0904
]
async def _handle_skill(self, user_input: str) -> bool:
if not user_input.startswith("/"):
return False
if not self.agent_loop:
return False
parts = user_input[1:].strip().split(None, 1)
if not parts:
return False
skill_name = parts[0].lower()
skill_info = self.agent_loop.skill_manager.get_skill(skill_name)
if not skill_info:
return False
self.agent_loop.telemetry_client.send_slash_command_used(skill_name, "skill")
try:
skill_content = read_safe(skill_info.skill_path).text
skill = self.agent_loop.skill_manager.parse_skill_command(user_input)
except OSError as e:
await self._mount_and_scroll(
ErrorMessage(
@ -801,10 +798,12 @@ class VibeApp(App): # noqa: PLR0904
)
return True
if len(parts) > 1:
skill_content = f"{user_input}\n\n{skill_content}"
if skill is None:
return False
await self._handle_user_message(skill_content)
self.agent_loop.telemetry_client.send_slash_command_used(skill.name, "skill")
prompt = SkillManager.build_skill_prompt(user_input, skill)
await self._handle_user_message(prompt)
return True
async def _handle_bash_command(self, command: str) -> None:
@ -1038,11 +1037,14 @@ class VibeApp(App): # noqa: PLR0904
return
if before is not None:
await messages_area.mount_all(widgets, before=before)
return
if after is not None:
elif after is not None:
await messages_area.mount_all(widgets, after=after)
return
await messages_area.mount_all(widgets)
else:
await messages_area.mount_all(widgets)
for widget in widgets:
if isinstance(widget, StreamingMessageBase):
await widget.write_initial_content()
def _is_tool_enabled_in_main_agent(self, tool: str) -> bool:
return tool in self.agent_loop.tool_manager.available_tools
@ -1328,19 +1330,36 @@ class VibeApp(App): # noqa: PLR0904
async def _show_mcp(self, cmd_args: str = "", **kwargs: Any) -> None:
mcp_servers = self.config.mcp_servers
if not mcp_servers:
await self._mount_and_scroll(
UserCommandMessage("No MCP servers configured.")
connector_registry = (
self.agent_loop.connector_registry if self._connectors_enabled else None
)
has_connectors = (
connector_registry is not None and connector_registry.connector_count > 0
)
if not mcp_servers and not has_connectors:
msg = (
"No MCP servers or connectors configured."
if self._connectors_enabled
else "No MCP servers configured."
)
await self._mount_and_scroll(UserCommandMessage(msg))
return
if self._current_bottom_app == BottomApp.MCP:
return
name = cmd_args.strip()
if name and not any(s.name == name for s in mcp_servers):
connector_names = (
connector_registry.get_connector_names() if connector_registry else []
)
if (
name
and not any(s.name == name for s in mcp_servers)
and name not in connector_names
):
all_names = [s.name for s in mcp_servers] + connector_names
entity = "MCP server or connector" if has_connectors else "MCP server"
await self._mount_and_scroll(
ErrorMessage(
f"Unknown MCP server: {name}. Known servers: "
+ ", ".join(s.name for s in mcp_servers),
f"Unknown {entity}: {name}. Known: " + ", ".join(all_names),
collapsed=self._tools_collapsed,
)
)
@ -1351,9 +1370,52 @@ class VibeApp(App): # noqa: PLR0904
mcp_servers=mcp_servers,
tool_manager=self.agent_loop.tool_manager,
initial_server=name,
connector_registry=connector_registry,
)
)
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
@ -1598,7 +1660,8 @@ class VibeApp(App): # noqa: PLR0904
base_config,
self.agent_loop.skill_manager,
self.agent_loop.mcp_registry,
plan_title(self._plan_info),
connector_registry=self.agent_loop.connector_registry,
plan_description=plan_title(self._plan_info),
)
await self._mount_and_scroll(
UserCommandMessage(
@ -2314,7 +2377,8 @@ class VibeApp(App): # noqa: PLR0904
self.config,
self.agent_loop.skill_manager,
self.agent_loop.mcp_registry,
plan_title(self._plan_info),
connector_registry=self.agent_loop.connector_registry,
plan_description=plan_title(self._plan_info),
)
def _update_profile_widgets(self, profile: AgentProfile) -> None:

View file

@ -13,14 +13,24 @@ from vibe.cli.textual_ui.widgets.banner.petit_chat import PetitChat
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.config import VibeConfig
from vibe.core.skills.manager import SkillManager
from vibe.core.tools.connectors import ConnectorRegistry
from vibe.core.tools.mcp.registry import MCPRegistry
def _pluralize(count: int, singular: str) -> str:
return f"{count} {singular}{'s' if count != 1 else ''}"
def _connector_count(registry: ConnectorRegistry | None) -> int:
return registry.connector_count if registry else 0
@dataclass
class BannerState:
active_model: str = ""
models_count: int = 0
mcp_servers_count: int = 0
connectors_count: int = 0
skills_count: int = 0
plan_description: str | None = None
@ -33,6 +43,7 @@ class Banner(Static):
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
connector_registry: ConnectorRegistry | None = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -41,6 +52,7 @@ class Banner(Static):
active_model=config.active_model,
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),
plan_description=None,
)
@ -85,22 +97,25 @@ class Banner(Static):
config: VibeConfig,
skill_manager: SkillManager,
mcp_registry: MCPRegistry,
connector_registry: ConnectorRegistry | None = None,
plan_description: str | None = None,
) -> None:
self.state = BannerState(
active_model=config.active_model,
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),
plan_description=plan_description,
)
def _format_meta_counts(self) -> str:
return (
f"{self.state.models_count} model{'s' if self.state.models_count != 1 else ''}"
f" · {self.state.mcp_servers_count} MCP server{'s' if self.state.mcp_servers_count != 1 else ''}"
f" · {self.state.skills_count} skill{'s' if self.state.skills_count != 1 else ''}"
)
parts = [_pluralize(self.state.models_count, "model")]
if self.state.connectors_count > 0:
parts.append(_pluralize(self.state.connectors_count, "connector"))
parts.append(_pluralize(self.state.mcp_servers_count, "MCP server"))
parts.append(_pluralize(self.state.skills_count, "skill"))
return " · ".join(parts)
def _format_plan(self) -> str:
return (

View file

@ -31,8 +31,6 @@ class ChatTextArea(TextArea):
show=False,
priority=True,
),
Binding("alt+left", "cursor_word_left", "Cursor word left", show=False),
Binding("alt+right", "cursor_word_right", "Cursor word right", show=False),
Binding("ctrl+g", "open_external_editor", "External Editor", show=False),
]

View file

@ -13,6 +13,7 @@ from textual.widgets import OptionList
from textual.widgets.option_list import Option
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.tools.connectors import ConnectorRegistry, connectors_enabled
from vibe.core.tools.mcp.tools import MCPTool
if TYPE_CHECKING:
@ -22,26 +23,34 @@ if TYPE_CHECKING:
class MCPToolIndex(NamedTuple):
server_tools: dict[str, list[tuple[str, type[MCPTool]]]]
connector_tools: dict[str, list[tuple[str, type[MCPTool]]]]
enabled_tools: dict[str, type[Any]]
def collect_mcp_tool_index(
mcp_servers: Sequence[MCPServer], tool_manager: ToolManager
mcp_servers: Sequence[MCPServer],
tool_manager: ToolManager,
connector_names: Sequence[str] = (),
) -> MCPToolIndex:
registered = tool_manager.registered_tools
available = tool_manager.available_tools
configured_servers = {server.name for server in mcp_servers}
connector_set = set(connector_names) if connectors_enabled() else set()
server_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
connector_tools: dict[str, list[tuple[str, type[MCPTool]]]] = {}
for tool_name, cls in registered.items():
if not issubclass(cls, MCPTool):
continue
server_name = cls.get_server_name()
if server_name is None or server_name not in configured_servers:
if server_name is None:
continue
server_tools.setdefault(server_name, []).append((tool_name, cls))
if cls.is_connector() and server_name in connector_set:
connector_tools.setdefault(server_name, []).append((tool_name, cls))
elif server_name in configured_servers:
server_tools.setdefault(server_name, []).append((tool_name, cls))
return MCPToolIndex(server_tools, enabled_tools=available)
return MCPToolIndex(server_tools, connector_tools, enabled_tools=available)
class MCPApp(Container):
@ -59,12 +68,21 @@ class MCPApp(Container):
mcp_servers: Sequence[MCPServer],
tool_manager: ToolManager,
initial_server: str = "",
connector_registry: ConnectorRegistry | None = None,
) -> None:
super().__init__(id="mcp-app")
self._mcp_servers = mcp_servers
self._connector_registry = connector_registry
connector_names = (
connector_registry.get_connector_names() if connector_registry else []
)
self._connector_names = connector_names
self._tool_manager = tool_manager
self._index = collect_mcp_tool_index(mcp_servers, tool_manager)
self._index = collect_mcp_tool_index(mcp_servers, tool_manager, connector_names)
# Track both the name and the kind ("server" or "connector") to
# disambiguate entries that share the same normalised name.
self._viewing_server: str | None = initial_server.strip() or None
self._viewing_kind: str | None = None
def compose(self) -> ComposeResult:
with Vertical(id="mcp-content"):
@ -80,8 +98,12 @@ class MCPApp(Container):
def refresh_index(self) -> None:
"""Re-snapshot the tool index (e.g. after deferred MCP discovery)."""
self._index = collect_mcp_tool_index(self._mcp_servers, self._tool_manager)
self._refresh_view(self._viewing_server)
if self._connector_registry:
self._connector_names = self._connector_registry.get_connector_names()
self._index = collect_mcp_tool_index(
self._mcp_servers, self._tool_manager, self._connector_names
)
self._refresh_view(self._viewing_server, kind=self._viewing_kind)
def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus()
@ -89,7 +111,9 @@ class MCPApp(Container):
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
option_id = event.option.id or ""
if option_id.startswith("server:"):
self._refresh_view(option_id.removeprefix("server:"))
self._refresh_view(option_id.removeprefix("server:"), kind="server")
elif option_id.startswith("connector:"):
self._refresh_view(option_id.removeprefix("connector:"), kind="connector")
def action_back(self) -> None:
if self._viewing_server is not None:
@ -98,50 +122,150 @@ class MCPApp(Container):
def action_close(self) -> None:
self.post_message(self.MCPClosed())
def _refresh_view(self, server_name: str | None) -> None:
# ── list view ────────────────────────────────────────────────────
def _refresh_view(
self, server_name: str | None, *, kind: str | None = None
) -> None:
index = self._index
option_list = self.query_one(OptionList)
option_list.clear_options()
server_names = {s.name for s in self._mcp_servers}
if server_name is None or server_name not in server_names:
self._viewing_server = None
self.query_one("#mcp-title", NoMarkupStatic).update("MCP Servers")
self.query_one("#mcp-help", NoMarkupStatic).update(
"↑↓ Navigate Enter Show tools Esc Close"
all_names = server_names | set(self._connector_names)
if server_name is None or server_name not in all_names:
self._show_list_view(option_list, index)
return
# Infer kind when not provided (e.g. initial_server from /mcp <name>).
# Prefer server over connector when the name is ambiguous.
if kind is None:
if server_name in server_names:
kind = "server"
else:
kind = "connector"
self._show_detail_view(server_name, option_list, index, kind=kind)
def _show_list_view(self, option_list: OptionList, index: MCPToolIndex) -> None:
self._viewing_server = None
self._viewing_kind = None
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"
)
has_servers = bool(self._mcp_servers)
# ── Local MCP Servers ──
if has_servers:
max_name = max(len(srv.name) for srv in self._mcp_servers)
max_type = max(len(srv.transport) + 2 for srv in self._mcp_servers) # +[]
option_list.add_option(
Option(
Text("Local MCP Servers", style="bold", no_wrap=True), disabled=True
)
)
for srv in self._mcp_servers:
tools = index.server_tools.get(srv.name, [])
enabled = sum(1 for t, _ in tools if t in index.enabled_tools)
status = _server_status(enabled)
type_tag = f"[{srv.transport}]"
label = Text(no_wrap=True)
label.append(srv.name)
label.append(f" [{srv.transport}] {status}")
label.append(f" {srv.name:<{max_name}}")
label.append(f" {type_tag:<{max_type}}", style="dim")
label.append(f" {_tool_count_text(enabled)}", style="dim")
option_list.add_option(Option(label, id=f"server:{srv.name}"))
if self._mcp_servers:
option_list.highlighted = 0
return
# ── 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,
)
)
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 = (
"No MCP servers or connectors configured"
if connectors_enabled()
else "No MCP servers configured"
)
option_list.add_option(Option(empty_msg, disabled=True))
if has_servers or has_connectors:
# Skip disabled header options (e.g. section labels).
first_enabled = next(
(i for i, opt in enumerate(option_list._options) if not opt.disabled), 0
)
option_list.highlighted = first_enabled
# ── detail view ──────────────────────────────────────────────────
def _show_detail_view(
self,
server_name: str,
option_list: OptionList,
index: MCPToolIndex,
*,
kind: str = "server",
) -> None:
self._viewing_server = server_name
self._viewing_kind = kind
is_connector = kind == "connector"
title_prefix = "Connector" if is_connector else "MCP Server"
self.query_one("#mcp-title", NoMarkupStatic).update(
f"MCP Server: {server_name}"
f"{title_prefix}: {server_name}"
)
self.query_one("#mcp-help", NoMarkupStatic).update(
"↑↓ Navigate Backspace Back Esc Close"
)
enabled_tools = [
(tool_name, cls)
for tool_name, cls in sorted(
index.server_tools.get(server_name, []), key=lambda t: t[0]
)
if tool_name in index.enabled_tools
]
if not enabled_tools:
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]
if not visible_tools:
option_list.add_option(
Option("No enabled tools for this server", disabled=True)
)
return
for tool_name, cls in enabled_tools:
for tool_name, cls in visible_tools:
remote_name = cls.get_remote_name()
raw_desc = (
(cls.description or "").removeprefix(f"[{server_name}] ").split("\n")[0]
@ -151,12 +275,12 @@ class MCPApp(Container):
if raw_desc:
label.append(f" - {raw_desc}")
option_list.add_option(Option(label, id=f"tool:{tool_name}"))
if enabled_tools:
if visible_tools:
option_list.highlighted = 0
def _server_status(enabled: int) -> str:
if enabled == 0:
return "unavailable"
noun = "tool" if enabled == 1 else "tools"
return f"{enabled} {noun} enabled"
def _tool_count_text(count: int) -> str:
if count == 0:
return "no tools"
noun = "tool" if count == 1 else "tools"
return f"{count} {noun}"

View file

@ -149,9 +149,7 @@ class AssistantMessage(StreamingMessageBase):
self.add_class("assistant-message")
def compose(self) -> ComposeResult:
if self._content:
self._content_initialized = True
markdown = Markdown(self._content)
markdown = Markdown("")
self._markdown = markdown
yield markdown

View file

@ -326,7 +326,27 @@ class QuestionApp(Container):
def _switch_question(self, new_idx: int) -> None:
self.current_question_idx = new_idx
self.selected_option = 0
self.selected_option = self._restore_cursor(new_idx)
def _restore_cursor(self, question_idx: int) -> int:
"""Return the option index to restore the cursor to for a previously answered question."""
q = self.questions[question_idx]
if q.multi_select:
if selections := self.multi_selections.get(question_idx):
return min(selections)
return 0
if question_idx not in self.answers:
return 0
answer_text, is_other = self.answers[question_idx]
if is_other:
return len(q.options)
return next(
(i for i, opt in enumerate(q.options) if opt.label == answer_text), 0
)
def action_next_question(self) -> None:
if self._is_other_selected:
@ -427,7 +447,32 @@ class QuestionApp(Container):
elif not has_text and other_idx in selections:
selections.discard(other_idx)
def _handle_number_key(self, event: events.Key) -> bool:
"""Handle number key press to quickly select an option. Returns True if handled."""
if self.other_input and self.other_input.has_focus:
return False
if not event.character or not event.character.isdigit():
return False
option_idx = int(event.character) - 1
# Only handle valid option indices, excluding submit button
if option_idx < 0 or option_idx >= len(self._current_question.options) + (
1 if self._has_other else 0
):
return False
event.stop()
event.prevent_default()
self.selected_option = option_idx
if not (self._has_other and option_idx == self._other_option_idx):
self.action_select()
return True
def on_key(self, event: events.Key) -> None:
if self._handle_number_key(event):
return
if len(self.questions) <= 1:
return
if self.other_input and self.other_input.has_focus: