81316ffb2d
- Fix RUF006: Implement background task tracking in Discord, iMessage, WeChat, and TUI to prevent premature GC of fire-and-forget tasks. - Fix B904: Add explicit exception chaining (raise ... from) across all exception handlers. - Fix RUF012: Annotate mutable class attributes with ClassVar for command arguments and media maps. - Fix B008: Refactor Typer commands in cli/commands.py to use Annotated for argument and option defaults. - Fix B023/B018: Resolve late-binding issues in lambdas and remove useless expressions. - Fix syntax errors in retry.py docstrings and models.py lambda parameter ordering.
332 lines
11 KiB
Python
332 lines
11 KiB
Python
"""Inline MCP server browser widget for /install-mcp in TUI.
|
|
|
|
Two-phase keyboard-driven widget (mirrors SkillBrowserWidget):
|
|
Phase 1 — tag picker (arrow keys + Enter to select, or Esc for all)
|
|
Phase 2 — server checkbox (arrow keys to navigate, Space to toggle, Enter to confirm)
|
|
|
|
Posts ``MCPBrowserWidget.Confirmed`` with selected MCPServerEntry objects,
|
|
or ``MCPBrowserWidget.Cancelled`` on Esc.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING, Any, ClassVar
|
|
|
|
from rich.text import Text
|
|
from textual.binding import Binding, BindingType
|
|
from textual.containers import Container
|
|
from textual.message import Message
|
|
from textual.widget import Widget
|
|
from textual.widgets import Static
|
|
|
|
if TYPE_CHECKING:
|
|
from textual import events
|
|
from textual.app import ComposeResult
|
|
|
|
from ...mcp.registry import MCPServerEntry
|
|
|
|
|
|
class MCPBrowserWidget(Widget):
|
|
"""Inline MCP server browser — mounts in chat, keyboard-driven.
|
|
|
|
Phase 1: Tag picker (select a tag filter or "All").
|
|
Phase 2: Server checkbox (toggle servers, confirm to install).
|
|
"""
|
|
|
|
can_focus = True
|
|
can_focus_children = False
|
|
|
|
DEFAULT_CSS = """
|
|
MCPBrowserWidget {
|
|
height: auto;
|
|
max-height: 30;
|
|
margin: 1 0;
|
|
padding: 0 1;
|
|
background: $surface;
|
|
border: solid $primary;
|
|
}
|
|
MCPBrowserWidget .browser-title {
|
|
height: 1;
|
|
text-style: bold;
|
|
color: $primary;
|
|
}
|
|
MCPBrowserWidget .browser-rows {
|
|
height: auto;
|
|
max-height: 20;
|
|
overflow-y: auto;
|
|
}
|
|
MCPBrowserWidget .browser-row {
|
|
height: 1;
|
|
padding: 0 1;
|
|
}
|
|
MCPBrowserWidget .browser-row-selected {
|
|
background: $primary;
|
|
text-style: bold;
|
|
}
|
|
MCPBrowserWidget .browser-help {
|
|
height: 1;
|
|
color: $text-muted;
|
|
text-style: italic;
|
|
}
|
|
"""
|
|
|
|
BINDINGS: ClassVar[list[BindingType]] = [
|
|
Binding("up", "move_up", "Up", show=False),
|
|
Binding("k", "move_up", "Up", show=False),
|
|
Binding("down", "move_down", "Down", show=False),
|
|
Binding("j", "move_down", "Down", show=False),
|
|
Binding("enter", "confirm", "Confirm", show=False),
|
|
Binding("space", "toggle", "Toggle", show=False),
|
|
Binding("escape", "cancel", "Cancel", show=False),
|
|
]
|
|
|
|
class Confirmed(Message):
|
|
"""Posted when user confirms server selection."""
|
|
|
|
def __init__(self, entries: list[MCPServerEntry]) -> None:
|
|
super().__init__()
|
|
self.entries = entries
|
|
|
|
class Cancelled(Message):
|
|
"""Posted when user cancels."""
|
|
|
|
def __init__(
|
|
self,
|
|
servers: list[MCPServerEntry],
|
|
installed_names: set[str],
|
|
*,
|
|
pre_filter_tag: str = "",
|
|
**kwargs: Any,
|
|
) -> None:
|
|
super().__init__(**kwargs)
|
|
self._servers = servers
|
|
self._installed_names = installed_names
|
|
self._pre_filter_tag = pre_filter_tag.lower()
|
|
self._selected = 0
|
|
self._row_widgets: list[Static] = []
|
|
self._title_widget: Static | None = None
|
|
self._help_widget: Static | None = None
|
|
|
|
# Phase 1: tag picker
|
|
# Phase 2: server checkbox
|
|
self._phase: int = 1
|
|
self._tag_items: list[tuple[str, int]] = []
|
|
self._server_items: list[MCPServerEntry] = []
|
|
self._checked: set[int] = set()
|
|
|
|
# Build tag list
|
|
from collections import Counter
|
|
|
|
tag_counter: Counter[str] = Counter()
|
|
for s in self._servers:
|
|
for t in s.tags:
|
|
tag_counter[t.lower()] += 1
|
|
sorted_tags = sorted(tag_counter.items(), key=lambda x: (-x[1], x[0]))
|
|
self._tag_items = [("all", len(self._servers)), *sorted_tags]
|
|
|
|
# If pre-filtered, skip to phase 2
|
|
if self._pre_filter_tag:
|
|
self._server_items = [
|
|
s
|
|
for s in self._servers
|
|
if self._pre_filter_tag in [t.lower() for t in s.tags]
|
|
]
|
|
if self._server_items:
|
|
self._phase = 2
|
|
else:
|
|
self._pre_filter_tag = ""
|
|
|
|
def compose(self) -> ComposeResult:
|
|
self._title_widget = Static("", classes="browser-title")
|
|
yield self._title_widget
|
|
with Container(classes="browser-rows"):
|
|
max_rows = max(len(self._tag_items), len(self._servers))
|
|
for _ in range(max_rows):
|
|
widget = Static("", classes="browser-row")
|
|
self._row_widgets.append(widget)
|
|
yield widget
|
|
self._help_widget = Static("", classes="browser-help")
|
|
yield self._help_widget
|
|
|
|
def on_mount(self) -> None:
|
|
self.call_after_refresh(self._update_display)
|
|
self.call_later(self.focus)
|
|
|
|
def _update_display(self) -> None:
|
|
if self._phase == 1:
|
|
self._render_tag_picker()
|
|
else:
|
|
self._render_server_checkbox()
|
|
|
|
def _render_tag_picker(self) -> None:
|
|
if self._title_widget:
|
|
self._title_widget.update("Filter by tag:")
|
|
if self._help_widget:
|
|
self._help_widget.update(
|
|
"\u2191/\u2193 navigate \u00b7 Enter select \u00b7 Esc cancel"
|
|
)
|
|
|
|
for i, widget in enumerate(self._row_widgets):
|
|
if i < len(self._tag_items):
|
|
tag, count = self._tag_items[i]
|
|
is_selected = i == self._selected
|
|
text = Text()
|
|
cursor = "\u25b8 " if is_selected else " "
|
|
text.append(cursor, style="bold cyan" if is_selected else "dim")
|
|
label = f"{tag} ({count})"
|
|
text.append(label, style="bold" if is_selected else "")
|
|
widget.update(text)
|
|
widget.display = True
|
|
widget.remove_class("browser-row-selected")
|
|
if is_selected:
|
|
widget.add_class("browser-row-selected")
|
|
widget.scroll_visible()
|
|
else:
|
|
widget.update("")
|
|
widget.display = False
|
|
|
|
def _row_content_width(self) -> int:
|
|
try:
|
|
w = self.size.width
|
|
if w > 0:
|
|
return w - 6
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return self.app.size.width - 10
|
|
except Exception:
|
|
return 100
|
|
|
|
def _truncate(self, desc: str, name: str, *, suffix: str = "") -> str:
|
|
overhead = 2 + 2 + len(name) + 3 + len(suffix)
|
|
max_len = max(20, self._row_content_width() - overhead)
|
|
if len(desc) <= max_len:
|
|
return desc
|
|
return desc[: max_len - 1] + "\u2026"
|
|
|
|
def _render_server_checkbox(self) -> None:
|
|
n_checked = len(
|
|
[
|
|
i
|
|
for i in self._checked
|
|
if self._server_items[i].name not in self._installed_names
|
|
]
|
|
)
|
|
if self._title_widget:
|
|
self._title_widget.update(
|
|
f"Select MCP servers to install ({n_checked} selected):"
|
|
)
|
|
if self._help_widget:
|
|
self._help_widget.update(
|
|
"\u2191/\u2193 navigate \u00b7 Space toggle \u00b7 Enter install \u00b7 Esc cancel"
|
|
)
|
|
|
|
for i, widget in enumerate(self._row_widgets):
|
|
if i < len(self._server_items):
|
|
entry = self._server_items[i]
|
|
is_selected = i == self._selected
|
|
is_installed = entry.name in self._installed_names
|
|
is_checked = i in self._checked
|
|
|
|
text = Text()
|
|
cursor = "\u25b8 " if is_selected else " "
|
|
text.append(cursor, style="bold cyan" if is_selected else "dim")
|
|
|
|
desc = entry.description or entry.label
|
|
|
|
if is_installed:
|
|
suffix = " (configured)"
|
|
desc = self._truncate(desc, entry.name, suffix=suffix)
|
|
text.append("\u2713 ", style="green")
|
|
text.append(entry.name, style="green dim")
|
|
text.append(f" \u2014 {desc}", style="dim")
|
|
text.append(suffix, style="dim italic")
|
|
elif is_checked:
|
|
desc = self._truncate(desc, entry.name)
|
|
text.append("\u25cf ", style="green bold")
|
|
text.append(entry.name, style="bold")
|
|
text.append(f" \u2014 {desc}", style="")
|
|
else:
|
|
desc = self._truncate(desc, entry.name)
|
|
text.append("\u25cb ", style="dim")
|
|
text.append(entry.name, style="bold" if is_selected else "")
|
|
text.append(f" \u2014 {desc}", style="dim")
|
|
|
|
widget.update(text)
|
|
widget.display = True
|
|
widget.remove_class("browser-row-selected")
|
|
if is_selected:
|
|
widget.add_class("browser-row-selected")
|
|
widget.scroll_visible()
|
|
else:
|
|
widget.update("")
|
|
widget.display = False
|
|
|
|
def _current_items_count(self) -> int:
|
|
if self._phase == 1:
|
|
return len(self._tag_items)
|
|
return len(self._server_items)
|
|
|
|
def action_move_up(self) -> None:
|
|
n = self._current_items_count()
|
|
if not n:
|
|
return
|
|
self._selected = (self._selected - 1) % n
|
|
self._update_display()
|
|
|
|
def action_move_down(self) -> None:
|
|
n = self._current_items_count()
|
|
if not n:
|
|
return
|
|
self._selected = (self._selected + 1) % n
|
|
self._update_display()
|
|
|
|
def action_toggle(self) -> None:
|
|
if self._phase != 2:
|
|
return
|
|
if not self._server_items:
|
|
return
|
|
entry = self._server_items[self._selected]
|
|
if entry.name in self._installed_names:
|
|
return
|
|
if self._selected in self._checked:
|
|
self._checked.discard(self._selected)
|
|
else:
|
|
self._checked.add(self._selected)
|
|
self._update_display()
|
|
|
|
def action_confirm(self) -> None:
|
|
if self._phase == 1:
|
|
if not self._tag_items:
|
|
return
|
|
tag, _ = self._tag_items[self._selected]
|
|
if tag == "all":
|
|
self._server_items = list(self._servers)
|
|
else:
|
|
self._server_items = [
|
|
s for s in self._servers if tag in [t.lower() for t in s.tags]
|
|
]
|
|
self._phase = 2
|
|
self._selected = 0
|
|
self._checked = set()
|
|
self._update_display()
|
|
else:
|
|
entries = [
|
|
self._server_items[i]
|
|
for i in sorted(self._checked)
|
|
if self._server_items[i].name not in self._installed_names
|
|
]
|
|
self.post_message(self.Confirmed(entries))
|
|
|
|
def action_cancel(self) -> None:
|
|
if self._phase == 2 and not self._pre_filter_tag:
|
|
self._phase = 1
|
|
self._selected = 0
|
|
self._checked = set()
|
|
self._update_display()
|
|
else:
|
|
self.post_message(self.Cancelled())
|
|
|
|
def on_blur(self, event: events.Blur) -> None:
|
|
self.call_after_refresh(self.focus)
|