v2.9.1 (#644)
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:
parent
632ea8c032
commit
1fd7eea289
63 changed files with 3482 additions and 301 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
227
vibe/cli/textual_ui/widgets/connector_auth_app.py
Normal file
227
vibe/cli/textual_ui/widgets/connector_auth_app.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue