fast-agent-mcp 0.3.8__py3-none-any.whl → 0.3.10__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.

Potentially problematic release.


This version of fast-agent-mcp might be problematic. Click here for more details.

@@ -0,0 +1,708 @@
1
+ """Rendering helpers for MCP status information in the enhanced prompt UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import TYPE_CHECKING, Iterable
7
+
8
+ from rich.text import Text
9
+
10
+ from fast_agent.ui import console
11
+
12
+ if TYPE_CHECKING:
13
+ from fast_agent.mcp.mcp_aggregator import ServerStatus
14
+ from fast_agent.mcp.transport_tracking import ChannelSnapshot
15
+
16
+
17
+ # Centralized color configuration
18
+ class Colours:
19
+ """Color constants for MCP status display elements."""
20
+
21
+ # Timeline activity colors (Option A: Mixed Intensity)
22
+ ERROR = "bright_red" # Keep error bright
23
+ DISABLED = "bright_blue" # Keep disabled bright
24
+ RESPONSE = "blue" # Normal blue instead of bright
25
+ REQUEST = "yellow" # Normal yellow instead of bright
26
+ NOTIFICATION = "cyan" # Normal cyan instead of bright
27
+ PING = "dim green" # Keep ping dim
28
+ IDLE = "white dim"
29
+ NONE = "dim"
30
+
31
+ # Channel arrow states
32
+ ARROW_ERROR = "bright_red"
33
+ ARROW_DISABLED = "bright_yellow" # For explicitly disabled/off
34
+ ARROW_METHOD_NOT_ALLOWED = "cyan" # For 405 method not allowed (notification color)
35
+ ARROW_OFF = "black dim"
36
+ ARROW_IDLE = "bright_cyan" # Connected but no activity
37
+ ARROW_ACTIVE = "bright_green" # Connected with activity
38
+
39
+ # Capability token states
40
+ TOKEN_ERROR = "bright_red"
41
+ TOKEN_WARNING = "bright_cyan"
42
+ TOKEN_DISABLED = "dim"
43
+ TOKEN_HIGHLIGHTED = "bright_yellow"
44
+ TOKEN_ENABLED = "bright_green"
45
+
46
+ # Text elements
47
+ TEXT_DIM = "dim"
48
+ TEXT_DEFAULT = "default" # Use terminal's default text color
49
+ TEXT_BRIGHT = "bright_white"
50
+ TEXT_ERROR = "bright_red"
51
+ TEXT_WARNING = "bright_yellow"
52
+ TEXT_SUCCESS = "bright_green"
53
+ TEXT_INFO = "bright_blue"
54
+ TEXT_CYAN = "cyan"
55
+
56
+
57
+ # Color mappings for different contexts
58
+ TIMELINE_COLORS = {
59
+ "error": Colours.ERROR,
60
+ "disabled": Colours.DISABLED,
61
+ "response": Colours.RESPONSE,
62
+ "request": Colours.REQUEST,
63
+ "notification": Colours.NOTIFICATION,
64
+ "ping": Colours.PING,
65
+ "none": Colours.IDLE,
66
+ }
67
+
68
+ TIMELINE_COLORS_STDIO = {
69
+ "error": Colours.ERROR,
70
+ "request": Colours.TOKEN_ENABLED, # All activity shows as bright green
71
+ "response": Colours.TOKEN_ENABLED,
72
+ "notification": Colours.TOKEN_ENABLED,
73
+ "ping": Colours.PING,
74
+ "none": Colours.IDLE,
75
+ }
76
+
77
+
78
+ def _format_compact_duration(seconds: float | None) -> str | None:
79
+ if seconds is None:
80
+ return None
81
+ total = int(seconds)
82
+ if total < 1:
83
+ return "<1s"
84
+ mins, secs = divmod(total, 60)
85
+ if mins == 0:
86
+ return f"{secs}s"
87
+ hours, mins = divmod(mins, 60)
88
+ if hours == 0:
89
+ return f"{mins}m{secs:02d}s"
90
+ days, hours = divmod(hours, 24)
91
+ if days == 0:
92
+ return f"{hours}h{mins:02d}m"
93
+ return f"{days}d{hours:02d}h"
94
+
95
+
96
+ def _summarise_call_counts(call_counts: dict[str, int]) -> str | None:
97
+ if not call_counts:
98
+ return None
99
+ ordered = sorted(call_counts.items(), key=lambda item: item[0])
100
+ return ", ".join(f"{name}:{count}" for name, count in ordered)
101
+
102
+
103
+ def _format_session_id(session_id: str | None) -> Text:
104
+ text = Text()
105
+ if not session_id:
106
+ text.append("none", style="yellow")
107
+ return text
108
+ if session_id == "local":
109
+ text.append("local", style="cyan")
110
+ return text
111
+
112
+ # Only trim if excessively long (>24 chars)
113
+ value = session_id
114
+ if len(session_id) > 24:
115
+ # Trim middle to preserve start and end
116
+ value = f"{session_id[:10]}...{session_id[-10:]}"
117
+ text.append(value, style="green")
118
+ return text
119
+
120
+
121
+ def _build_aligned_field(
122
+ label: str, value: Text | str, *, label_width: int = 9, value_style: str = Colours.TEXT_DEFAULT
123
+ ) -> Text:
124
+ field = Text()
125
+ field.append(f"{label:<{label_width}}: ", style="dim")
126
+ if isinstance(value, Text):
127
+ field.append_text(value)
128
+ else:
129
+ field.append(value, style=value_style)
130
+ return field
131
+
132
+
133
+ def _cap_attr(source, attr: str | None) -> bool:
134
+ if source is None:
135
+ return False
136
+ target = source
137
+ if attr:
138
+ if isinstance(source, dict):
139
+ target = source.get(attr)
140
+ else:
141
+ target = getattr(source, attr, None)
142
+ if isinstance(target, bool):
143
+ return target
144
+ return bool(target)
145
+
146
+
147
+ def _format_capability_shorthand(
148
+ status: ServerStatus, template_expected: bool
149
+ ) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
150
+ caps = status.server_capabilities
151
+ tools = getattr(caps, "tools", None)
152
+ prompts = getattr(caps, "prompts", None)
153
+ resources = getattr(caps, "resources", None)
154
+ logging_caps = getattr(caps, "logging", None)
155
+ completion_caps = (
156
+ getattr(caps, "completion", None)
157
+ or getattr(caps, "completions", None)
158
+ or getattr(caps, "respond", None)
159
+ )
160
+ experimental_caps = getattr(caps, "experimental", None)
161
+
162
+ instructions_available = bool(status.instructions_available)
163
+ instructions_enabled = status.instructions_enabled
164
+
165
+ entries = [
166
+ ("To", _cap_attr(tools, None), _cap_attr(tools, "listChanged")),
167
+ ("Pr", _cap_attr(prompts, None), _cap_attr(prompts, "listChanged")),
168
+ (
169
+ "Re",
170
+ _cap_attr(resources, "read") or _cap_attr(resources, None),
171
+ _cap_attr(resources, "listChanged"),
172
+ ),
173
+ ("Rs", _cap_attr(resources, "subscribe"), _cap_attr(resources, "subscribe")),
174
+ ("Lo", _cap_attr(logging_caps, None), False),
175
+ ("Co", _cap_attr(completion_caps, None), _cap_attr(completion_caps, "listChanged")),
176
+ ("Ex", _cap_attr(experimental_caps, None), False),
177
+ ]
178
+
179
+ if not instructions_available:
180
+ entries.append(("In", False, False))
181
+ elif instructions_enabled is False:
182
+ entries.append(("In", "red", False))
183
+ elif instructions_enabled is None and not template_expected:
184
+ entries.append(("In", "blue", False))
185
+ elif instructions_enabled is None:
186
+ entries.append(("In", True, False))
187
+ elif template_expected:
188
+ entries.append(("In", True, False))
189
+ else:
190
+ entries.append(("In", "blue", False))
191
+
192
+ if status.roots_configured:
193
+ entries.append(("Ro", True, False))
194
+ else:
195
+ entries.append(("Ro", False, False))
196
+
197
+ mode = (status.elicitation_mode or "").lower()
198
+ if mode == "auto_cancel":
199
+ entries.append(("El", "red", False))
200
+ elif mode and mode != "none":
201
+ entries.append(("El", True, False))
202
+ else:
203
+ entries.append(("El", False, False))
204
+
205
+ sampling_mode = (status.sampling_mode or "").lower()
206
+ if sampling_mode == "configured":
207
+ entries.append(("Sa", "blue", False))
208
+ elif sampling_mode == "auto":
209
+ entries.append(("Sa", True, False))
210
+ else:
211
+ entries.append(("Sa", False, False))
212
+
213
+ entries.append(("Sp", bool(status.spoofing_enabled), False))
214
+
215
+ def token_style(supported, highlighted) -> str:
216
+ if supported == "red":
217
+ return Colours.TOKEN_ERROR
218
+ if supported == "blue":
219
+ return Colours.TOKEN_WARNING
220
+ if not supported:
221
+ return Colours.TOKEN_DISABLED
222
+ if highlighted:
223
+ return Colours.TOKEN_HIGHLIGHTED
224
+ return Colours.TOKEN_ENABLED
225
+
226
+ tokens = [
227
+ (label, token_style(supported, highlighted)) for label, supported, highlighted in entries
228
+ ]
229
+ return tokens[:8], tokens[8:]
230
+
231
+
232
+ def _build_capability_text(tokens: list[tuple[str, str]]) -> Text:
233
+ line = Text()
234
+ host_boundary_inserted = False
235
+ for idx, (label, style) in enumerate(tokens):
236
+ if idx:
237
+ line.append(" ")
238
+ if not host_boundary_inserted and label == "Ro":
239
+ line.append("• ", style="dim")
240
+ host_boundary_inserted = True
241
+ line.append(label, style=style)
242
+ return line
243
+
244
+
245
+ def _format_relative_time(dt: datetime | None) -> str:
246
+ if dt is None:
247
+ return "never"
248
+ try:
249
+ now = datetime.now(timezone.utc)
250
+ except Exception:
251
+ now = datetime.utcnow().replace(tzinfo=timezone.utc)
252
+ seconds = max(0, (now - dt).total_seconds())
253
+ return _format_compact_duration(seconds) or "<1s"
254
+
255
+
256
+ def _format_label(label: str, width: int = 10) -> str:
257
+ return f"{label:<{width}}" if len(label) < width else label
258
+
259
+
260
+ def _build_inline_timeline(buckets: Iterable[str]) -> str:
261
+ """Build a compact timeline string for inline display."""
262
+ timeline = " [dim]10m[/dim] "
263
+ for state in buckets:
264
+ color = TIMELINE_COLORS.get(state, Colours.NONE)
265
+ if state in {"idle", "none"}:
266
+ symbol = "·"
267
+ elif state == "request":
268
+ symbol = "◆" # Diamond for requests - rare and important
269
+ else:
270
+ symbol = "●" # Circle for other activity
271
+ timeline += f"[bold {color}]{symbol}[/bold {color}]"
272
+ timeline += " [dim]now[/dim]"
273
+ return timeline
274
+
275
+
276
+ def _render_channel_summary(status: ServerStatus, indent: str, total_width: int) -> None:
277
+ snapshot = getattr(status, "transport_channels", None)
278
+ if snapshot is None:
279
+ return
280
+
281
+ # Show channel types based on what's available
282
+ entries: list[tuple[str, str, ChannelSnapshot | None]] = []
283
+
284
+ # Check if we have HTTP transport channels
285
+ http_channels = [
286
+ getattr(snapshot, "get", None),
287
+ getattr(snapshot, "post_sse", None),
288
+ getattr(snapshot, "post_json", None),
289
+ ]
290
+
291
+ # Check if we have stdio transport channel
292
+ stdio_channel = getattr(snapshot, "stdio", None)
293
+
294
+ if any(channel is not None for channel in http_channels):
295
+ # HTTP transport - show the original three channels
296
+ entries = [
297
+ ("GET (SSE)", "◀", getattr(snapshot, "get", None)),
298
+ ("POST (SSE)", "▶", getattr(snapshot, "post_sse", None)),
299
+ ("POST (JSON)", "▶", getattr(snapshot, "post_json", None)),
300
+ ]
301
+ elif stdio_channel is not None:
302
+ # STDIO transport - show single bidirectional channel
303
+ entries = [
304
+ ("STDIO", "⇄", stdio_channel),
305
+ ]
306
+
307
+ # Skip if no channels have data
308
+ if not any(channel is not None for _, _, channel in entries):
309
+ return
310
+
311
+ console.console.print() # Add space before channels
312
+
313
+ # Determine if we're showing stdio or HTTP channels
314
+ is_stdio = stdio_channel is not None
315
+
316
+ # Get transport type for display
317
+ transport = getattr(status, "transport", None) or "unknown"
318
+ transport_display = transport.upper() if transport != "unknown" else "Channels"
319
+
320
+ # Header with column labels
321
+ header = Text(indent)
322
+ header.append(f"┌ {transport_display} ", style="dim")
323
+
324
+ # Calculate padding needed based on transport display length
325
+ # Base structure: "┌ " (2) + transport_display + " " (1) + "─" padding to align with columns
326
+ header_prefix_len = 3 + len(transport_display)
327
+
328
+ if is_stdio:
329
+ # Simplified header for stdio: just activity column
330
+ # Need to align with "│ ⇄ STDIO 10m ●●●●●●●●●●●●●●●●●●●● now 29"
331
+ # That's: "│ " + arrow + " " + label(13) + "10m " + dots(20) + " now" = 47 chars
332
+ # Then: " " + activity(8) = 10 chars
333
+ # Total content width = 47 + 10 = 57 chars
334
+ # So we need 47 - header_prefix_len dashes before "activity"
335
+ dash_count = max(1, 47 - header_prefix_len)
336
+ header.append("─" * dash_count, style="dim")
337
+ header.append(" activity", style="dim")
338
+ else:
339
+ # Original header for HTTP channels
340
+ # Need to align with the req/resp/notif/ping columns
341
+ # Structure: "│ " + arrow + " " + label(13) + "10m " + dots(20) + " now" = 47 chars
342
+ # Then: " " + req(5) + " " + resp(5) + " " + notif(5) + " " + ping(5) = 25 chars
343
+ # Total content width = 47 + 25 = 72 chars
344
+ # So we need 47 - header_prefix_len dashes before the column headers
345
+ dash_count = max(1, 47 - header_prefix_len)
346
+ header.append("─" * dash_count, style="dim")
347
+ header.append(" req resp notif ping", style="dim")
348
+
349
+ console.console.print(header)
350
+
351
+ # Empty row after header for cleaner spacing
352
+ empty_header = Text(indent)
353
+ empty_header.append("│", style="dim")
354
+ console.console.print(empty_header)
355
+
356
+ # Collect any errors to show at bottom
357
+ errors = []
358
+
359
+ # Get appropriate timeline color map
360
+ timeline_color_map = TIMELINE_COLORS_STDIO if is_stdio else TIMELINE_COLORS
361
+
362
+ for label, arrow, channel in entries:
363
+ line = Text(indent)
364
+ line.append("│ ", style="dim")
365
+
366
+ # Determine arrow color based on state
367
+ arrow_style = Colours.ARROW_OFF # default no channel
368
+ if channel:
369
+ state = (channel.state or "open").lower()
370
+
371
+ # Check for 405 status code (method not allowed = not an error, just unsupported)
372
+ if channel.last_status_code == 405:
373
+ arrow_style = Colours.ARROW_METHOD_NOT_ALLOWED
374
+ # Don't add 405 to errors list - it's not an error, just method not supported
375
+ # Error state (non-405 errors)
376
+ elif state == "error":
377
+ arrow_style = Colours.ARROW_ERROR
378
+ if channel.last_error and channel.last_status_code != 405:
379
+ error_msg = channel.last_error
380
+ if channel.last_status_code:
381
+ errors.append(
382
+ (label.split()[0], f"{error_msg} ({channel.last_status_code})")
383
+ )
384
+ else:
385
+ errors.append((label.split()[0], error_msg))
386
+ # Explicitly disabled or off
387
+ elif state in {"off", "disabled"}:
388
+ arrow_style = Colours.ARROW_OFF
389
+ # No activity (idle)
390
+ elif channel.request_count == 0 and channel.response_count == 0:
391
+ arrow_style = Colours.ARROW_IDLE
392
+ # Active/connected with activity
393
+ elif state in {"open", "connected"}:
394
+ arrow_style = Colours.ARROW_ACTIVE
395
+ # Fallback for other states
396
+ else:
397
+ arrow_style = Colours.ARROW_IDLE
398
+
399
+ # Arrow and label with better spacing
400
+ # Use hollow arrow for 405 Method Not Allowed
401
+ if channel and channel.last_status_code == 405:
402
+ # Convert solid arrows to hollow for 405
403
+ hollow_arrows = {"◀": "◁", "▶": "▷", "⇄": "⇄"} # bidirectional stays same
404
+ display_arrow = hollow_arrows.get(arrow, arrow)
405
+ else:
406
+ display_arrow = arrow
407
+ line.append(display_arrow, style=arrow_style)
408
+ line.append(f" {label:<13}", style=Colours.TEXT_DEFAULT)
409
+
410
+ # Always show timeline (dim black dots if no data)
411
+ line.append("10m ", style="dim")
412
+ if channel and channel.activity_buckets:
413
+ # Show actual activity
414
+ for bucket_state in channel.activity_buckets:
415
+ color = timeline_color_map.get(bucket_state, "dim")
416
+ if bucket_state in {"idle", "none"}:
417
+ symbol = "·"
418
+ elif bucket_state == "request":
419
+ symbol = "◆" # Diamond for requests - rare and important
420
+ else:
421
+ symbol = "●" # Circle for other activity
422
+ line.append(symbol, style=f"bold {color}")
423
+ else:
424
+ # Show dim dots for no activity
425
+ for _ in range(20):
426
+ line.append("·", style="black dim")
427
+ line.append(" now", style="dim")
428
+
429
+ # Metrics - different layouts for stdio vs HTTP
430
+ if is_stdio:
431
+ # Simplified activity column for stdio
432
+ if channel and channel.message_count > 0:
433
+ activity = str(channel.message_count).rjust(8)
434
+ activity_style = Colours.TEXT_DEFAULT
435
+ else:
436
+ activity = "-".rjust(8)
437
+ activity_style = Colours.TEXT_DIM
438
+ line.append(f" {activity}", style=activity_style)
439
+ else:
440
+ # Original HTTP columns
441
+ if channel:
442
+ # Show "-" for shut/disabled channels (405, off, disabled states)
443
+ channel_state = (channel.state or "open").lower()
444
+ is_shut = (
445
+ channel.last_status_code == 405 or
446
+ channel_state in {"off", "disabled"} or
447
+ (channel_state == "error" and channel.last_status_code == 405)
448
+ )
449
+
450
+ if is_shut:
451
+ req = "-".rjust(5)
452
+ resp = "-".rjust(5)
453
+ notif = "-".rjust(5)
454
+ ping = "-".rjust(5)
455
+ metrics_style = Colours.TEXT_DIM
456
+ else:
457
+ req = str(channel.request_count).rjust(5)
458
+ resp = str(channel.response_count).rjust(5)
459
+ notif = str(channel.notification_count).rjust(5)
460
+ ping = str(channel.ping_count if channel.ping_count else "-").rjust(5)
461
+ metrics_style = Colours.TEXT_DEFAULT
462
+ else:
463
+ req = "-".rjust(5)
464
+ resp = "-".rjust(5)
465
+ notif = "-".rjust(5)
466
+ ping = "-".rjust(5)
467
+ metrics_style = Colours.TEXT_DIM
468
+ line.append(f" {req} {resp} {notif} {ping}", style=metrics_style)
469
+
470
+ console.console.print(line)
471
+
472
+ # Debug: print the raw line length
473
+ # import sys
474
+ # print(f"Line length: {len(line.plain)}", file=sys.stderr)
475
+
476
+ # Show errors at bottom if any
477
+ if errors:
478
+ # Empty row before errors
479
+ empty_line = Text(indent)
480
+ empty_line.append("│", style="dim")
481
+ console.console.print(empty_line)
482
+
483
+ for channel_type, error_msg in errors:
484
+ error_line = Text(indent)
485
+ error_line.append("│ ", style=Colours.TEXT_DIM)
486
+ error_line.append("⚠ ", style=Colours.TEXT_WARNING)
487
+ error_line.append(f"{channel_type}: ", style=Colours.TEXT_DEFAULT)
488
+ # Truncate long error messages
489
+ if len(error_msg) > 60:
490
+ error_msg = error_msg[:57] + "..."
491
+ error_line.append(error_msg, style=Colours.TEXT_ERROR)
492
+ console.console.print(error_line)
493
+
494
+ # Legend if any timelines shown
495
+ has_timelines = any(channel and channel.activity_buckets for _, _, channel in entries)
496
+
497
+ if has_timelines:
498
+ # Empty row before footer with legend
499
+ empty_before = Text(indent)
500
+ empty_before.append("│", style="dim")
501
+ console.console.print(empty_before)
502
+
503
+ # Footer with legend
504
+ footer = Text(indent)
505
+ footer.append("└", style="dim")
506
+
507
+ if has_timelines:
508
+ footer.append(" legend: ", style="dim")
509
+
510
+ if is_stdio:
511
+ # Simplified legend for stdio: just activity vs idle
512
+ legend_map = [
513
+ ("activity", f"bold {Colours.TOKEN_ENABLED}"),
514
+ ("idle", Colours.IDLE),
515
+ ]
516
+ else:
517
+ # Full legend for HTTP channels
518
+ legend_map = [
519
+ ("error", f"bold {Colours.ERROR}"),
520
+ ("response", f"bold {Colours.RESPONSE}"),
521
+ ("request", f"bold {Colours.REQUEST}"),
522
+ ("notification", f"bold {Colours.NOTIFICATION}"),
523
+ ("ping", Colours.PING),
524
+ ("idle", Colours.IDLE),
525
+ ]
526
+
527
+ for i, (name, color) in enumerate(legend_map):
528
+ if i > 0:
529
+ footer.append(" ", style="dim")
530
+ if name == "idle":
531
+ symbol = "·"
532
+ elif name == "request":
533
+ symbol = "◆" # Diamond for requests
534
+ else:
535
+ symbol = "●"
536
+ footer.append(symbol, style=f"{color}")
537
+ footer.append(f" {name}", style="dim")
538
+
539
+ console.console.print(footer)
540
+
541
+ # Add blank line for spacing before capabilities
542
+ console.console.print()
543
+
544
+
545
+ async def render_mcp_status(agent, indent: str = "") -> None:
546
+ server_status_map = {}
547
+ if hasattr(agent, "get_server_status") and callable(getattr(agent, "get_server_status")):
548
+ try:
549
+ server_status_map = await agent.get_server_status()
550
+ except Exception:
551
+ server_status_map = {}
552
+
553
+ if not server_status_map:
554
+ console.console.print(f"{indent}[dim]•[/dim] [dim]No MCP status available[/dim]")
555
+ return
556
+
557
+ template_expected = False
558
+ if hasattr(agent, "config"):
559
+ template_expected = "{{serverInstructions}}" in str(
560
+ getattr(agent.config, "instruction", "")
561
+ )
562
+
563
+ try:
564
+ total_width = console.console.size.width
565
+ except Exception:
566
+ total_width = 80
567
+
568
+ def render_header(label: Text, right: Text | None = None) -> None:
569
+ line = Text()
570
+ line.append_text(label)
571
+ line.append(" ")
572
+
573
+ separator_width = total_width - line.cell_len
574
+ if right and right.cell_len > 0:
575
+ separator_width -= right.cell_len
576
+ separator_width = max(1, separator_width)
577
+ line.append("─" * separator_width, style="dim")
578
+ line.append_text(right)
579
+ else:
580
+ line.append("─" * max(1, separator_width), style="dim")
581
+
582
+ console.console.print()
583
+ console.console.print(line)
584
+ console.console.print()
585
+
586
+ server_items = list(sorted(server_status_map.items()))
587
+
588
+ for index, (server, status) in enumerate(server_items, start=1):
589
+ primary_caps, secondary_caps = _format_capability_shorthand(status, template_expected)
590
+
591
+ impl_name = status.implementation_name or status.server_name or "unknown"
592
+ impl_display = impl_name[:30]
593
+ if len(impl_name) > 30:
594
+ impl_display = impl_display[:27] + "..."
595
+
596
+ version_display = status.implementation_version or ""
597
+ if len(version_display) > 12:
598
+ version_display = version_display[:9] + "..."
599
+
600
+ header_label = Text(indent)
601
+ header_label.append("▎", style=Colours.TEXT_CYAN)
602
+ header_label.append("●", style=f"dim {Colours.TEXT_CYAN}")
603
+ header_label.append(f" [{index:2}] ", style=Colours.TEXT_CYAN)
604
+ header_label.append(server, style=f"{Colours.TEXT_INFO} bold")
605
+ render_header(header_label)
606
+
607
+ # First line: name and version
608
+ meta_line = Text(indent + " ")
609
+ meta_fields: list[Text] = []
610
+ meta_fields.append(_build_aligned_field("name", impl_display))
611
+ if version_display:
612
+ meta_fields.append(_build_aligned_field("version", version_display))
613
+
614
+ for idx, field in enumerate(meta_fields):
615
+ if idx:
616
+ meta_line.append(" ", style="dim")
617
+ meta_line.append_text(field)
618
+
619
+ client_parts = []
620
+ if status.client_info_name:
621
+ client_parts.append(status.client_info_name)
622
+ if status.client_info_version:
623
+ client_parts.append(status.client_info_version)
624
+ client_display = " ".join(client_parts)
625
+ if len(client_display) > 24:
626
+ client_display = client_display[:21] + "..."
627
+
628
+ if client_display:
629
+ meta_line.append(" | ", style="dim")
630
+ meta_line.append_text(_build_aligned_field("client", client_display))
631
+
632
+ console.console.print(meta_line)
633
+
634
+ # Second line: session (on its own line)
635
+ session_line = Text(indent + " ")
636
+ session_text = _format_session_id(status.session_id)
637
+ session_line.append_text(_build_aligned_field("session", session_text))
638
+ console.console.print(session_line)
639
+ console.console.print()
640
+
641
+ # Build status segments
642
+ state_segments: list[Text] = []
643
+
644
+ duration = _format_compact_duration(status.staleness_seconds)
645
+ if duration:
646
+ last_text = Text("last activity: ", style=Colours.TEXT_DIM)
647
+ last_text.append(duration, style=Colours.TEXT_DEFAULT)
648
+ last_text.append(" ago", style=Colours.TEXT_DIM)
649
+ state_segments.append(last_text)
650
+
651
+ if status.error_message and status.is_connected is False:
652
+ state_segments.append(Text(status.error_message, style=Colours.TEXT_ERROR))
653
+
654
+ instr_available = bool(status.instructions_available)
655
+ if instr_available and status.instructions_enabled is False:
656
+ state_segments.append(Text("instructions disabled", style=Colours.TEXT_ERROR))
657
+ elif instr_available and not template_expected:
658
+ state_segments.append(Text("template missing", style=Colours.TEXT_WARNING))
659
+
660
+ if status.spoofing_enabled:
661
+ state_segments.append(Text("client spoof", style=Colours.TEXT_WARNING))
662
+
663
+ # Main status line (without transport and connected)
664
+ if state_segments:
665
+ status_line = Text(indent + " ")
666
+ for idx, segment in enumerate(state_segments):
667
+ if idx:
668
+ status_line.append(" | ", style="dim")
669
+ status_line.append_text(segment)
670
+ console.console.print(status_line)
671
+
672
+ # MCP protocol calls made (only shows calls that have actually been invoked)
673
+ calls = _summarise_call_counts(status.call_counts)
674
+ if calls:
675
+ calls_line = Text(indent + " ")
676
+ calls_line.append("mcp calls: ", style=Colours.TEXT_DIM)
677
+ calls_line.append(calls, style=Colours.TEXT_DEFAULT)
678
+ console.console.print(calls_line)
679
+ _render_channel_summary(status, indent, total_width)
680
+
681
+ combined_tokens = primary_caps + secondary_caps
682
+ prefix = Text(indent)
683
+ prefix.append("─| ", style="dim")
684
+ suffix = Text(" |", style="dim")
685
+
686
+ caps_content = (
687
+ _build_capability_text(combined_tokens)
688
+ if combined_tokens
689
+ else Text("none", style="dim")
690
+ )
691
+
692
+ caps_display = caps_content.copy()
693
+ available = max(0, total_width - prefix.cell_len - suffix.cell_len)
694
+ if caps_display.cell_len > available:
695
+ caps_display.truncate(available)
696
+
697
+ banner_line = Text()
698
+ banner_line.append_text(prefix)
699
+ banner_line.append_text(caps_display)
700
+ banner_line.append_text(suffix)
701
+ remaining = total_width - banner_line.cell_len
702
+ if remaining > 0:
703
+ banner_line.append("─" * remaining, style="dim")
704
+
705
+ console.console.print(banner_line)
706
+
707
+ if index != len(server_items):
708
+ console.console.print()