Co-authored-by: Brice Carpentier <brice.carpentier@mistral.ai>
Co-authored-by: Clément Drouin <clement.drouin@mistral.ai>
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: Lucas Marandat <31749711+lucasmrdt@users.noreply.github.com>
Co-authored-by: Michel Thomazo <51709227+michelTho@users.noreply.github.com>
Co-authored-by: Pierre Rossinès <pierre.rossines@mistral.ai>
Co-authored-by: Quentin <quentin.torroba@mistral.ai>
Co-authored-by: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
Mathias Gesbert 2026-04-29 17:20:27 +02:00 committed by GitHub
parent 632ea8c032
commit 1fd7eea289
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 3482 additions and 301 deletions

View file

@ -57,6 +57,7 @@ from vibe.cli.textual_ui.widgets.chat_input import ChatInputContainer
from vibe.cli.textual_ui.widgets.chat_input.text_area import ChatTextArea
from vibe.cli.textual_ui.widgets.compact import CompactMessage
from vibe.cli.textual_ui.widgets.config_app import ConfigApp
from vibe.cli.textual_ui.widgets.connector_auth_app import ConnectorAuthApp
from vibe.cli.textual_ui.widgets.context_progress import ContextProgress, TokenState
from vibe.cli.textual_ui.widgets.debug_console import DebugConsole
from vibe.cli.textual_ui.widgets.feedback_bar import FeedbackBar
@ -198,6 +199,7 @@ class BottomApp(StrEnum):
Approval = auto()
Config = auto()
ConnectorAuth = auto()
Input = auto()
MCP = auto()
ModelPicker = auto()
@ -808,6 +810,27 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(MCPApp).refresh_index()
self._refresh_banner()
async def on_mcpapp_connector_auth_requested(
self, message: MCPApp.ConnectorAuthRequested
) -> None:
await self._switch_to_input_app()
await self._switch_from_input(
ConnectorAuthApp(
connector_name=message.connector_name,
connector_registry=message.connector_registry,
tool_manager=message.tool_manager,
)
)
async def on_connector_auth_app_connector_auth_closed(
self, message: ConnectorAuthApp.ConnectorAuthClosed
) -> None:
if message.refreshed:
await self.agent_loop.refresh_system_prompt()
self._refresh_banner()
await self._switch_to_input_app()
await self._show_mcp(cmd_args=message.connector_name)
async def on_proxy_setup_app_proxy_setup_closed(
self, message: ProxySetupApp.ProxySetupClosed
) -> None:
@ -1336,9 +1359,9 @@ class VibeApp(App): # noqa: PLR0904
teleport_msg.set_status("Teleporting...")
case TeleportWaitingForGitHubEvent():
teleport_msg.set_status("Connecting to GitHub...")
case TeleportAuthRequiredEvent(oauth_url=url):
case TeleportAuthRequiredEvent(oauth_url=url, message=msg):
webbrowser.open(url)
teleport_msg.set_status("Authorizing GitHub...")
teleport_msg.set_status(msg or "Authorizing GitHub...")
case TeleportAuthCompleteEvent():
teleport_msg.set_status("GitHub authorized")
case TeleportFetchingUrlEvent():
@ -2059,6 +2082,8 @@ class VibeApp(App): # noqa: PLR0904
self.query_one(SessionPickerApp).focus()
case BottomApp.MCP:
self.query_one(MCPApp).focus()
case BottomApp.ConnectorAuth:
self.query_one(ConnectorAuthApp).focus()
case BottomApp.Rewind:
self.query_one(RewindApp).focus()
case BottomApp.Voice:
@ -2335,7 +2360,7 @@ class VibeApp(App): # noqa: PLR0904
self.run_worker(self._interrupt_agent_loop(), exclusive=False)
def _handle_bottom_app_close_escape(
self, widget_type: type[MCPApp] | type[ProxySetupApp]
self, widget_type: type[MCPApp] | type[ProxySetupApp] | type[ConnectorAuthApp]
) -> None:
try:
self.query_one(widget_type).action_close()
@ -2350,6 +2375,8 @@ class VibeApp(App): # noqa: PLR0904
self._handle_voice_app_escape()
elif self._current_bottom_app == BottomApp.MCP:
self._handle_bottom_app_close_escape(MCPApp)
elif self._current_bottom_app == BottomApp.ConnectorAuth:
self._handle_bottom_app_close_escape(ConnectorAuthApp)
elif self._current_bottom_app == BottomApp.ProxySetup:
self._handle_bottom_app_close_escape(ProxySetupApp)
elif self._current_bottom_app == BottomApp.Approval:

