zjcode 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,1634 @@
|
|
|
1
|
+
"""Read-only MCP server and tool viewer modal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING, Any, ClassVar, assert_never
|
|
7
|
+
|
|
8
|
+
from textual.binding import Binding, BindingType
|
|
9
|
+
from textual.containers import Vertical, VerticalScroll
|
|
10
|
+
from textual.content import Content
|
|
11
|
+
from textual.events import (
|
|
12
|
+
Click, # noqa: TC002 - needed at runtime for Textual event dispatch
|
|
13
|
+
)
|
|
14
|
+
from textual.screen import ModalScreen
|
|
15
|
+
from textual.widgets import Input, Static
|
|
16
|
+
|
|
17
|
+
from deepagents_code.clipboard import copy_text_to_clipboard
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Awaitable, Callable
|
|
21
|
+
|
|
22
|
+
from textual.app import ComposeResult
|
|
23
|
+
|
|
24
|
+
from deepagents_code.mcp_tools import MCPServerInfo, MCPServerStatus, MCPToolInfo
|
|
25
|
+
|
|
26
|
+
from deepagents_code import theme
|
|
27
|
+
from deepagents_code.config import Glyphs, get_glyphs, is_ascii_mode
|
|
28
|
+
from deepagents_code.unicode_security import sanitize_control_chars
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
MCP_VIEWER_RECONNECT_REQUEST = "\x00__mcp_reconnect__"
|
|
33
|
+
"""Sentinel returned by `MCPViewerScreen.dismiss` to request a reconnect.
|
|
34
|
+
|
|
35
|
+
The null-byte prefix makes the value un-collidable with any valid MCP
|
|
36
|
+
server name returned by `MCPServerInfo.name`, so callers can branch on
|
|
37
|
+
this exact string without weakening the existing server-name dispatch.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
MCP_RECONNECT_KEY = "ctrl+r"
|
|
41
|
+
"""Textual `Binding` key for the in-viewer reconnect action.
|
|
42
|
+
|
|
43
|
+
Kept as a module constant so the footer hint and the help-text rendered
|
|
44
|
+
in server headers stay in sync with the bound chord.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
MCP_RECONNECT_KEY_LABEL = "Ctrl+R"
|
|
48
|
+
"""Display label for `MCP_RECONNECT_KEY`.
|
|
49
|
+
|
|
50
|
+
Shown in the footer hint chip and inline header prompts so the user
|
|
51
|
+
sees the same chord text the binding will fire on.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _status_glyph(status: MCPServerStatus, glyphs: Glyphs) -> str:
|
|
56
|
+
"""Return the glyph character for a server `status`.
|
|
57
|
+
|
|
58
|
+
Maps onto the existing `Glyphs` set so ASCII fallback is automatic
|
|
59
|
+
(`✓ ⚠ ✗` -> `[OK] [!] [X]`). No new glyph definitions needed.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
status: One of `ok` / `unauthenticated` / `awaiting_reconnect` /
|
|
63
|
+
`error` / `disabled`.
|
|
64
|
+
glyphs: Active `Glyphs` table (Unicode or ASCII).
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The unicode or ASCII glyph character matching `status`.
|
|
68
|
+
"""
|
|
69
|
+
if status == "ok":
|
|
70
|
+
return glyphs.checkmark
|
|
71
|
+
if status == "unauthenticated":
|
|
72
|
+
return glyphs.warning
|
|
73
|
+
if status == "awaiting_reconnect":
|
|
74
|
+
return glyphs.circle_empty
|
|
75
|
+
if status == "disabled":
|
|
76
|
+
return glyphs.pause
|
|
77
|
+
if status == "error":
|
|
78
|
+
return glyphs.error
|
|
79
|
+
assert_never(status)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _status_color(status: MCPServerStatus, colors: theme.ThemeColors) -> str:
|
|
83
|
+
"""Map a server `status` onto a semantic theme color.
|
|
84
|
+
|
|
85
|
+
`ok` -> success (green); `unauthenticated` -> warning (yellow);
|
|
86
|
+
`error` -> error (red); `disabled` -> muted. Returning the theme's
|
|
87
|
+
hex string lets callers pass the value to `Content.styled()` or
|
|
88
|
+
`Content.assemble()` so a theme switch recolors the indicator
|
|
89
|
+
without code changes.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
status: One of `ok` / `unauthenticated` / `awaiting_reconnect` /
|
|
93
|
+
`error` / `disabled`.
|
|
94
|
+
colors: Active theme palette (typically from `theme.get_theme_colors`).
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Hex color string from the active theme.
|
|
98
|
+
"""
|
|
99
|
+
if status == "ok":
|
|
100
|
+
return colors.success
|
|
101
|
+
if status == "unauthenticated":
|
|
102
|
+
return colors.warning
|
|
103
|
+
if status == "awaiting_reconnect":
|
|
104
|
+
return colors.warning
|
|
105
|
+
if status == "disabled":
|
|
106
|
+
return colors.muted
|
|
107
|
+
if status == "error":
|
|
108
|
+
return colors.error
|
|
109
|
+
assert_never(status)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _styled(inner: str, style: str) -> str:
|
|
113
|
+
"""Wrap a `Content.from_markup` template fragment in `[style]…[/]` if needed.
|
|
114
|
+
|
|
115
|
+
Centralizes the `'[' + style + ']…[/]' if style else …` pattern that the
|
|
116
|
+
three formatter methods would otherwise repeat five times.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
inner: Template fragment (may contain `$var` substitutions).
|
|
120
|
+
style: Active style string; empty string means render unstyled.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
`inner` wrapped in `[style]…[/]` when `style` is truthy, otherwise
|
|
124
|
+
`inner` unchanged.
|
|
125
|
+
"""
|
|
126
|
+
return f"[{style}]{inner}[/]" if style else inner
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _format_prop_type(prop_type: Any) -> str: # noqa: ANN401 - JSON Schema field is intentionally untyped
|
|
130
|
+
"""Render a JSON Schema `type` field for parameter display.
|
|
131
|
+
|
|
132
|
+
JSON Schema allows `type` to be a string (`"string"`) or a list of
|
|
133
|
+
strings (`["string", "null"]` for nullable types). Plain `str()` on a
|
|
134
|
+
list produces an ugly Python repr; we join with `|` instead.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
prop_type: The raw value of the schema's `type` field.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Display-friendly type string. `"any"` when `prop_type` is missing
|
|
141
|
+
or not coercible to a meaningful string.
|
|
142
|
+
"""
|
|
143
|
+
if prop_type is None:
|
|
144
|
+
return "any"
|
|
145
|
+
if isinstance(prop_type, list):
|
|
146
|
+
parts = [str(t) for t in prop_type if t]
|
|
147
|
+
return "|".join(parts) if parts else "any"
|
|
148
|
+
return str(prop_type) or "any"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
_INLINE_TEXT_LIMIT = 200
|
|
152
|
+
"""Max characters for untrusted text rendered inline in a server header.
|
|
153
|
+
|
|
154
|
+
Bounds a hostile or buggy server's error/name so it cannot overflow the
|
|
155
|
+
single-line header; full text is available in the error-detail modal.
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _sort_servers_for_display(
|
|
160
|
+
server_info: list[MCPServerInfo],
|
|
161
|
+
) -> list[MCPServerInfo]:
|
|
162
|
+
"""Return `server_info` with attention-needed servers floated to the top.
|
|
163
|
+
|
|
164
|
+
Stable sort so the user's config order is preserved within each group.
|
|
165
|
+
Surfacing unauthenticated and awaiting-reconnect servers first makes
|
|
166
|
+
the next action visible without scrolling on configs with many `ok`
|
|
167
|
+
servers.
|
|
168
|
+
"""
|
|
169
|
+
priority = {"unauthenticated": 0, "awaiting_reconnect": 1}
|
|
170
|
+
return sorted(server_info, key=lambda s: priority.get(s.status, 2))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _visible_tools_for(
|
|
174
|
+
server: MCPServerInfo, tokens: list[str]
|
|
175
|
+
) -> tuple[MCPToolInfo, ...] | None:
|
|
176
|
+
"""Return the tools to render for `server` under the active filter.
|
|
177
|
+
|
|
178
|
+
Filter matches tool and server *names* only — descriptions, parameter
|
|
179
|
+
names, and the transport are deliberately not in the haystack so long
|
|
180
|
+
MCP docstrings don't produce spurious matches. A server with zero tools
|
|
181
|
+
that matches by name returns `None` so the caller can skip rendering a
|
|
182
|
+
stub header followed by the global "No matching tools" empty-state.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
server: The server whose tools are candidates for display.
|
|
186
|
+
tokens: Lower-cased filter tokens — empty means "no filter".
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
- `server.tools` when the filter is empty or matches the server name
|
|
190
|
+
and the server actually has tools.
|
|
191
|
+
- A subset tuple when individual tool names match.
|
|
192
|
+
- `None` when nothing matches, including the server-name-match case
|
|
193
|
+
on a server with zero tools — caller skips the header entirely.
|
|
194
|
+
"""
|
|
195
|
+
if not tokens:
|
|
196
|
+
return server.tools
|
|
197
|
+
|
|
198
|
+
if all(token in server.name.lower() for token in tokens):
|
|
199
|
+
return server.tools or None
|
|
200
|
+
|
|
201
|
+
matching = tuple(
|
|
202
|
+
tool
|
|
203
|
+
for tool in server.tools
|
|
204
|
+
if all(token in tool.name.lower() for token in tokens)
|
|
205
|
+
)
|
|
206
|
+
return matching or None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class MCPToolItem(Static):
|
|
210
|
+
"""A selectable tool item in the MCP viewer."""
|
|
211
|
+
|
|
212
|
+
def __init__(
|
|
213
|
+
self,
|
|
214
|
+
name: str,
|
|
215
|
+
description: str,
|
|
216
|
+
index: int,
|
|
217
|
+
*,
|
|
218
|
+
classes: str = "",
|
|
219
|
+
input_schema: dict[str, Any] | None = None,
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Initialize a tool item.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
name: Tool name.
|
|
225
|
+
description: Full tool description.
|
|
226
|
+
index: Flat index of this tool in the list.
|
|
227
|
+
classes: CSS classes.
|
|
228
|
+
input_schema: Raw MCP `inputSchema` dict; rendered as parameters
|
|
229
|
+
when the tool is expanded. `None` is treated as "no schema".
|
|
230
|
+
"""
|
|
231
|
+
self.tool_name = name
|
|
232
|
+
self.tool_description = description
|
|
233
|
+
self.index = index
|
|
234
|
+
self._input_schema = input_schema
|
|
235
|
+
self._expanded = False
|
|
236
|
+
self._selected = "mcp-tool-selected" in classes
|
|
237
|
+
# Pass a placeholder label — `_format_collapsed` reads `self.size`,
|
|
238
|
+
# which is only valid after the widget is attached to a screen.
|
|
239
|
+
# `on_mount` re-renders with width-aware truncation.
|
|
240
|
+
super().__init__(classes=classes)
|
|
241
|
+
|
|
242
|
+
def _desc_style(self) -> str:
|
|
243
|
+
"""Return the markup style tag for the description span.
|
|
244
|
+
|
|
245
|
+
Dim text on the `$primary` selection background is unreadable, so
|
|
246
|
+
selected rows drop the dim and use bold for tool names only.
|
|
247
|
+
"""
|
|
248
|
+
return "" if self._selected else "dim"
|
|
249
|
+
|
|
250
|
+
def _format_collapsed(self, name: str, description: str) -> Content:
|
|
251
|
+
"""Build the collapsed (single-line) label.
|
|
252
|
+
|
|
253
|
+
Truncates the description with `(...)` if it would overflow
|
|
254
|
+
the widget width.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
name: Tool name.
|
|
258
|
+
description: Tool description.
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
Styled Content label.
|
|
262
|
+
"""
|
|
263
|
+
if not description:
|
|
264
|
+
return Content.from_markup(" $name", name=name)
|
|
265
|
+
prefix_len = 2 + len(name) + 1
|
|
266
|
+
avail = self.size.width - prefix_len - 1 if self.size.width else 0
|
|
267
|
+
ellipsis = " (...)"
|
|
268
|
+
if avail > 0 and len(description) > avail:
|
|
269
|
+
cut = max(0, avail - len(ellipsis))
|
|
270
|
+
desc_text = description[:cut] + ellipsis
|
|
271
|
+
else:
|
|
272
|
+
desc_text = description
|
|
273
|
+
template = f" $name {_styled('$desc', self._desc_style())}"
|
|
274
|
+
return Content.from_markup(template, name=name, desc=desc_text)
|
|
275
|
+
|
|
276
|
+
def _format_expanded(self, name: str, description: str) -> Content:
|
|
277
|
+
"""Build the expanded (multi-line) label.
|
|
278
|
+
|
|
279
|
+
When `input_schema` carries a non-empty `properties` dict, append
|
|
280
|
+
a `Parameters:` block listing each parameter as `name: type` with
|
|
281
|
+
`*` for required.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
name: Tool name.
|
|
285
|
+
description: Tool description.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Styled Content label with description and parameters on
|
|
289
|
+
following lines.
|
|
290
|
+
"""
|
|
291
|
+
if description:
|
|
292
|
+
style = self._desc_style()
|
|
293
|
+
template = f" [bold]$name[/bold]\n {_styled('$desc', style)}"
|
|
294
|
+
base = Content.from_markup(template, name=name, desc=description)
|
|
295
|
+
else:
|
|
296
|
+
base = Content.from_markup(" [bold]$name[/bold]", name=name)
|
|
297
|
+
|
|
298
|
+
params = self._format_parameters()
|
|
299
|
+
return base.append(params) if params is not None else base
|
|
300
|
+
|
|
301
|
+
def _format_parameters(self) -> Content | None:
|
|
302
|
+
"""Build the parameter list rendered below the description.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
A `Content` block with one line per parameter, or `None` when
|
|
306
|
+
there is no `input_schema`, the schema is not an object with
|
|
307
|
+
non-empty `properties`, or `properties` is malformed.
|
|
308
|
+
"""
|
|
309
|
+
schema = self._input_schema
|
|
310
|
+
if not schema or not isinstance(schema, dict):
|
|
311
|
+
return None
|
|
312
|
+
properties = schema.get("properties")
|
|
313
|
+
if not isinstance(properties, dict) or not properties:
|
|
314
|
+
return None
|
|
315
|
+
required = schema.get("required") or []
|
|
316
|
+
if not isinstance(required, list):
|
|
317
|
+
required = []
|
|
318
|
+
required_set = {str(item) for item in required}
|
|
319
|
+
|
|
320
|
+
# Mirror `_desc_style`: empty when this row is selected, so the
|
|
321
|
+
# parameter block stays readable on the `$primary` selection
|
|
322
|
+
# background (CSS recolors text via `.mcp-tool-selected`). When
|
|
323
|
+
# not selected, render dim so the params sit visually below the
|
|
324
|
+
# description.
|
|
325
|
+
style = self._desc_style()
|
|
326
|
+
result = Content.from_markup("\n " + _styled("Parameters:", style))
|
|
327
|
+
line_template = "\n " + _styled("$name: $ptype$star", style)
|
|
328
|
+
for prop_name, prop_schema in properties.items():
|
|
329
|
+
prop_type = _format_prop_type(
|
|
330
|
+
prop_schema.get("type") if isinstance(prop_schema, dict) else None
|
|
331
|
+
)
|
|
332
|
+
star = " *" if str(prop_name) in required_set else ""
|
|
333
|
+
# `Content.from_markup` substitution escapes user-supplied
|
|
334
|
+
# text, so a parameter named `[bold]foo[/]` cannot inject
|
|
335
|
+
# markup tags into the output. Newlines are stripped to
|
|
336
|
+
# protect viewport-row math (smart-scroll relies on
|
|
337
|
+
# `widget.region.height` matching the rendered row count).
|
|
338
|
+
safe_name = str(prop_name).replace("\n", " ").replace("\r", " ")[:80]
|
|
339
|
+
line = Content.from_markup(
|
|
340
|
+
line_template,
|
|
341
|
+
name=safe_name,
|
|
342
|
+
ptype=prop_type,
|
|
343
|
+
star=star,
|
|
344
|
+
)
|
|
345
|
+
result = result.append(line)
|
|
346
|
+
return result
|
|
347
|
+
|
|
348
|
+
def _rerender(self) -> None:
|
|
349
|
+
"""Re-render the label with the current selected/expanded state."""
|
|
350
|
+
if self._expanded:
|
|
351
|
+
self.update(self._format_expanded(self.tool_name, self.tool_description))
|
|
352
|
+
else:
|
|
353
|
+
self.update(self._format_collapsed(self.tool_name, self.tool_description))
|
|
354
|
+
|
|
355
|
+
def set_selected(self, selected: bool) -> None:
|
|
356
|
+
"""Apply or remove the selected-row styling and re-render the label."""
|
|
357
|
+
if self._selected == selected:
|
|
358
|
+
return
|
|
359
|
+
self._selected = selected
|
|
360
|
+
if selected:
|
|
361
|
+
self.add_class("mcp-tool-selected")
|
|
362
|
+
else:
|
|
363
|
+
self.remove_class("mcp-tool-selected")
|
|
364
|
+
self._rerender()
|
|
365
|
+
|
|
366
|
+
def toggle_expand(self) -> None:
|
|
367
|
+
"""Toggle between collapsed and expanded view."""
|
|
368
|
+
self.set_expanded(not self._expanded)
|
|
369
|
+
|
|
370
|
+
def set_expanded(self, expanded: bool) -> None:
|
|
371
|
+
"""Set expansion state explicitly and re-render.
|
|
372
|
+
|
|
373
|
+
Single seam through which expansion changes flow, so the screen-level
|
|
374
|
+
`Ctrl+E` toggle-all action and the per-row `toggle_expand` share the
|
|
375
|
+
same render path. Always re-applies `styles.height` and re-renders so
|
|
376
|
+
callers do not need to know whether the state changed — the redundant
|
|
377
|
+
write is cheap and avoids drift if `styles.height` was changed
|
|
378
|
+
externally (CSS reload, theme switch, programmatic edit).
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
expanded: `True` for expanded multi-line view, `False` for
|
|
382
|
+
collapsed single-line view.
|
|
383
|
+
"""
|
|
384
|
+
self._expanded = expanded
|
|
385
|
+
self.styles.height = "auto" if expanded else 1
|
|
386
|
+
self._rerender()
|
|
387
|
+
|
|
388
|
+
def on_mount(self) -> None:
|
|
389
|
+
"""Re-render with correct truncation once width is known.
|
|
390
|
+
|
|
391
|
+
Defers via `call_after_refresh` so the first paint happens AFTER
|
|
392
|
+
the layout pass. At `on_mount` time `self.size.width` is still
|
|
393
|
+
0, which short-circuits `_format_collapsed`'s `avail > 0` guard
|
|
394
|
+
and emits the full description un-truncated for one frame. The
|
|
395
|
+
subsequent resize re-render then snaps an ellipsis in,
|
|
396
|
+
producing a visible overflow flicker on every mount (initial
|
|
397
|
+
open, filter rebuild, F2 toggle rebuild).
|
|
398
|
+
"""
|
|
399
|
+
self.call_after_refresh(self._rerender)
|
|
400
|
+
|
|
401
|
+
def on_resize(self) -> None:
|
|
402
|
+
"""Re-truncate when widget width changes."""
|
|
403
|
+
if not self._expanded:
|
|
404
|
+
self.update(self._format_collapsed(self.tool_name, self.tool_description))
|
|
405
|
+
|
|
406
|
+
def on_click(self, event: Click) -> None:
|
|
407
|
+
"""Handle click — select and toggle expand via parent screen.
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
event: The click event.
|
|
411
|
+
"""
|
|
412
|
+
event.stop()
|
|
413
|
+
screen = self.screen
|
|
414
|
+
if isinstance(screen, MCPViewerScreen):
|
|
415
|
+
screen._move_to(self.index)
|
|
416
|
+
self.toggle_expand()
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _render_server_header(
|
|
420
|
+
server: MCPServerInfo,
|
|
421
|
+
indicator_glyph: str,
|
|
422
|
+
indicator_color: str,
|
|
423
|
+
visible_tools: tuple[MCPToolInfo, ...],
|
|
424
|
+
glyphs: Glyphs,
|
|
425
|
+
*,
|
|
426
|
+
selected: bool = False,
|
|
427
|
+
) -> Content:
|
|
428
|
+
"""Build the styled header line for one server.
|
|
429
|
+
|
|
430
|
+
Uses `Content.assemble`'s `(text, style)` tuple form so the per-span
|
|
431
|
+
color is applied dynamically from the theme palette — `from_markup`
|
|
432
|
+
does NOT substitute into bracket tags (`[$icolor]…[/]` would render
|
|
433
|
+
as a literal/unknown tag, not a hex color). The tuple form is also
|
|
434
|
+
injection-safe: each span's `text` is rendered verbatim, never
|
|
435
|
+
markup-parsed, so a server name like `[bold]foo[/]` shows literally
|
|
436
|
+
rather than getting styled. `server.error` additionally goes through
|
|
437
|
+
`sanitize_control_chars` because MCP servers can return arbitrary error
|
|
438
|
+
text including newlines or terminal escapes.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
server: The server whose header is being rendered.
|
|
442
|
+
indicator_glyph: Status glyph (already chosen from `_status_glyph`).
|
|
443
|
+
indicator_color: Status color (already chosen from `_status_color`).
|
|
444
|
+
visible_tools: Tools that survived the active filter — used only
|
|
445
|
+
for the count label.
|
|
446
|
+
glyphs: Active `Glyphs` table for the bullet separator.
|
|
447
|
+
selected: When `True`, suppresses `dim` styling on secondary spans
|
|
448
|
+
so the text stays readable on the `$primary` selection background.
|
|
449
|
+
|
|
450
|
+
Returns:
|
|
451
|
+
Styled `Content` ready to mount inside a `Static`.
|
|
452
|
+
"""
|
|
453
|
+
dim_style = "" if selected else "dim"
|
|
454
|
+
tool_count = len(visible_tools)
|
|
455
|
+
t_label = "tool" if tool_count == 1 else "tools"
|
|
456
|
+
if server.status == "ok":
|
|
457
|
+
summary = f" {server.transport} {glyphs.bullet} {tool_count} {t_label}"
|
|
458
|
+
return Content.assemble(
|
|
459
|
+
(f"{indicator_glyph} ", indicator_color),
|
|
460
|
+
(server.name, "bold"),
|
|
461
|
+
(summary, dim_style),
|
|
462
|
+
)
|
|
463
|
+
if server.status == "unauthenticated":
|
|
464
|
+
login_hint = " — Enter to log in"
|
|
465
|
+
return Content.assemble(
|
|
466
|
+
(f"{indicator_glyph} ", indicator_color),
|
|
467
|
+
(server.name, "bold"),
|
|
468
|
+
(f" {server.transport}", dim_style),
|
|
469
|
+
(f" {glyphs.bullet} {server.status}", indicator_color),
|
|
470
|
+
(login_hint, dim_style),
|
|
471
|
+
)
|
|
472
|
+
if server.status == "awaiting_reconnect":
|
|
473
|
+
return Content.assemble(
|
|
474
|
+
(f"{indicator_glyph} ", indicator_color),
|
|
475
|
+
(server.name, "bold"),
|
|
476
|
+
(f" {server.transport}", dim_style),
|
|
477
|
+
(f" {glyphs.bullet} ready to load", indicator_color),
|
|
478
|
+
(f" — {MCP_RECONNECT_KEY_LABEL} to load tools", dim_style),
|
|
479
|
+
)
|
|
480
|
+
if server.status == "error":
|
|
481
|
+
return Content.assemble(
|
|
482
|
+
(f"{indicator_glyph} ", indicator_color),
|
|
483
|
+
(server.name, "bold"),
|
|
484
|
+
(f" {server.transport}", dim_style),
|
|
485
|
+
(f" {glyphs.bullet} {server.status}", indicator_color),
|
|
486
|
+
(" — Enter for details", dim_style),
|
|
487
|
+
)
|
|
488
|
+
if server.status == "disabled":
|
|
489
|
+
error_text = sanitize_control_chars(
|
|
490
|
+
server.error or "", max_length=_INLINE_TEXT_LIMIT
|
|
491
|
+
)
|
|
492
|
+
return Content.assemble(
|
|
493
|
+
(f"{indicator_glyph} ", indicator_color),
|
|
494
|
+
(server.name, "bold"),
|
|
495
|
+
(f" {server.transport}", dim_style),
|
|
496
|
+
(f" {glyphs.bullet} {server.status}", indicator_color),
|
|
497
|
+
(f" — {error_text}", dim_style) if error_text else "",
|
|
498
|
+
)
|
|
499
|
+
assert_never(server.status)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
class MCPServerErrorScreen(ModalScreen[None]):
|
|
503
|
+
"""Read-only modal for a failed MCP server's error details."""
|
|
504
|
+
|
|
505
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
506
|
+
Binding("c", "copy_error", "Copy", show=False, priority=True),
|
|
507
|
+
Binding("escape", "cancel", "Close", show=False, priority=True),
|
|
508
|
+
]
|
|
509
|
+
|
|
510
|
+
CSS = """
|
|
511
|
+
MCPServerErrorScreen {
|
|
512
|
+
align: center middle;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
MCPServerErrorScreen > Vertical {
|
|
516
|
+
width: 100;
|
|
517
|
+
max-width: 90%;
|
|
518
|
+
height: 80%;
|
|
519
|
+
background: $surface;
|
|
520
|
+
border: solid $error;
|
|
521
|
+
padding: 1 2;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
MCPServerErrorScreen .mcp-error-title {
|
|
525
|
+
text-style: bold;
|
|
526
|
+
color: $error;
|
|
527
|
+
text-align: center;
|
|
528
|
+
margin-bottom: 1;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
MCPServerErrorScreen .mcp-error-body {
|
|
532
|
+
height: 1fr;
|
|
533
|
+
background: $background;
|
|
534
|
+
scrollbar-gutter: stable;
|
|
535
|
+
padding: 0 1;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
MCPServerErrorScreen .mcp-error-text {
|
|
539
|
+
color: $text;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
MCPServerErrorScreen .mcp-error-help {
|
|
543
|
+
height: 1;
|
|
544
|
+
color: $text-muted;
|
|
545
|
+
text-style: italic;
|
|
546
|
+
margin-top: 1;
|
|
547
|
+
text-align: center;
|
|
548
|
+
}
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
def __init__(self, server: MCPServerInfo) -> None:
|
|
552
|
+
"""Initialize the error-detail modal.
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
server: Failed MCP server whose error text should be displayed.
|
|
556
|
+
"""
|
|
557
|
+
super().__init__()
|
|
558
|
+
self._server = server
|
|
559
|
+
self._error = sanitize_control_chars(
|
|
560
|
+
server.error or "No error details were reported.",
|
|
561
|
+
keep_newlines=True,
|
|
562
|
+
collapse_whitespace=False,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
def compose(self) -> ComposeResult:
|
|
566
|
+
"""Compose the modal layout.
|
|
567
|
+
|
|
568
|
+
Yields:
|
|
569
|
+
Modal shell with title, scrollable error text, and help footer.
|
|
570
|
+
"""
|
|
571
|
+
glyphs = get_glyphs()
|
|
572
|
+
yield Vertical(
|
|
573
|
+
Static(
|
|
574
|
+
Content.from_markup(
|
|
575
|
+
"MCP Server Error: $server",
|
|
576
|
+
server=sanitize_control_chars(
|
|
577
|
+
self._server.name, max_length=_INLINE_TEXT_LIMIT
|
|
578
|
+
),
|
|
579
|
+
),
|
|
580
|
+
classes="mcp-error-title",
|
|
581
|
+
),
|
|
582
|
+
VerticalScroll(
|
|
583
|
+
Static(
|
|
584
|
+
Content.from_markup("$error", error=self._error),
|
|
585
|
+
classes="mcp-error-text",
|
|
586
|
+
),
|
|
587
|
+
classes="mcp-error-body",
|
|
588
|
+
),
|
|
589
|
+
Static(
|
|
590
|
+
f"c copy error {glyphs.bullet} Esc close",
|
|
591
|
+
classes="mcp-error-help",
|
|
592
|
+
),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
def action_copy_error(self) -> None:
|
|
596
|
+
"""Copy the server error details to the clipboard."""
|
|
597
|
+
success, error = copy_text_to_clipboard(self.app, self._error)
|
|
598
|
+
if success:
|
|
599
|
+
self.app.notify(
|
|
600
|
+
"MCP error copied",
|
|
601
|
+
severity="information",
|
|
602
|
+
timeout=2,
|
|
603
|
+
markup=False,
|
|
604
|
+
)
|
|
605
|
+
return
|
|
606
|
+
suffix = f": {error}" if error else ""
|
|
607
|
+
self.app.notify(
|
|
608
|
+
f"Failed to copy MCP error{suffix}",
|
|
609
|
+
severity="warning",
|
|
610
|
+
timeout=3,
|
|
611
|
+
markup=False,
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
def action_cancel(self) -> None:
|
|
615
|
+
"""Close the error details modal."""
|
|
616
|
+
self.dismiss(None)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
class MCPServerHeaderItem(Static):
|
|
620
|
+
"""A selectable server-header row in the MCP viewer.
|
|
621
|
+
|
|
622
|
+
Cursor-selectable so users can navigate to every server — even those
|
|
623
|
+
in `unauthenticated` or `error` states which have no tool rows by the
|
|
624
|
+
`MCPServerInfo` invariant — and read the full status / error text on
|
|
625
|
+
the line. Not expandable: `Enter` and `Ctrl+E` are no-ops here.
|
|
626
|
+
"""
|
|
627
|
+
|
|
628
|
+
def __init__(
|
|
629
|
+
self,
|
|
630
|
+
server: MCPServerInfo,
|
|
631
|
+
indicator_glyph: str,
|
|
632
|
+
indicator_color: str,
|
|
633
|
+
visible_tools: tuple[MCPToolInfo, ...],
|
|
634
|
+
glyphs: Glyphs,
|
|
635
|
+
index: int,
|
|
636
|
+
*,
|
|
637
|
+
classes: str = "",
|
|
638
|
+
) -> None:
|
|
639
|
+
"""Initialize a server-header row.
|
|
640
|
+
|
|
641
|
+
Args:
|
|
642
|
+
server: Server metadata used to re-render content on selection
|
|
643
|
+
state changes.
|
|
644
|
+
indicator_glyph: Pre-computed status glyph character.
|
|
645
|
+
indicator_color: Pre-computed status hex color string.
|
|
646
|
+
visible_tools: Filtered tool tuple — used for the count label.
|
|
647
|
+
glyphs: Active glyph table.
|
|
648
|
+
index: Flat row index inside `MCPViewerScreen._row_widgets`.
|
|
649
|
+
classes: CSS classes — should include `mcp-server-header`, and
|
|
650
|
+
optionally `mcp-header-selected` for the initial selection.
|
|
651
|
+
"""
|
|
652
|
+
self._server = server
|
|
653
|
+
self._indicator_glyph = indicator_glyph
|
|
654
|
+
self._indicator_color = indicator_color
|
|
655
|
+
self._visible_tools = visible_tools
|
|
656
|
+
self._glyphs = glyphs
|
|
657
|
+
self.index = index
|
|
658
|
+
self._selected = "mcp-header-selected" in classes
|
|
659
|
+
content = _render_server_header(
|
|
660
|
+
server,
|
|
661
|
+
indicator_glyph,
|
|
662
|
+
indicator_color,
|
|
663
|
+
visible_tools,
|
|
664
|
+
glyphs,
|
|
665
|
+
selected=self._selected,
|
|
666
|
+
)
|
|
667
|
+
super().__init__(content, classes=classes)
|
|
668
|
+
|
|
669
|
+
@property
|
|
670
|
+
def server(self) -> MCPServerInfo:
|
|
671
|
+
"""Server metadata this header row is rendering."""
|
|
672
|
+
return self._server
|
|
673
|
+
|
|
674
|
+
def set_selected(self, selected: bool) -> None:
|
|
675
|
+
"""Apply or remove the selected-row styling and re-render the label.
|
|
676
|
+
|
|
677
|
+
Re-renders content on selection change so `dim` secondary spans
|
|
678
|
+
are suppressed on the `$primary` selection background — the same
|
|
679
|
+
approach `MCPToolItem` uses via `_desc_style`.
|
|
680
|
+
"""
|
|
681
|
+
if self._selected == selected:
|
|
682
|
+
return
|
|
683
|
+
self._selected = selected
|
|
684
|
+
if selected:
|
|
685
|
+
self.add_class("mcp-header-selected")
|
|
686
|
+
else:
|
|
687
|
+
self.remove_class("mcp-header-selected")
|
|
688
|
+
self.update(
|
|
689
|
+
_render_server_header(
|
|
690
|
+
self._server,
|
|
691
|
+
self._indicator_glyph,
|
|
692
|
+
self._indicator_color,
|
|
693
|
+
self._visible_tools,
|
|
694
|
+
self._glyphs,
|
|
695
|
+
selected=selected,
|
|
696
|
+
)
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
def refresh_from_server(
|
|
700
|
+
self,
|
|
701
|
+
server: MCPServerInfo,
|
|
702
|
+
indicator_glyph: str,
|
|
703
|
+
indicator_color: str,
|
|
704
|
+
visible_tools: tuple[MCPToolInfo, ...],
|
|
705
|
+
glyphs: Glyphs,
|
|
706
|
+
) -> None:
|
|
707
|
+
"""Replace the underlying server data and re-render in place.
|
|
708
|
+
|
|
709
|
+
Used by `apply_server_disable_toggle` so an F2 toggle updates this
|
|
710
|
+
header without tearing down and re-mounting the widget. Preserves
|
|
711
|
+
the row's selected state so the cursor stays put visually.
|
|
712
|
+
|
|
713
|
+
Args:
|
|
714
|
+
server: Updated server metadata.
|
|
715
|
+
indicator_glyph: New status glyph character.
|
|
716
|
+
indicator_color: New status hex color.
|
|
717
|
+
visible_tools: New filtered tool tuple (drives the count label).
|
|
718
|
+
glyphs: Active glyph table.
|
|
719
|
+
"""
|
|
720
|
+
self._server = server
|
|
721
|
+
self._indicator_glyph = indicator_glyph
|
|
722
|
+
self._indicator_color = indicator_color
|
|
723
|
+
self._visible_tools = visible_tools
|
|
724
|
+
self._glyphs = glyphs
|
|
725
|
+
self.update(
|
|
726
|
+
_render_server_header(
|
|
727
|
+
server,
|
|
728
|
+
indicator_glyph,
|
|
729
|
+
indicator_color,
|
|
730
|
+
visible_tools,
|
|
731
|
+
glyphs,
|
|
732
|
+
selected=self._selected,
|
|
733
|
+
)
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
def on_click(self, event: Click) -> None:
|
|
737
|
+
"""Handle click — select the header, or start login on unauth re-click.
|
|
738
|
+
|
|
739
|
+
Headers are not expandable. Clicking once moves the cursor;
|
|
740
|
+
clicking the already-selected header either starts login for
|
|
741
|
+
an `unauthenticated` server or opens details for an `error`
|
|
742
|
+
server.
|
|
743
|
+
|
|
744
|
+
Args:
|
|
745
|
+
event: The click event.
|
|
746
|
+
"""
|
|
747
|
+
event.stop()
|
|
748
|
+
screen = self.screen
|
|
749
|
+
if not isinstance(screen, MCPViewerScreen):
|
|
750
|
+
return
|
|
751
|
+
if self._selected and self._server.needs_attention():
|
|
752
|
+
screen.dismiss(self._server.name)
|
|
753
|
+
return
|
|
754
|
+
if self._selected and self._server.status == "error":
|
|
755
|
+
screen.show_server_error(self._server)
|
|
756
|
+
return
|
|
757
|
+
screen._move_to(self.index)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
class MCPViewerScreen(ModalScreen[str | None]):
|
|
761
|
+
"""Modal viewer for active MCP servers and their tools.
|
|
762
|
+
|
|
763
|
+
Displays servers grouped by name with transport type and tool count.
|
|
764
|
+
Navigate with arrow keys, Enter to expand/collapse tool descriptions,
|
|
765
|
+
start in-app OAuth login for an unauthenticated server, or inspect a
|
|
766
|
+
failed server. Ctrl+R requests a reconnect, F2 on a server header
|
|
767
|
+
toggles its disabled state, and Escape closes the modal.
|
|
768
|
+
|
|
769
|
+
Dismisses with `None` when closed without action, the server name to
|
|
770
|
+
drive an in-TUI OAuth login when the user activates an
|
|
771
|
+
`unauthenticated` server header, or `MCP_VIEWER_RECONNECT_REQUEST`
|
|
772
|
+
for a reconnect. The disable/enable toggle (`F2`) is handled in-place
|
|
773
|
+
via the `on_toggle_disable` callback so the screen never tears down
|
|
774
|
+
— see the constructor.
|
|
775
|
+
"""
|
|
776
|
+
|
|
777
|
+
BINDINGS: ClassVar[list[BindingType]] = [
|
|
778
|
+
Binding("up", "move_up", "Up", show=False, priority=True),
|
|
779
|
+
Binding("down", "move_down", "Down", show=False, priority=True),
|
|
780
|
+
Binding("shift+tab", "jump_up", "Up", show=False, priority=True),
|
|
781
|
+
Binding("tab", "jump_down", "Down", show=False, priority=True),
|
|
782
|
+
Binding("enter", "toggle_expand", "Expand", show=False, priority=True),
|
|
783
|
+
# Use a non-letter chord so it does not steal text input from the
|
|
784
|
+
# filter Input. PR #2949 originally proposed `a` for the same
|
|
785
|
+
# action; we rebound to `ctrl+e` for that reason.
|
|
786
|
+
Binding("ctrl+e", "toggle_all", "Toggle all", show=False, priority=True),
|
|
787
|
+
Binding("pageup", "page_up", "Page up", show=False, priority=True),
|
|
788
|
+
Binding("pagedown", "page_down", "Page down", show=False, priority=True),
|
|
789
|
+
Binding(MCP_RECONNECT_KEY, "reconnect", "Reconnect", show=False, priority=True),
|
|
790
|
+
Binding("f2", "toggle_disable", "Toggle disable", show=False, priority=True),
|
|
791
|
+
Binding("escape", "cancel", "Close", show=False, priority=True),
|
|
792
|
+
]
|
|
793
|
+
"""Key bindings for navigation, expansion, and cancel.
|
|
794
|
+
|
|
795
|
+
All bindings use `priority=True` so they take precedence over the
|
|
796
|
+
embedded filter `Input`. Vim-style `j`/`k` bindings are deliberately
|
|
797
|
+
omitted because they would prevent typing those letters into the
|
|
798
|
+
always-focused filter input — same rationale as `model_selector.py`.
|
|
799
|
+
"""
|
|
800
|
+
|
|
801
|
+
CSS = """
|
|
802
|
+
MCPViewerScreen {
|
|
803
|
+
align: center middle;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
MCPViewerScreen > Vertical {
|
|
807
|
+
width: 80;
|
|
808
|
+
max-width: 90%;
|
|
809
|
+
height: 80%;
|
|
810
|
+
background: $surface;
|
|
811
|
+
border: solid $primary;
|
|
812
|
+
padding: 1 2;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
MCPViewerScreen .mcp-viewer-title {
|
|
816
|
+
text-style: bold;
|
|
817
|
+
color: $primary;
|
|
818
|
+
text-align: center;
|
|
819
|
+
margin-bottom: 1;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
MCPViewerScreen #mcp-filter {
|
|
823
|
+
margin-bottom: 1;
|
|
824
|
+
border: solid $primary-lighten-2;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
MCPViewerScreen #mcp-filter:focus {
|
|
828
|
+
border: solid $primary;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
MCPViewerScreen .mcp-list {
|
|
832
|
+
height: 1fr;
|
|
833
|
+
min-height: 5;
|
|
834
|
+
scrollbar-gutter: stable;
|
|
835
|
+
background: $background;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
MCPViewerScreen .mcp-server-header {
|
|
839
|
+
color: $primary;
|
|
840
|
+
margin-top: 1;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
MCPViewerScreen .mcp-server-header:hover {
|
|
844
|
+
background: $surface-lighten-1;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
MCPViewerScreen .mcp-list > .mcp-server-header:first-child {
|
|
848
|
+
margin-top: 0;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
MCPViewerScreen .mcp-header-selected {
|
|
852
|
+
background: $primary;
|
|
853
|
+
color: $text;
|
|
854
|
+
text-style: bold;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
MCPViewerScreen .mcp-header-selected:hover {
|
|
858
|
+
background: $primary-lighten-1;
|
|
859
|
+
color: $text;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
MCPViewerScreen .mcp-tool-item {
|
|
863
|
+
height: 1;
|
|
864
|
+
padding: 0 1;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
MCPViewerScreen .mcp-tool-item:hover {
|
|
868
|
+
background: $surface-lighten-1;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
MCPViewerScreen .mcp-tool-selected {
|
|
872
|
+
background: $primary;
|
|
873
|
+
color: $text;
|
|
874
|
+
text-style: bold;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
MCPViewerScreen .mcp-tool-selected:hover {
|
|
878
|
+
background: $primary-lighten-1;
|
|
879
|
+
color: $text;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
MCPViewerScreen .mcp-empty {
|
|
883
|
+
color: $text-muted;
|
|
884
|
+
text-style: italic;
|
|
885
|
+
text-align: center;
|
|
886
|
+
margin-top: 2;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
MCPViewerScreen .mcp-viewer-help {
|
|
890
|
+
height: 1;
|
|
891
|
+
color: $text-muted;
|
|
892
|
+
text-style: italic;
|
|
893
|
+
margin-top: 1;
|
|
894
|
+
text-align: center;
|
|
895
|
+
}
|
|
896
|
+
"""
|
|
897
|
+
|
|
898
|
+
def __init__(
|
|
899
|
+
self,
|
|
900
|
+
server_info: list[MCPServerInfo],
|
|
901
|
+
*,
|
|
902
|
+
connecting: bool = False,
|
|
903
|
+
pending_reconnect: bool = False,
|
|
904
|
+
on_toggle_disable: Callable[[str], Awaitable[None]] | None = None,
|
|
905
|
+
) -> None:
|
|
906
|
+
"""Initialize the MCP viewer screen.
|
|
907
|
+
|
|
908
|
+
Args:
|
|
909
|
+
server_info: List of MCP server metadata to display.
|
|
910
|
+
connecting: When `True` and `server_info` is empty, show a
|
|
911
|
+
"connecting..." placeholder instead of the "no servers"
|
|
912
|
+
message; the screen refreshes when `refresh_server_info`
|
|
913
|
+
is called after the server startup completes.
|
|
914
|
+
pending_reconnect: `True` when a deferred MCP login is queued
|
|
915
|
+
and a restart will pick it up. Surfaces the `Ctrl+R`
|
|
916
|
+
reconnect hint in the footer; the keybind itself is a
|
|
917
|
+
no-op when this is `False`.
|
|
918
|
+
on_toggle_disable: Async callback invoked with the selected
|
|
919
|
+
server's name when the user presses `F2` on a header row.
|
|
920
|
+
The callback persists the new disabled state and is
|
|
921
|
+
expected to call `refresh_server_info` on this screen so
|
|
922
|
+
the user sees the updated status without a screen swap.
|
|
923
|
+
When `None`, `F2` is a no-op.
|
|
924
|
+
"""
|
|
925
|
+
super().__init__()
|
|
926
|
+
self._server_info = server_info
|
|
927
|
+
self._connecting = connecting
|
|
928
|
+
self._pending_reconnect = pending_reconnect
|
|
929
|
+
self._on_toggle_disable = on_toggle_disable
|
|
930
|
+
# All cursor-navigable rows in render order: server headers + tool
|
|
931
|
+
# items intermixed. `_selected_index` indexes into this list.
|
|
932
|
+
self._row_widgets: list[MCPToolItem | MCPServerHeaderItem] = []
|
|
933
|
+
self._selected_index = 0
|
|
934
|
+
self._query: str = ""
|
|
935
|
+
|
|
936
|
+
@property
|
|
937
|
+
def _tool_widgets(self) -> list[MCPToolItem]:
|
|
938
|
+
"""Tool rows only — excludes server headers.
|
|
939
|
+
|
|
940
|
+
Convenience view used by `Ctrl+E` toggle-all and by tests that
|
|
941
|
+
only care about tool-level state. The authoritative storage is
|
|
942
|
+
`_row_widgets`.
|
|
943
|
+
"""
|
|
944
|
+
return [w for w in self._row_widgets if isinstance(w, MCPToolItem)]
|
|
945
|
+
|
|
946
|
+
async def refresh_server_info(
|
|
947
|
+
self,
|
|
948
|
+
server_info: list[MCPServerInfo],
|
|
949
|
+
*,
|
|
950
|
+
pending_reconnect: bool | None = None,
|
|
951
|
+
select_server: str | None = None,
|
|
952
|
+
) -> None:
|
|
953
|
+
"""Replace the displayed server list; typically after server startup.
|
|
954
|
+
|
|
955
|
+
Rebuilds the modal body in place so a user who opened `/mcp` before
|
|
956
|
+
tools finished loading sees them appear without closing/reopening.
|
|
957
|
+
Also used by the in-place disable toggle so the cursor lands back
|
|
958
|
+
on the same server header after F2.
|
|
959
|
+
|
|
960
|
+
The active filter is cleared on refresh: the connecting placeholder
|
|
961
|
+
suppresses the filter input, so `_query` cannot be non-empty when
|
|
962
|
+
this is called. Resetting it also prevents the programmatic
|
|
963
|
+
`Input(value=...)` mount in `_mount_body` from triggering a
|
|
964
|
+
redundant `Input.Changed` repopulation.
|
|
965
|
+
|
|
966
|
+
This is async because `body.remove_children()` must complete before
|
|
967
|
+
`_mount_body` re-inserts an `Input(id="mcp-filter")` — Textual
|
|
968
|
+
defers child removal, so a non-awaited remove leaves the old
|
|
969
|
+
widget attached and the mount raises `DuplicateIds`.
|
|
970
|
+
|
|
971
|
+
Args:
|
|
972
|
+
server_info: Refreshed server metadata.
|
|
973
|
+
pending_reconnect: When provided, updates the footer's
|
|
974
|
+
reconnect hint. `None` preserves the existing value.
|
|
975
|
+
select_server: When provided, after the rebuild, move the
|
|
976
|
+
cursor to the header row whose `server.name` matches.
|
|
977
|
+
Unmatched names are silently ignored (the rebuild keeps
|
|
978
|
+
the default index-0 selection).
|
|
979
|
+
"""
|
|
980
|
+
self._server_info = server_info
|
|
981
|
+
self._connecting = False
|
|
982
|
+
self._query = ""
|
|
983
|
+
if pending_reconnect is not None:
|
|
984
|
+
self._pending_reconnect = pending_reconnect
|
|
985
|
+
body = self.query_one(Vertical)
|
|
986
|
+
await body.remove_children()
|
|
987
|
+
self._row_widgets = []
|
|
988
|
+
self._selected_index = 0
|
|
989
|
+
self._mount_body(body)
|
|
990
|
+
if select_server is not None:
|
|
991
|
+
for idx, widget in enumerate(self._row_widgets):
|
|
992
|
+
if (
|
|
993
|
+
isinstance(widget, MCPServerHeaderItem)
|
|
994
|
+
and widget.server.name == select_server
|
|
995
|
+
):
|
|
996
|
+
self._move_to(idx)
|
|
997
|
+
self._reveal_selection(widget, direction=1)
|
|
998
|
+
break
|
|
999
|
+
self._focus_filter_input()
|
|
1000
|
+
|
|
1001
|
+
async def apply_server_disable_toggle(
|
|
1002
|
+
self,
|
|
1003
|
+
server_info: list[MCPServerInfo],
|
|
1004
|
+
*,
|
|
1005
|
+
toggled_server: str,
|
|
1006
|
+
pending_reconnect: bool | None = None,
|
|
1007
|
+
) -> None:
|
|
1008
|
+
"""Patch a single server's row in place after an F2 toggle.
|
|
1009
|
+
|
|
1010
|
+
Surgically updates only the affected server's header and tool
|
|
1011
|
+
rows so unchanged widgets keep their identity — no full
|
|
1012
|
+
`body.remove_children()` + remount, which would re-create every
|
|
1013
|
+
`MCPToolItem` and reintroduce the `on_mount` truncation flicker
|
|
1014
|
+
across the entire list.
|
|
1015
|
+
|
|
1016
|
+
Falls back to `refresh_server_info` when the toggled server
|
|
1017
|
+
cannot be patched in place: server missing from the new info
|
|
1018
|
+
list, no existing header widget (e.g., the active filter
|
|
1019
|
+
currently hides it), or the new state would filter the server
|
|
1020
|
+
out entirely.
|
|
1021
|
+
|
|
1022
|
+
Args:
|
|
1023
|
+
server_info: Refreshed server metadata (full list).
|
|
1024
|
+
toggled_server: Name of the server whose disabled state just
|
|
1025
|
+
changed; identifies which row to patch.
|
|
1026
|
+
pending_reconnect: When provided, updates the footer's
|
|
1027
|
+
reconnect hint. `None` preserves the existing value.
|
|
1028
|
+
"""
|
|
1029
|
+
self._server_info = server_info
|
|
1030
|
+
if pending_reconnect is not None:
|
|
1031
|
+
self._pending_reconnect = pending_reconnect
|
|
1032
|
+
|
|
1033
|
+
new_server = next((s for s in server_info if s.name == toggled_server), None)
|
|
1034
|
+
header_idx = next(
|
|
1035
|
+
(
|
|
1036
|
+
i
|
|
1037
|
+
for i, w in enumerate(self._row_widgets)
|
|
1038
|
+
if isinstance(w, MCPServerHeaderItem)
|
|
1039
|
+
and w.server.name == toggled_server
|
|
1040
|
+
),
|
|
1041
|
+
None,
|
|
1042
|
+
)
|
|
1043
|
+
tokens = [tok for tok in self._query.lower().split() if tok]
|
|
1044
|
+
visible_tools = (
|
|
1045
|
+
_visible_tools_for(new_server, tokens) if new_server is not None else None
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
if new_server is None or header_idx is None or visible_tools is None:
|
|
1049
|
+
logger.debug(
|
|
1050
|
+
"apply_server_disable_toggle fallback for %r: "
|
|
1051
|
+
"new_server=%s header_idx=%s visible_tools=%s",
|
|
1052
|
+
toggled_server,
|
|
1053
|
+
new_server is not None,
|
|
1054
|
+
header_idx,
|
|
1055
|
+
visible_tools is not None,
|
|
1056
|
+
)
|
|
1057
|
+
await self.refresh_server_info(
|
|
1058
|
+
server_info,
|
|
1059
|
+
pending_reconnect=pending_reconnect,
|
|
1060
|
+
select_server=toggled_server,
|
|
1061
|
+
)
|
|
1062
|
+
return
|
|
1063
|
+
|
|
1064
|
+
header = self._row_widgets[header_idx]
|
|
1065
|
+
if not isinstance(header, MCPServerHeaderItem):
|
|
1066
|
+
# The lookup above filters by isinstance, so this branch
|
|
1067
|
+
# should be unreachable. Log loudly rather than silently
|
|
1068
|
+
# returning so a future invariant break is visible.
|
|
1069
|
+
logger.warning(
|
|
1070
|
+
"apply_server_disable_toggle: expected header at index %d, got %r",
|
|
1071
|
+
header_idx,
|
|
1072
|
+
type(header).__name__,
|
|
1073
|
+
)
|
|
1074
|
+
return
|
|
1075
|
+
next_header_idx = next(
|
|
1076
|
+
(
|
|
1077
|
+
i
|
|
1078
|
+
for i in range(header_idx + 1, len(self._row_widgets))
|
|
1079
|
+
if isinstance(self._row_widgets[i], MCPServerHeaderItem)
|
|
1080
|
+
),
|
|
1081
|
+
len(self._row_widgets),
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
# `remove_children(to_remove)` removes the listed widgets
|
|
1085
|
+
# atomically in one refresh; awaiting `widget.remove()` per
|
|
1086
|
+
# row would yield to Textual between each, animating the
|
|
1087
|
+
# tool list shrinking one entry at a time.
|
|
1088
|
+
scroll = self.query_one(".mcp-list", VerticalScroll)
|
|
1089
|
+
to_remove = self._row_widgets[header_idx + 1 : next_header_idx]
|
|
1090
|
+
if to_remove:
|
|
1091
|
+
await scroll.remove_children(to_remove)
|
|
1092
|
+
del self._row_widgets[header_idx + 1 : next_header_idx]
|
|
1093
|
+
|
|
1094
|
+
colors = theme.get_theme_colors(self)
|
|
1095
|
+
glyphs = get_glyphs()
|
|
1096
|
+
header.refresh_from_server(
|
|
1097
|
+
new_server,
|
|
1098
|
+
_status_glyph(new_server.status, glyphs),
|
|
1099
|
+
_status_color(new_server.status, colors),
|
|
1100
|
+
visible_tools,
|
|
1101
|
+
glyphs,
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
if visible_tools:
|
|
1105
|
+
new_widgets: list[MCPToolItem] = [
|
|
1106
|
+
MCPToolItem(
|
|
1107
|
+
name=tool.name,
|
|
1108
|
+
description=tool.description,
|
|
1109
|
+
index=0, # renumbered below
|
|
1110
|
+
classes="mcp-tool-item",
|
|
1111
|
+
input_schema=tool.input_schema,
|
|
1112
|
+
)
|
|
1113
|
+
for tool in visible_tools
|
|
1114
|
+
]
|
|
1115
|
+
await scroll.mount(*new_widgets, after=header)
|
|
1116
|
+
self._row_widgets[header_idx + 1 : header_idx + 1] = new_widgets
|
|
1117
|
+
|
|
1118
|
+
# `MCPToolItem.on_click` calls `screen._move_to(self.index)`,
|
|
1119
|
+
# so every row's stored index must match its position after
|
|
1120
|
+
# the splice — otherwise clicks land on the wrong row.
|
|
1121
|
+
for idx, widget in enumerate(self._row_widgets):
|
|
1122
|
+
widget.index = idx
|
|
1123
|
+
if self._selected_index >= len(self._row_widgets):
|
|
1124
|
+
self._selected_index = max(0, len(self._row_widgets) - 1)
|
|
1125
|
+
|
|
1126
|
+
# `_build_help_text` is cheap and reads `_pending_reconnect`,
|
|
1127
|
+
# so re-render the footer whenever the caller supplied a new
|
|
1128
|
+
# value — saves comparing against the prior state.
|
|
1129
|
+
if pending_reconnect is not None:
|
|
1130
|
+
help_static = self.query_one(".mcp-viewer-help", Static)
|
|
1131
|
+
help_static.update(self._build_help_text(glyphs))
|
|
1132
|
+
|
|
1133
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
1134
|
+
"""Rebuild the visible tool list whenever the filter input changes.
|
|
1135
|
+
|
|
1136
|
+
Only the scroll's children are torn down — the title, filter Input,
|
|
1137
|
+
and help footer stay mounted so focus is preserved across keystrokes.
|
|
1138
|
+
"""
|
|
1139
|
+
if event.input.id != "mcp-filter":
|
|
1140
|
+
return
|
|
1141
|
+
self._query = event.value
|
|
1142
|
+
scroll = self.query_one(".mcp-list", VerticalScroll)
|
|
1143
|
+
scroll.remove_children()
|
|
1144
|
+
self._row_widgets = []
|
|
1145
|
+
self._selected_index = 0
|
|
1146
|
+
self._populate_scroll(scroll, self._query)
|
|
1147
|
+
self._selected_index = min(
|
|
1148
|
+
self._selected_index, max(0, len(self._row_widgets) - 1)
|
|
1149
|
+
)
|
|
1150
|
+
|
|
1151
|
+
def compose(self) -> ComposeResult: # noqa: PLR6301 # Textual requires an instance method
|
|
1152
|
+
"""Compose the screen layout.
|
|
1153
|
+
|
|
1154
|
+
Yields:
|
|
1155
|
+
Empty `Vertical` — `_mount_body` fills it on mount so the same
|
|
1156
|
+
builder can also refresh the screen in place after server-ready.
|
|
1157
|
+
"""
|
|
1158
|
+
yield Vertical()
|
|
1159
|
+
|
|
1160
|
+
def on_mount(self) -> None:
|
|
1161
|
+
"""Build the body once the screen is mounted."""
|
|
1162
|
+
if is_ascii_mode():
|
|
1163
|
+
container = self.query_one(Vertical)
|
|
1164
|
+
colors = theme.get_theme_colors(self)
|
|
1165
|
+
container.styles.border = ("ascii", colors.success)
|
|
1166
|
+
self._mount_body(self.query_one(Vertical))
|
|
1167
|
+
|
|
1168
|
+
def _mount_body(self, container: Vertical) -> None:
|
|
1169
|
+
"""Populate `container` with the title, filter input, list, and help footer.
|
|
1170
|
+
|
|
1171
|
+
The filter Input and scroll container are mounted once. Subsequent
|
|
1172
|
+
filter rebuilds replace only the scroll's children via
|
|
1173
|
+
`_populate_scroll`, keeping the Input focused across keystrokes.
|
|
1174
|
+
"""
|
|
1175
|
+
glyphs = get_glyphs()
|
|
1176
|
+
total_servers = len(self._server_info)
|
|
1177
|
+
total_tools = sum(len(s.tools) for s in self._server_info)
|
|
1178
|
+
|
|
1179
|
+
if total_servers:
|
|
1180
|
+
server_label = "server" if total_servers == 1 else "servers"
|
|
1181
|
+
tool_label = "tool" if total_tools == 1 else "tools"
|
|
1182
|
+
title = (
|
|
1183
|
+
f"MCP Servers ({total_servers} {server_label},"
|
|
1184
|
+
f" {total_tools} {tool_label})"
|
|
1185
|
+
)
|
|
1186
|
+
else:
|
|
1187
|
+
title = "MCP Servers"
|
|
1188
|
+
container.mount(Static(title, classes="mcp-viewer-title"))
|
|
1189
|
+
|
|
1190
|
+
# Suppress the filter Input while the connecting placeholder is
|
|
1191
|
+
# showing — there's nothing to filter yet.
|
|
1192
|
+
if self._server_info:
|
|
1193
|
+
container.mount(
|
|
1194
|
+
Input(
|
|
1195
|
+
id="mcp-filter",
|
|
1196
|
+
placeholder="Filter tools...",
|
|
1197
|
+
value=self._query,
|
|
1198
|
+
)
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
scroll = VerticalScroll(classes="mcp-list")
|
|
1202
|
+
container.mount(scroll)
|
|
1203
|
+
self._populate_scroll(scroll, self._query)
|
|
1204
|
+
|
|
1205
|
+
container.mount(
|
|
1206
|
+
Static(self._build_help_text(glyphs), classes="mcp-viewer-help")
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
def _focus_filter_input(self) -> None:
|
|
1210
|
+
"""Refocus the filter `Input` after an in-place body rebuild.
|
|
1211
|
+
|
|
1212
|
+
`refresh_server_info` clears the body via `remove_children`, which
|
|
1213
|
+
blurs the screen (Textual resets focus to `None` when the focused
|
|
1214
|
+
widget is pruned). The newly mounted filter `Input` is not
|
|
1215
|
+
auto-focused on a re-mount — Textual auto-focuses only on the first
|
|
1216
|
+
mount — so a viewer opened while the server is still connecting
|
|
1217
|
+
would leave the rebuilt input unfocused once tools load, and
|
|
1218
|
+
keystrokes would never reach it. Restore focus explicitly here,
|
|
1219
|
+
deferred via `call_after_refresh` because `_mount_body` mounts
|
|
1220
|
+
without awaiting.
|
|
1221
|
+
|
|
1222
|
+
The `Input` exists only when there are servers to filter (see
|
|
1223
|
+
`_mount_body`); when the list is empty there is nothing to focus, so
|
|
1224
|
+
return early. Gating on `_server_info` rather than swallowing a
|
|
1225
|
+
missing-widget error keeps a genuinely-absent input (id drift, a
|
|
1226
|
+
failed mount) visible instead of silently re-introducing the
|
|
1227
|
+
keystroke-swallow this method exists to prevent.
|
|
1228
|
+
"""
|
|
1229
|
+
|
|
1230
|
+
def _focus() -> None:
|
|
1231
|
+
if not self._server_info:
|
|
1232
|
+
return
|
|
1233
|
+
self.query_one("#mcp-filter", Input).focus()
|
|
1234
|
+
|
|
1235
|
+
self.call_after_refresh(_focus)
|
|
1236
|
+
|
|
1237
|
+
def _build_help_text(self, glyphs: Glyphs) -> str:
|
|
1238
|
+
"""Compose the help-footer string from the current `_pending_reconnect`.
|
|
1239
|
+
|
|
1240
|
+
Single source of truth so `_mount_body` (initial) and
|
|
1241
|
+
`apply_server_disable_toggle` (incremental) stay in sync — F2
|
|
1242
|
+
flips the reconnect-pending state, and the footer must update
|
|
1243
|
+
without a full re-mount.
|
|
1244
|
+
|
|
1245
|
+
Returns:
|
|
1246
|
+
The rendered help line for the modal footer.
|
|
1247
|
+
"""
|
|
1248
|
+
help_parts = [
|
|
1249
|
+
f"{glyphs.arrow_up}/{glyphs.arrow_down} navigate",
|
|
1250
|
+
"Enter expand/login/details",
|
|
1251
|
+
"F2 disable/enable",
|
|
1252
|
+
"Ctrl+E expand all",
|
|
1253
|
+
]
|
|
1254
|
+
if self._pending_reconnect:
|
|
1255
|
+
help_parts.append(f"{MCP_RECONNECT_KEY_LABEL} reconnect")
|
|
1256
|
+
help_parts.extend(["type to filter", "Esc close"])
|
|
1257
|
+
return f" {glyphs.bullet} ".join(help_parts)
|
|
1258
|
+
|
|
1259
|
+
def _populate_scroll(self, scroll: VerticalScroll, query: str) -> None:
|
|
1260
|
+
"""Mount filtered server headers + tool items into `scroll`.
|
|
1261
|
+
|
|
1262
|
+
Empty `query` shows everything; otherwise multi-token AND matching
|
|
1263
|
+
on server names and tool names only — descriptions, parameter
|
|
1264
|
+
names, and transport are not in the haystack (see
|
|
1265
|
+
`_visible_tools_for`).
|
|
1266
|
+
"""
|
|
1267
|
+
glyphs = get_glyphs()
|
|
1268
|
+
|
|
1269
|
+
if not self._server_info:
|
|
1270
|
+
placeholder = (
|
|
1271
|
+
"Loading MCP tools..."
|
|
1272
|
+
if self._connecting
|
|
1273
|
+
else ("No MCP servers configured.\nUse `--mcp-config` to load servers.")
|
|
1274
|
+
)
|
|
1275
|
+
scroll.mount(Static(placeholder, classes="mcp-empty"))
|
|
1276
|
+
return
|
|
1277
|
+
|
|
1278
|
+
tokens = [tok for tok in query.lower().split() if tok]
|
|
1279
|
+
colors = theme.get_theme_colors(self)
|
|
1280
|
+
flat_index = 0
|
|
1281
|
+
|
|
1282
|
+
for server in _sort_servers_for_display(self._server_info):
|
|
1283
|
+
visible_tools = _visible_tools_for(server, tokens)
|
|
1284
|
+
if visible_tools is None:
|
|
1285
|
+
# Server filtered out entirely.
|
|
1286
|
+
continue
|
|
1287
|
+
|
|
1288
|
+
indicator_color = _status_color(server.status, colors)
|
|
1289
|
+
indicator_glyph = _status_glyph(server.status, glyphs)
|
|
1290
|
+
header_classes = "mcp-server-header"
|
|
1291
|
+
if flat_index == 0:
|
|
1292
|
+
header_classes += " mcp-header-selected"
|
|
1293
|
+
header = MCPServerHeaderItem(
|
|
1294
|
+
server=server,
|
|
1295
|
+
indicator_glyph=indicator_glyph,
|
|
1296
|
+
indicator_color=indicator_color,
|
|
1297
|
+
visible_tools=visible_tools,
|
|
1298
|
+
glyphs=glyphs,
|
|
1299
|
+
index=flat_index,
|
|
1300
|
+
classes=header_classes,
|
|
1301
|
+
)
|
|
1302
|
+
self._row_widgets.append(header)
|
|
1303
|
+
scroll.mount(header)
|
|
1304
|
+
flat_index += 1
|
|
1305
|
+
|
|
1306
|
+
for tool in visible_tools:
|
|
1307
|
+
classes = "mcp-tool-item"
|
|
1308
|
+
widget = MCPToolItem(
|
|
1309
|
+
name=tool.name,
|
|
1310
|
+
description=tool.description,
|
|
1311
|
+
index=flat_index,
|
|
1312
|
+
classes=classes,
|
|
1313
|
+
input_schema=tool.input_schema,
|
|
1314
|
+
)
|
|
1315
|
+
self._row_widgets.append(widget)
|
|
1316
|
+
scroll.mount(widget)
|
|
1317
|
+
flat_index += 1
|
|
1318
|
+
|
|
1319
|
+
if not self._row_widgets:
|
|
1320
|
+
msg = "No matching tools." if tokens else "No tools available."
|
|
1321
|
+
scroll.mount(Static(msg, classes="mcp-empty"))
|
|
1322
|
+
|
|
1323
|
+
def _move_to(self, index: int) -> None:
|
|
1324
|
+
"""Move selection to the given row index.
|
|
1325
|
+
|
|
1326
|
+
Args:
|
|
1327
|
+
index: Target row index inside `_row_widgets` (header or tool).
|
|
1328
|
+
"""
|
|
1329
|
+
count = len(self._row_widgets)
|
|
1330
|
+
if not count:
|
|
1331
|
+
return
|
|
1332
|
+
if not (0 <= index < count):
|
|
1333
|
+
# Stale index from a widget that survived a filter rebuild.
|
|
1334
|
+
return
|
|
1335
|
+
old = self._selected_index
|
|
1336
|
+
if not (0 <= old < count):
|
|
1337
|
+
old = 0
|
|
1338
|
+
self._selected_index = index
|
|
1339
|
+
|
|
1340
|
+
if old != index:
|
|
1341
|
+
self._row_widgets[old].set_selected(False)
|
|
1342
|
+
self._row_widgets[index].set_selected(True)
|
|
1343
|
+
# Caller (action) is responsible for any viewport pin — different
|
|
1344
|
+
# navigation directions want different anchors (top for down,
|
|
1345
|
+
# bottom for up).
|
|
1346
|
+
|
|
1347
|
+
def _move_selection(self, delta: int) -> None:
|
|
1348
|
+
"""Move selection by delta row positions within the list bounds.
|
|
1349
|
+
|
|
1350
|
+
Walks every row (headers + tools). Navigation actions handle wrapping
|
|
1351
|
+
before calling this helper at a list boundary.
|
|
1352
|
+
|
|
1353
|
+
Args:
|
|
1354
|
+
delta: Number of row positions to move.
|
|
1355
|
+
"""
|
|
1356
|
+
if not self._row_widgets:
|
|
1357
|
+
return
|
|
1358
|
+
target = self._selected_index + delta
|
|
1359
|
+
if 0 <= target < len(self._row_widgets):
|
|
1360
|
+
self._move_to(target)
|
|
1361
|
+
|
|
1362
|
+
def _next_server_header(self, start: int, step: int) -> int | None:
|
|
1363
|
+
"""Return the next server-header index in the requested direction.
|
|
1364
|
+
|
|
1365
|
+
Args:
|
|
1366
|
+
start: Index to start searching from (exclusive).
|
|
1367
|
+
step: `+1` (forward) or `-1` (backward).
|
|
1368
|
+
|
|
1369
|
+
Returns:
|
|
1370
|
+
The index of the nearest `MCPServerHeaderItem` in that direction,
|
|
1371
|
+
or `None` when no server header exists there.
|
|
1372
|
+
"""
|
|
1373
|
+
index = start + step
|
|
1374
|
+
while 0 <= index < len(self._row_widgets):
|
|
1375
|
+
if isinstance(self._row_widgets[index], MCPServerHeaderItem):
|
|
1376
|
+
return index
|
|
1377
|
+
index += step
|
|
1378
|
+
return None
|
|
1379
|
+
|
|
1380
|
+
def _scroll_widget_bottom_to_view(
|
|
1381
|
+
self, widget: MCPToolItem | MCPServerHeaderItem
|
|
1382
|
+
) -> None:
|
|
1383
|
+
"""Scroll so `widget.region.bottom` aligns with the viewport bottom.
|
|
1384
|
+
|
|
1385
|
+
Used when jumping upward into a row taller than the viewport: lands
|
|
1386
|
+
the user at the bottom of that row so the next `Up` press immediately
|
|
1387
|
+
line-scrolls upward through its content rather than jumping again.
|
|
1388
|
+
"""
|
|
1389
|
+
scroll = self.query_one(".mcp-list", VerticalScroll)
|
|
1390
|
+
delta = (widget.region.y + widget.region.height) - (
|
|
1391
|
+
scroll.region.y + scroll.region.height
|
|
1392
|
+
)
|
|
1393
|
+
if delta:
|
|
1394
|
+
scroll.scroll_relative(y=delta, animate=False)
|
|
1395
|
+
|
|
1396
|
+
def _reveal_selection(
|
|
1397
|
+
self,
|
|
1398
|
+
widget: MCPToolItem | MCPServerHeaderItem,
|
|
1399
|
+
*,
|
|
1400
|
+
direction: int,
|
|
1401
|
+
) -> None:
|
|
1402
|
+
"""Bring `widget` into view after a selection change.
|
|
1403
|
+
|
|
1404
|
+
Only force-anchors rows taller than the viewport — these need a
|
|
1405
|
+
deliberate edge alignment so subsequent arrow presses can line-scroll
|
|
1406
|
+
through the row's body. For normal rows, defers to `scroll_visible`,
|
|
1407
|
+
which is a no-op when the row is already fully visible. Matches
|
|
1408
|
+
`/model` switcher behavior where short, in-view rows don't tug the
|
|
1409
|
+
viewport on every keypress.
|
|
1410
|
+
|
|
1411
|
+
Args:
|
|
1412
|
+
widget: The newly selected row.
|
|
1413
|
+
direction: `+1` when moving down (anchor top for tall rows),
|
|
1414
|
+
`-1` when moving up (anchor bottom for tall rows).
|
|
1415
|
+
"""
|
|
1416
|
+
scroll = self.query_one(".mcp-list", VerticalScroll)
|
|
1417
|
+
if widget.region.height > scroll.region.height:
|
|
1418
|
+
if direction > 0:
|
|
1419
|
+
widget.scroll_visible(top=True)
|
|
1420
|
+
else:
|
|
1421
|
+
self._scroll_widget_bottom_to_view(widget)
|
|
1422
|
+
else:
|
|
1423
|
+
widget.scroll_visible()
|
|
1424
|
+
|
|
1425
|
+
def action_move_up(self) -> None:
|
|
1426
|
+
"""Smart up: scroll one row inside a tall expanded row, else jump.
|
|
1427
|
+
|
|
1428
|
+
If the selected row's top edge is already inside the viewport, jump
|
|
1429
|
+
to the previous row (header or tool), wrapping to the final row from
|
|
1430
|
+
the first. For rows taller than the viewport, pin the new selection's
|
|
1431
|
+
**bottom** to the viewport so the next `Up` resumes line-stepping
|
|
1432
|
+
through that row; otherwise just ensure the row is visible. `Tab` /
|
|
1433
|
+
`Shift+Tab` jump between server headers (see `action_jump_up`).
|
|
1434
|
+
"""
|
|
1435
|
+
if not self._row_widgets:
|
|
1436
|
+
return
|
|
1437
|
+
scroll = self.query_one(".mcp-list", VerticalScroll)
|
|
1438
|
+
selected = self._row_widgets[self._selected_index]
|
|
1439
|
+
if selected.region.y >= scroll.region.y:
|
|
1440
|
+
old = self._selected_index
|
|
1441
|
+
if old == 0:
|
|
1442
|
+
self._move_to(len(self._row_widgets) - 1)
|
|
1443
|
+
else:
|
|
1444
|
+
self._move_selection(-1)
|
|
1445
|
+
if self._selected_index != old:
|
|
1446
|
+
self._reveal_selection(
|
|
1447
|
+
self._row_widgets[self._selected_index], direction=-1
|
|
1448
|
+
)
|
|
1449
|
+
else:
|
|
1450
|
+
scroll.scroll_relative(y=-1, animate=False)
|
|
1451
|
+
|
|
1452
|
+
def action_move_down(self) -> None:
|
|
1453
|
+
"""Smart down: scroll one row inside a tall expanded row, else jump.
|
|
1454
|
+
|
|
1455
|
+
If the selected row's bottom edge is already inside the viewport,
|
|
1456
|
+
jump to the next row (header or tool), wrapping to the first row from
|
|
1457
|
+
the final one. For rows taller than the viewport, pin the new
|
|
1458
|
+
selection's top to the viewport; otherwise just ensure the row is
|
|
1459
|
+
visible. `Tab` / `Shift+Tab` jump between server headers (see
|
|
1460
|
+
`action_jump_down`).
|
|
1461
|
+
"""
|
|
1462
|
+
if not self._row_widgets:
|
|
1463
|
+
return
|
|
1464
|
+
scroll = self.query_one(".mcp-list", VerticalScroll)
|
|
1465
|
+
selected = self._row_widgets[self._selected_index]
|
|
1466
|
+
selected_bottom = selected.region.y + selected.region.height
|
|
1467
|
+
viewport_bottom = scroll.region.y + scroll.region.height
|
|
1468
|
+
if selected_bottom <= viewport_bottom:
|
|
1469
|
+
old = self._selected_index
|
|
1470
|
+
if old == len(self._row_widgets) - 1:
|
|
1471
|
+
self._move_to(0)
|
|
1472
|
+
else:
|
|
1473
|
+
self._move_selection(1)
|
|
1474
|
+
if self._selected_index != old:
|
|
1475
|
+
self._reveal_selection(
|
|
1476
|
+
self._row_widgets[self._selected_index], direction=1
|
|
1477
|
+
)
|
|
1478
|
+
else:
|
|
1479
|
+
scroll.scroll_relative(y=1, animate=False)
|
|
1480
|
+
|
|
1481
|
+
def action_jump_up(self) -> None:
|
|
1482
|
+
"""Jump backward to the nearest server header (Shift+Tab), wrapping.
|
|
1483
|
+
|
|
1484
|
+
From a tool row this lands on the current server's own header; from a
|
|
1485
|
+
header it moves to the previous server. Wraps to the final header from
|
|
1486
|
+
the top.
|
|
1487
|
+
"""
|
|
1488
|
+
target = self._next_server_header(self._selected_index, -1)
|
|
1489
|
+
if target is None:
|
|
1490
|
+
target = self._next_server_header(len(self._row_widgets), -1)
|
|
1491
|
+
if target is None or target == self._selected_index:
|
|
1492
|
+
return
|
|
1493
|
+
self._move_to(target)
|
|
1494
|
+
self._reveal_selection(self._row_widgets[target], direction=-1)
|
|
1495
|
+
|
|
1496
|
+
def action_jump_down(self) -> None:
|
|
1497
|
+
"""Jump to the next server (Tab), wrapping at the end."""
|
|
1498
|
+
target = self._next_server_header(self._selected_index, +1)
|
|
1499
|
+
if target is None:
|
|
1500
|
+
target = self._next_server_header(-1, +1)
|
|
1501
|
+
if target is None or target == self._selected_index:
|
|
1502
|
+
return
|
|
1503
|
+
self._move_to(target)
|
|
1504
|
+
self._reveal_selection(self._row_widgets[target], direction=1)
|
|
1505
|
+
|
|
1506
|
+
def show_server_error(self, server: MCPServerInfo) -> None:
|
|
1507
|
+
"""Open the read-only error detail modal for `server`.
|
|
1508
|
+
|
|
1509
|
+
Args:
|
|
1510
|
+
server: Failed MCP server to inspect.
|
|
1511
|
+
"""
|
|
1512
|
+
self.app.push_screen(MCPServerErrorScreen(server))
|
|
1513
|
+
|
|
1514
|
+
def action_toggle_expand(self) -> None:
|
|
1515
|
+
"""Toggle expand on a tool row, log in, or show error details.
|
|
1516
|
+
|
|
1517
|
+
Tool rows expand/collapse as before; activating a header row for
|
|
1518
|
+
a server in `unauthenticated` state dismisses the viewer with the
|
|
1519
|
+
server name so the app can drive in-TUI OAuth login. Activating an
|
|
1520
|
+
`error` header opens a read-only detail modal. Headers for other
|
|
1521
|
+
states (ok, awaiting reconnect, disabled) remain no-ops.
|
|
1522
|
+
"""
|
|
1523
|
+
if not self._row_widgets:
|
|
1524
|
+
return
|
|
1525
|
+
row = self._row_widgets[self._selected_index]
|
|
1526
|
+
if isinstance(row, MCPToolItem):
|
|
1527
|
+
row.toggle_expand()
|
|
1528
|
+
# The new height isn't reflected until after the next layout
|
|
1529
|
+
# pass, so defer the visibility scroll. Without this, expanding
|
|
1530
|
+
# a row near the viewport bottom leaves its new body off-screen.
|
|
1531
|
+
self.call_after_refresh(row.scroll_visible)
|
|
1532
|
+
return
|
|
1533
|
+
server = row.server
|
|
1534
|
+
if server.needs_attention():
|
|
1535
|
+
self.dismiss(server.name)
|
|
1536
|
+
return
|
|
1537
|
+
if server.status == "error":
|
|
1538
|
+
self.show_server_error(server)
|
|
1539
|
+
|
|
1540
|
+
def action_toggle_all(self) -> None:
|
|
1541
|
+
"""Expand or collapse every visible tool at once.
|
|
1542
|
+
|
|
1543
|
+
If any visible tool is collapsed, expand all; otherwise collapse all.
|
|
1544
|
+
Operates on tool rows only — server headers are not expandable.
|
|
1545
|
+
Hidden tools (filtered out) keep their state.
|
|
1546
|
+
"""
|
|
1547
|
+
tools = self._tool_widgets
|
|
1548
|
+
if not tools:
|
|
1549
|
+
return
|
|
1550
|
+
any_collapsed = any(not w._expanded for w in tools)
|
|
1551
|
+
for widget in tools:
|
|
1552
|
+
widget.set_expanded(any_collapsed)
|
|
1553
|
+
|
|
1554
|
+
def action_page_up(self) -> None:
|
|
1555
|
+
"""Scroll up by one page and snap selection to the topmost visible row.
|
|
1556
|
+
|
|
1557
|
+
Without the selection snap, `_selected_index` would still point at
|
|
1558
|
+
the now-offscreen row, and a subsequent `Up`/`Down` press would
|
|
1559
|
+
yank the viewport back to it (see `action_move_up` / `_move_down`,
|
|
1560
|
+
which scroll the offscreen selection back into view).
|
|
1561
|
+
"""
|
|
1562
|
+
if not self._row_widgets:
|
|
1563
|
+
return
|
|
1564
|
+
scroll = self.query_one(".mcp-list", VerticalScroll)
|
|
1565
|
+
scroll.scroll_page_up()
|
|
1566
|
+
self.call_after_refresh(self._snap_selection_to_topmost_visible)
|
|
1567
|
+
|
|
1568
|
+
def action_page_down(self) -> None:
|
|
1569
|
+
"""Scroll down by one page and snap selection to the bottommost visible row.
|
|
1570
|
+
|
|
1571
|
+
Mirror of `action_page_up`: prevents a subsequent arrow key from
|
|
1572
|
+
scrolling the viewport back to a now-offscreen selection.
|
|
1573
|
+
"""
|
|
1574
|
+
if not self._row_widgets:
|
|
1575
|
+
return
|
|
1576
|
+
scroll = self.query_one(".mcp-list", VerticalScroll)
|
|
1577
|
+
scroll.scroll_page_down()
|
|
1578
|
+
self.call_after_refresh(self._snap_selection_to_bottommost_visible)
|
|
1579
|
+
|
|
1580
|
+
def _snap_selection_to_topmost_visible(self) -> None:
|
|
1581
|
+
"""Move selection to the first row whose top is at or below the viewport top."""
|
|
1582
|
+
if not self._row_widgets:
|
|
1583
|
+
return
|
|
1584
|
+
scroll = self.query_one(".mcp-list", VerticalScroll)
|
|
1585
|
+
top = scroll.region.y
|
|
1586
|
+
for idx, widget in enumerate(self._row_widgets):
|
|
1587
|
+
if widget.region.y >= top:
|
|
1588
|
+
self._move_to(idx)
|
|
1589
|
+
return
|
|
1590
|
+
|
|
1591
|
+
def _snap_selection_to_bottommost_visible(self) -> None:
|
|
1592
|
+
"""Move selection to the last row whose bottom fits inside the viewport."""
|
|
1593
|
+
if not self._row_widgets:
|
|
1594
|
+
return
|
|
1595
|
+
scroll = self.query_one(".mcp-list", VerticalScroll)
|
|
1596
|
+
bottom = scroll.region.y + scroll.region.height
|
|
1597
|
+
target: int | None = None
|
|
1598
|
+
for idx, widget in enumerate(self._row_widgets):
|
|
1599
|
+
if widget.region.y + widget.region.height <= bottom:
|
|
1600
|
+
target = idx
|
|
1601
|
+
if target is not None:
|
|
1602
|
+
self._move_to(target)
|
|
1603
|
+
|
|
1604
|
+
def action_cancel(self) -> None:
|
|
1605
|
+
"""Close the viewer without selecting a server to log into."""
|
|
1606
|
+
self.dismiss(None)
|
|
1607
|
+
|
|
1608
|
+
def action_reconnect(self) -> None:
|
|
1609
|
+
"""Dismiss with the reconnect sentinel when a login is pending.
|
|
1610
|
+
|
|
1611
|
+
Bindings are static, so the keybind is always bound; this guard
|
|
1612
|
+
is what makes it a no-op when nothing is queued.
|
|
1613
|
+
"""
|
|
1614
|
+
if not self._pending_reconnect:
|
|
1615
|
+
return
|
|
1616
|
+
self.dismiss(MCP_VIEWER_RECONNECT_REQUEST)
|
|
1617
|
+
|
|
1618
|
+
def action_toggle_disable(self) -> None:
|
|
1619
|
+
"""Hand off a toggle-disable request to the app without dismissing.
|
|
1620
|
+
|
|
1621
|
+
Only fires when a server header is selected — pressing F2 on a
|
|
1622
|
+
tool row is a no-op. The app's callback persists the new state
|
|
1623
|
+
and is expected to call `refresh_server_info(..., select_server=)`
|
|
1624
|
+
on this screen, so the user sees the new status without the
|
|
1625
|
+
screen tearing down (which would flicker and reset selection).
|
|
1626
|
+
"""
|
|
1627
|
+
if not self._row_widgets:
|
|
1628
|
+
return
|
|
1629
|
+
row = self._row_widgets[self._selected_index]
|
|
1630
|
+
if isinstance(row, MCPToolItem):
|
|
1631
|
+
return
|
|
1632
|
+
if self._on_toggle_disable is None:
|
|
1633
|
+
return
|
|
1634
|
+
self.app.call_later(self._on_toggle_disable, row.server.name)
|