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.
Files changed (163) hide show
  1. deepagents_code/__init__.py +42 -0
  2. deepagents_code/__main__.py +6 -0
  3. deepagents_code/_ask_user_types.py +90 -0
  4. deepagents_code/_cli_context.py +96 -0
  5. deepagents_code/_constants.py +41 -0
  6. deepagents_code/_debug.py +148 -0
  7. deepagents_code/_debug_buffer.py +204 -0
  8. deepagents_code/_env_vars.py +411 -0
  9. deepagents_code/_fake_models.py +66 -0
  10. deepagents_code/_git.py +521 -0
  11. deepagents_code/_paths.py +69 -0
  12. deepagents_code/_server_config.py +576 -0
  13. deepagents_code/_session_stats.py +235 -0
  14. deepagents_code/_startup_error.py +45 -0
  15. deepagents_code/_testing_models.py +305 -0
  16. deepagents_code/_textual_patches.py +420 -0
  17. deepagents_code/_tool_stream.py +694 -0
  18. deepagents_code/_version.py +46 -0
  19. deepagents_code/agent.py +1976 -0
  20. deepagents_code/app.py +19239 -0
  21. deepagents_code/app.tcss +438 -0
  22. deepagents_code/approval_mode.py +131 -0
  23. deepagents_code/ask_user.py +306 -0
  24. deepagents_code/auth_display.py +185 -0
  25. deepagents_code/auth_store.py +545 -0
  26. deepagents_code/built_in_skills/__init__.py +5 -0
  27. deepagents_code/built_in_skills/remember/SKILL.md +118 -0
  28. deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
  29. deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
  30. deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
  31. deepagents_code/client/__init__.py +1 -0
  32. deepagents_code/client/commands/__init__.py +1 -0
  33. deepagents_code/client/commands/auth.py +541 -0
  34. deepagents_code/client/commands/config.py +714 -0
  35. deepagents_code/client/commands/mcp.py +250 -0
  36. deepagents_code/client/commands/tools.py +416 -0
  37. deepagents_code/client/launch/__init__.py +1 -0
  38. deepagents_code/client/launch/server.py +978 -0
  39. deepagents_code/client/launch/server_manager.py +540 -0
  40. deepagents_code/client/non_interactive.py +1758 -0
  41. deepagents_code/client/remote_client.py +794 -0
  42. deepagents_code/clipboard.py +217 -0
  43. deepagents_code/command_registry.py +450 -0
  44. deepagents_code/config.py +4829 -0
  45. deepagents_code/config_manifest.py +1451 -0
  46. deepagents_code/configurable_model.py +577 -0
  47. deepagents_code/default_agent_prompt.md +12 -0
  48. deepagents_code/doctor.py +559 -0
  49. deepagents_code/editor.py +142 -0
  50. deepagents_code/event_bus.py +411 -0
  51. deepagents_code/extras_info.py +661 -0
  52. deepagents_code/file_ops.py +576 -0
  53. deepagents_code/formatting.py +135 -0
  54. deepagents_code/goal_rubric.py +497 -0
  55. deepagents_code/goal_tools.py +459 -0
  56. deepagents_code/hooks.py +348 -0
  57. deepagents_code/input.py +1041 -0
  58. deepagents_code/integrations/__init__.py +1 -0
  59. deepagents_code/integrations/openai_codex.py +551 -0
  60. deepagents_code/integrations/sandbox_config.py +198 -0
  61. deepagents_code/integrations/sandbox_factory.py +1124 -0
  62. deepagents_code/integrations/sandbox_provider.py +137 -0
  63. deepagents_code/integrations/sandbox_registry.py +350 -0
  64. deepagents_code/iterm_cursor_guide.py +176 -0
  65. deepagents_code/local_context.py +926 -0
  66. deepagents_code/main.py +3985 -0
  67. deepagents_code/managed_tools.py +642 -0
  68. deepagents_code/mcp_auth.py +1950 -0
  69. deepagents_code/mcp_config.py +176 -0
  70. deepagents_code/mcp_disabled.py +212 -0
  71. deepagents_code/mcp_login_service.py +281 -0
  72. deepagents_code/mcp_oauth_ui.py +199 -0
  73. deepagents_code/mcp_providers/__init__.py +23 -0
  74. deepagents_code/mcp_providers/_registry.py +39 -0
  75. deepagents_code/mcp_providers/base.py +133 -0
  76. deepagents_code/mcp_providers/github.py +102 -0
  77. deepagents_code/mcp_providers/slack.py +175 -0
  78. deepagents_code/mcp_tools.py +2427 -0
  79. deepagents_code/mcp_trust.py +207 -0
  80. deepagents_code/media_utils.py +826 -0
  81. deepagents_code/memory_guard.py +474 -0
  82. deepagents_code/model_config.py +4156 -0
  83. deepagents_code/notifications.py +247 -0
  84. deepagents_code/offload.py +402 -0
  85. deepagents_code/onboarding.py +223 -0
  86. deepagents_code/output.py +69 -0
  87. deepagents_code/paste_collapse.py +103 -0
  88. deepagents_code/project_utils.py +231 -0
  89. deepagents_code/py.typed +0 -0
  90. deepagents_code/reasoning_effort.py +641 -0
  91. deepagents_code/reliable_rubric.py +97 -0
  92. deepagents_code/resume_state.py +237 -0
  93. deepagents_code/server_graph.py +310 -0
  94. deepagents_code/session_end_summary.py +329 -0
  95. deepagents_code/sessions.py +1716 -0
  96. deepagents_code/skills/__init__.py +18 -0
  97. deepagents_code/skills/commands.py +1252 -0
  98. deepagents_code/skills/invocation.py +112 -0
  99. deepagents_code/skills/load.py +196 -0
  100. deepagents_code/skills/trust.py +547 -0
  101. deepagents_code/state_migration.py +136 -0
  102. deepagents_code/subagents.py +278 -0
  103. deepagents_code/system_prompt.md +204 -0
  104. deepagents_code/terminal_capabilities.py +115 -0
  105. deepagents_code/terminal_escape.py +287 -0
  106. deepagents_code/theme.py +891 -0
  107. deepagents_code/todo_list_prompt.md +12 -0
  108. deepagents_code/tool_catalog.py +509 -0
  109. deepagents_code/tool_display.py +367 -0
  110. deepagents_code/tools.py +516 -0
  111. deepagents_code/tui/__init__.py +1 -0
  112. deepagents_code/tui/textual_adapter.py +2553 -0
  113. deepagents_code/tui/widgets/__init__.py +9 -0
  114. deepagents_code/tui/widgets/_js_eval_display.py +139 -0
  115. deepagents_code/tui/widgets/_links.py +261 -0
  116. deepagents_code/tui/widgets/agent_selector.py +420 -0
  117. deepagents_code/tui/widgets/approval.py +602 -0
  118. deepagents_code/tui/widgets/ask_user.py +515 -0
  119. deepagents_code/tui/widgets/auth.py +1997 -0
  120. deepagents_code/tui/widgets/autocomplete.py +890 -0
  121. deepagents_code/tui/widgets/chat_input.py +3181 -0
  122. deepagents_code/tui/widgets/codex_auth.py +452 -0
  123. deepagents_code/tui/widgets/cwd_switch.py +242 -0
  124. deepagents_code/tui/widgets/debug_console.py +868 -0
  125. deepagents_code/tui/widgets/diff.py +252 -0
  126. deepagents_code/tui/widgets/effort_selector.py +189 -0
  127. deepagents_code/tui/widgets/goal_review.py +390 -0
  128. deepagents_code/tui/widgets/goal_status.py +50 -0
  129. deepagents_code/tui/widgets/history.py +194 -0
  130. deepagents_code/tui/widgets/install_confirm.py +248 -0
  131. deepagents_code/tui/widgets/launch_init.py +482 -0
  132. deepagents_code/tui/widgets/loading.py +227 -0
  133. deepagents_code/tui/widgets/mcp_login.py +539 -0
  134. deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
  135. deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
  136. deepagents_code/tui/widgets/message_store.py +999 -0
  137. deepagents_code/tui/widgets/messages.py +3846 -0
  138. deepagents_code/tui/widgets/model_selector.py +2343 -0
  139. deepagents_code/tui/widgets/notification_center.py +456 -0
  140. deepagents_code/tui/widgets/notification_detail.py +257 -0
  141. deepagents_code/tui/widgets/notification_settings.py +170 -0
  142. deepagents_code/tui/widgets/restart_prompt.py +158 -0
  143. deepagents_code/tui/widgets/skill_trust.py +131 -0
  144. deepagents_code/tui/widgets/startup_tip.py +91 -0
  145. deepagents_code/tui/widgets/status.py +781 -0
  146. deepagents_code/tui/widgets/subagent_panel.py +969 -0
  147. deepagents_code/tui/widgets/theme_selector.py +401 -0
  148. deepagents_code/tui/widgets/thread_selector.py +2564 -0
  149. deepagents_code/tui/widgets/tool_renderers.py +190 -0
  150. deepagents_code/tui/widgets/tool_widgets.py +304 -0
  151. deepagents_code/tui/widgets/update_available.py +311 -0
  152. deepagents_code/tui/widgets/update_confirm.py +184 -0
  153. deepagents_code/tui/widgets/update_progress.py +327 -0
  154. deepagents_code/tui/widgets/welcome.py +508 -0
  155. deepagents_code/turn_end_summary.py +522 -0
  156. deepagents_code/ui.py +858 -0
  157. deepagents_code/unicode_security.py +563 -0
  158. deepagents_code/update_check.py +3124 -0
  159. zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
  160. zjcode-0.0.1.dist-info/METADATA +220 -0
  161. zjcode-0.0.1.dist-info/RECORD +163 -0
  162. zjcode-0.0.1.dist-info/WHEEL +4 -0
  163. 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)