View file

@ -691,7 +691,7 @@ StatusMessage {
}
#config-app, #voice-app, #mcp-app {
#config-app, #voice-app, #mcp-app, #connectorauth-app {
width: 100%;
height: auto;
background: transparent;
@ -700,7 +700,7 @@ StatusMessage {
margin: 0;
}
#config-content, #voice-content, #mcp-content {
#config-content, #voice-content, #mcp-content, #connectorauth-content {
width: 100%;
height: auto;
}
@ -711,16 +711,20 @@ StatusMessage {
color: ansi_blue;
}
#config-options, #mcp-options {
#config-options, #mcp-options, #connectorauth-options {
width: 100%;
max-height: 50vh;
border: none;
}
#config-options:focus, #mcp-options:focus {
#config-options:focus, #mcp-options:focus, #connectorauth-options:focus {
border: none;
}
#connectorauth-detail {
margin: 1 0;
}
.settings-help {
height: auto;
color: ansi_bright_black;

View file

@ -0,0 +1,227 @@
from __future__ import annotations
from enum import StrEnum, auto
from typing import TYPE_CHECKING, ClassVar
import webbrowser
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding, BindingType
from textual.containers import Container, Vertical
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.clipboard import copy_text_to_clipboard
from vibe.cli.textual_ui.widgets.no_markup_static import NoMarkupStatic
from vibe.core.tools.connectors import ConnectorRegistry
if TYPE_CHECKING:
from vibe.core.tools.manager import ToolManager
_HELP = "Backspace Back"
_OPTION_PADDING = " "
class _AuthOptionId(StrEnum):
OPEN = auto()
COPY = auto()
SHOW = auto()
class ConnectorAuthApp(Container):
"""Bottom-panel app for authenticating a workspace connector."""
can_focus_children = True
BINDINGS: ClassVar[list[BindingType]] = [
Binding("escape", "close", "Close", show=False),
Binding("backspace", "close", "Back", show=False),
Binding("r", "refresh", "Refresh", show=False),
]
class ConnectorAuthClosed(Message):
def __init__(
self, *, refreshed: bool = False, connector_name: str = ""
) -> None:
super().__init__()
self.refreshed = refreshed
self.connector_name = connector_name
def __init__(
self,
connector_name: str,
connector_registry: ConnectorRegistry,
tool_manager: ToolManager,
) -> None:
super().__init__(id="connectorauth-app")
self._connector_name = connector_name
self._connector_registry = connector_registry
self._tool_manager = tool_manager
self._auth_url: str | None = None
self._auth_url_visible = False
self._status_message: str | None = None
def compose(self) -> ComposeResult:
with Vertical(id="connectorauth-content"):
yield NoMarkupStatic("", id="connectorauth-title", classes="settings-title")
yield NoMarkupStatic("")
yield OptionList(id="connectorauth-options")
yield NoMarkupStatic("", id="connectorauth-detail")
yield NoMarkupStatic("", id="connectorauth-help", classes="settings-help")
def on_mount(self) -> None:
self.query_one("#connectorauth-title", NoMarkupStatic).update(
f"Connector: {self._connector_name}"
)
option_list = self.query_one(OptionList)
option_list.add_option(Option("Fetching authentication info...", disabled=True))
self._set_help_text(_HELP)
option_list.focus()
self.run_worker(self._fetch_auth_url(), exclusive=True, group="auth_url")
def on_descendant_blur(self, _event: DescendantBlur) -> None:
self.query_one(OptionList).focus()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
option_id = event.option.id or ""
if option_id == _AuthOptionId.OPEN:
self._open_browser()
elif option_id == _AuthOptionId.COPY:
self._copy_url()
elif option_id == _AuthOptionId.SHOW:
self._toggle_url()
def action_close(self) -> None:
self.post_message(self.ConnectorAuthClosed())
async def action_refresh(self) -> None:
self._status_message = "Refreshing connector..."
self._set_help_text(_HELP)
self.run_worker(
self._refresh_connector(), exclusive=True, group="connector_refresh"
)
# ── workers ──────────────────────────────────────────────────────
async def _fetch_auth_url(self) -> str | None:
return await self._connector_registry.get_auth_url(self._connector_name)
async def _refresh_connector(self) -> int:
"""Refresh connector tools. Returns the number of tools discovered."""
from vibe.core.tools.manager import ToolManager
new_tools = await self._connector_registry.refresh_connector_async(
self._connector_name
)
if isinstance(self._tool_manager, ToolManager):
await self._tool_manager.integrate_connectors_async()
return len(new_tools)
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
if event.worker.group == "auth_url" and event.worker.is_finished:
self._on_auth_url_fetched(event.worker.result)
elif event.worker.group == "connector_refresh" and event.worker.is_finished:
tool_count = (
event.worker.result if isinstance(event.worker.result, int) else 0
)
self._on_connector_refreshed(tool_count)
# ── auth UI ──────────────────────────────────────────────────────
def _on_auth_url_fetched(self, result: object) -> None:
option_list = self.query_one(OptionList)
option_list.clear_options()
auth_url = result if isinstance(result, str) else None
if auth_url:
self._auth_url = auth_url
option_list.add_option(
Option(
Text("This connector requires authentication", no_wrap=True),
disabled=True,
)
)
option_list.add_option(Option("", disabled=True))
option_list.add_option(
Option(
Text(
f"{_OPTION_PADDING}Press enter to open auth in your browser",
no_wrap=True,
),
id=_AuthOptionId.OPEN,
)
)
option_list.add_option(
Option(
Text(f"{_OPTION_PADDING}Copy URL to clipboard", no_wrap=True),
id=_AuthOptionId.COPY,
)
)
option_list.add_option(
Option(
Text(f"{_OPTION_PADDING}Manually show the URL", no_wrap=True),
id=_AuthOptionId.SHOW,
)
)
option_list.highlighted = option_list.get_option_index(_AuthOptionId.OPEN)
self._update_detail_text()
else:
self.query_one("#connectorauth-detail", NoMarkupStatic).update("")
option_list.add_option(
Option("This connector does not provide authentication", disabled=True)
)
self._set_help_text(_HELP)
def _on_connector_refreshed(self, tool_count: int) -> None:
if tool_count > 0:
self._status_message = f"{tool_count} tools discovered."
self.post_message(
self.ConnectorAuthClosed(
refreshed=True, connector_name=self._connector_name
)
)
else:
self._status_message = (
"No tools discovered. Authentication may still be pending, "
"try again in a moment."
)
self._set_help_text(_HELP)
# ── actions ──────────────────────────────────────────────────────
def _open_browser(self) -> None:
if self._auth_url is None:
return
webbrowser.open(self._auth_url)
self._status_message = "Opened in browser."
self._set_help_text(_HELP)
def _copy_url(self) -> None:
if self._auth_url is None:
return
copy_text_to_clipboard(
self.app, self._auth_url, success_message="Auth URL copied to clipboard"
)
def _toggle_url(self) -> None:
if self._auth_url is None:
return
self._auth_url_visible = not self._auth_url_visible
self._update_detail_text()
# ── helpers ──────────────────────────────────────────────────────
def _update_detail_text(self) -> None:
detail = self.query_one("#connectorauth-detail", NoMarkupStatic)
parts: list[str] = []
if self._auth_url_visible and self._auth_url:
parts.append(self._auth_url)
parts.append("")
parts.append("Once authenticated, press R to refresh")
detail.update("\n".join(parts))
def _set_help_text(self, text: str) -> None:
if self._status_message:
text = f"{self._status_message} {text}"
self.query_one("#connectorauth-help", NoMarkupStatic).update(text)

View file

@ -62,9 +62,12 @@ def collect_mcp_tool_index(
return MCPToolIndex(server_tools, connector_tools, enabled_tools=available)
_LIST_VIEW_HELP = (
_LIST_VIEW_HELP_TOOLS = (
"↑↓ Navigate Enter Show tools D Disable E Enable R Refresh Esc Close"
)
_LIST_VIEW_HELP_AUTH = (
"↑↓ Navigate Enter Authenticate D Disable E Enable R Refresh Esc Close"
)
_DETAIL_VIEW_HELP = (
"↑↓ Navigate D Disable E Enable Backspace Back R Refresh Esc Close"
)
@ -99,6 +102,20 @@ class MCPApp(Container):
self.disabled = disabled
self.tool_name = tool_name
class ConnectorAuthRequested(Message):
"""Posted when a disconnected connector needs authentication."""
def __init__(
self,
connector_name: str,
connector_registry: ConnectorRegistry,
tool_manager: ToolManager,
) -> None:
super().__init__()
self.connector_name = connector_name
self.connector_registry = connector_registry
self.tool_manager = tool_manager
def __init__(
self,
mcp_servers: Sequence[MCPServer],
@ -134,7 +151,6 @@ class MCPApp(Container):
yield NoMarkupStatic("", id="mcp-title", classes="settings-title")
yield NoMarkupStatic("")
yield OptionList(id="mcp-options")
yield NoMarkupStatic("")
yield NoMarkupStatic("", id="mcp-help", classes="settings-help")
def on_mount(self) -> None:
@ -175,6 +191,9 @@ class MCPApp(Container):
# it are disabled headers, scroll to top so the header stays visible.
if all(option_list.get_option_at_index(i).disabled for i in range(highlighted)):
option_list.scroll_to(y=0, animate=False, force=True, immediate=True)
# Update help text based on whether highlighted connector needs auth.
if self._viewing_server is None:
self._set_help_text(self._list_help_for_option(event.option))
def action_back(self) -> None:
if self._refreshing:
@ -192,7 +211,7 @@ class MCPApp(Container):
return
self._status_message = "Refreshing..."
help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP
help = _DETAIL_VIEW_HELP if self._viewing_server else _LIST_VIEW_HELP_TOOLS
self._set_help_text(help)
self._refreshing = True
@ -212,6 +231,15 @@ class MCPApp(Container):
self._status_message = result if isinstance(result, str) else "Refreshed."
self.refresh_index()
def _list_help_for_option(self, option: Option) -> str:
"""Return the appropriate list-view help text for the given option."""
option_id = option.id or ""
if option_id.startswith("connector:") and self._connector_registry:
name = option_id.removeprefix("connector:")
if not self._connector_registry.is_connected(name):
return _LIST_VIEW_HELP_AUTH
return _LIST_VIEW_HELP_TOOLS
def _set_help_text(self, text: str) -> None:
if self._status_message:
text = f"{self._status_message} {text}"
@ -385,7 +413,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._set_help_text(_LIST_VIEW_HELP)
self._set_help_text(_LIST_VIEW_HELP_TOOLS)
has_servers = bool(self._mcp_servers)
@ -495,9 +523,22 @@ class MCPApp(Container):
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])
if not all_tools:
option_list.add_option(
Option("No tools discovered for this server", disabled=True)
)
if (
is_connector
and self._connector_registry
and not self._connector_registry.is_connected(server_name)
):
self.post_message(
self.ConnectorAuthRequested(
connector_name=server_name,
connector_registry=self._connector_registry,
tool_manager=self._tool_manager,
)
)
else:
option_list.add_option(
Option("No tools discovered for this server", disabled=True)
)
return
for tool_name, cls in all_tools:
is_tool_enabled = tool_name in index.enabled_tools