v2.7.6 (#600)
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:
parent
e1a25caa52
commit
95336528f6
59 changed files with 3992 additions and 403 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue