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

Files changed (44) hide show
  1. fast_agent/agents/llm_agent.py +59 -37
  2. fast_agent/agents/llm_decorator.py +13 -2
  3. fast_agent/agents/mcp_agent.py +21 -5
  4. fast_agent/agents/tool_agent.py +41 -29
  5. fast_agent/agents/workflow/router_agent.py +2 -1
  6. fast_agent/cli/commands/check_config.py +48 -1
  7. fast_agent/config.py +65 -2
  8. fast_agent/constants.py +3 -0
  9. fast_agent/context.py +42 -9
  10. fast_agent/core/fastagent.py +14 -1
  11. fast_agent/core/logging/listeners.py +1 -1
  12. fast_agent/core/validation.py +31 -33
  13. fast_agent/event_progress.py +2 -3
  14. fast_agent/human_input/form_fields.py +4 -1
  15. fast_agent/interfaces.py +12 -2
  16. fast_agent/llm/fastagent_llm.py +31 -0
  17. fast_agent/llm/model_database.py +2 -2
  18. fast_agent/llm/model_factory.py +8 -1
  19. fast_agent/llm/provider_key_manager.py +1 -0
  20. fast_agent/llm/provider_types.py +1 -0
  21. fast_agent/llm/request_params.py +3 -1
  22. fast_agent/mcp/mcp_aggregator.py +313 -40
  23. fast_agent/mcp/mcp_connection_manager.py +39 -9
  24. fast_agent/mcp/prompt_message_extended.py +2 -2
  25. fast_agent/mcp/skybridge.py +45 -0
  26. fast_agent/mcp/sse_tracking.py +287 -0
  27. fast_agent/mcp/transport_tracking.py +37 -3
  28. fast_agent/mcp/types.py +24 -0
  29. fast_agent/resources/examples/workflows/router.py +1 -0
  30. fast_agent/resources/setup/fastagent.config.yaml +7 -1
  31. fast_agent/ui/console_display.py +946 -84
  32. fast_agent/ui/elicitation_form.py +23 -1
  33. fast_agent/ui/enhanced_prompt.py +153 -58
  34. fast_agent/ui/interactive_prompt.py +57 -34
  35. fast_agent/ui/markdown_truncator.py +942 -0
  36. fast_agent/ui/mcp_display.py +110 -29
  37. fast_agent/ui/plain_text_truncator.py +68 -0
  38. fast_agent/ui/rich_progress.py +4 -1
  39. fast_agent/ui/streaming_buffer.py +449 -0
  40. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/METADATA +4 -3
  41. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/RECORD +44 -38
  42. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/WHEEL +0 -0
  43. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/entry_points.txt +0 -0
  44. {fast_agent_mcp-0.3.13.dist-info → fast_agent_mcp-0.3.15.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,19 @@
1
+ import math
2
+ from contextlib import contextmanager
1
3
  from enum import Enum
2
4
  from json import JSONDecodeError
3
- from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
5
+ from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Union
4
6
 
5
7
  from mcp.types import CallToolResult
8
+ from rich.live import Live
9
+ from rich.markdown import Markdown
6
10
  from rich.panel import Panel
7
11
  from rich.text import Text
8
12
 
13
+ from fast_agent.config import Settings
9
14
  from fast_agent.constants import REASONING
10
15
  from fast_agent.ui import console
16
+ from fast_agent.ui.markdown_truncator import MarkdownTruncator
11
17
  from fast_agent.ui.mcp_ui_utils import UILink
12
18
  from fast_agent.ui.mermaid_utils import (
13
19
  MermaidDiagram,
@@ -15,12 +21,21 @@ from fast_agent.ui.mermaid_utils import (
15
21
  detect_diagram_type,
16
22
  extract_mermaid_diagrams,
17
23
  )
24
+ from fast_agent.ui.plain_text_truncator import PlainTextTruncator
18
25
 
19
26
  if TYPE_CHECKING:
20
27
  from fast_agent.mcp.prompt_message_extended import PromptMessageExtended
28
+ from fast_agent.mcp.skybridge import SkybridgeServerConfig
21
29
 
22
30
  CODE_STYLE = "native"
23
31
 
32
+ MARKDOWN_STREAM_TARGET_RATIO = 0.7
33
+ MARKDOWN_STREAM_REFRESH_PER_SECOND = 4
34
+ MARKDOWN_STREAM_HEIGHT_FUDGE = 1
35
+ PLAIN_STREAM_TARGET_RATIO = 0.9
36
+ PLAIN_STREAM_REFRESH_PER_SECOND = 20
37
+ PLAIN_STREAM_HEIGHT_FUDGE = 1
38
+
24
39
 
25
40
  class MessageType(Enum):
26
41
  """Types of messages that can be displayed."""
@@ -82,33 +97,104 @@ def _prepare_markdown_content(content: str, escape_xml: bool = True) -> str:
82
97
  This ensures XML/HTML tags are displayed as visible text rather than
83
98
  being interpreted as markup by the markdown renderer.
84
99
 
85
- Note: This method does not handle overlapping code blocks (e.g., if inline
86
- code appears within a fenced code block range). In practice, this is not
87
- an issue since markdown syntax doesn't support such overlapping.
100
+ Uses markdown-it parser to properly identify code regions, avoiding
101
+ the issues with regex-based approaches (e.g., backticks inside fenced
102
+ code blocks being misidentified as inline code).
88
103
  """
89
104
  if not escape_xml or not isinstance(content, str):
90
105
  return content
91
106
 
107
+ # Import markdown-it for proper parsing
108
+ from markdown_it import MarkdownIt
109
+
110
+ # Parse the markdown to identify code regions
111
+ parser = MarkdownIt()
112
+ try:
113
+ tokens = parser.parse(content)
114
+ except Exception:
115
+ # If parsing fails, fall back to escaping everything
116
+ # (better safe than corrupting content)
117
+ result = content
118
+ for char, replacement in HTML_ESCAPE_CHARS.items():
119
+ result = result.replace(char, replacement)
120
+ return result
121
+
122
+ # Collect protected ranges from tokens
92
123
  protected_ranges = []
124
+ lines = content.split("\n")
125
+
126
+ def _flatten_tokens(tokens):
127
+ """Recursively flatten token tree."""
128
+ for token in tokens:
129
+ yield token
130
+ if token.children:
131
+ yield from _flatten_tokens(token.children)
132
+
133
+ # Process all tokens to find code blocks and inline code
134
+ for token in _flatten_tokens(tokens):
135
+ if token.map is not None:
136
+ # Block-level tokens with line mapping (fence, code_block)
137
+ if token.type in ("fence", "code_block"):
138
+ start_line = token.map[0]
139
+ end_line = token.map[1]
140
+ start_pos = sum(len(line) + 1 for line in lines[:start_line])
141
+ end_pos = sum(len(line) + 1 for line in lines[:end_line])
142
+ protected_ranges.append((start_pos, end_pos))
143
+
144
+ # Inline code tokens don't have map, but have content
145
+ if token.type == "code_inline":
146
+ # For inline code, we need to find its position in the source
147
+ # The token has the content, but we need to search for it
148
+ # We'll look for the pattern `content` in the content string
149
+ code_content = token.content
150
+ if code_content:
151
+ # Search for this inline code in the content
152
+ # We need to account for the backticks: `content`
153
+ pattern = f"`{code_content}`"
154
+ start = 0
155
+ while True:
156
+ pos = content.find(pattern, start)
157
+ if pos == -1:
158
+ break
159
+ # Check if this position is already in a protected range
160
+ in_protected = any(s <= pos < e for s, e in protected_ranges)
161
+ if not in_protected:
162
+ protected_ranges.append((pos, pos + len(pattern)))
163
+ start = pos + len(pattern)
164
+
165
+ # Check for incomplete code blocks (streaming scenario)
166
+ # Count opening vs closing fences
93
167
  import re
94
168
 
95
- # Protect fenced code blocks (don't escape anything inside these)
96
- code_block_pattern = r"```[\s\S]*?```"
97
- for match in re.finditer(code_block_pattern, content):
98
- protected_ranges.append((match.start(), match.end()))
169
+ fence_pattern = r"^```"
170
+ fences = list(re.finditer(fence_pattern, content, re.MULTILINE))
99
171
 
100
- # Protect inline code (don't escape anything inside these)
101
- inline_code_pattern = r"(?<!`)`(?!``)[^`\n]+`(?!`)"
102
- for match in re.finditer(inline_code_pattern, content):
103
- protected_ranges.append((match.start(), match.end()))
172
+ # If we have an odd number of fences, the last one is incomplete
173
+ if len(fences) % 2 == 1:
174
+ # Protect from the last fence to the end
175
+ last_fence_pos = fences[-1].start()
176
+ # Only add if not already protected
177
+ in_protected = any(s <= last_fence_pos < e for s, e in protected_ranges)
178
+ if not in_protected:
179
+ protected_ranges.append((last_fence_pos, len(content)))
104
180
 
181
+ # Sort and merge overlapping ranges
105
182
  protected_ranges.sort(key=lambda x: x[0])
106
183
 
184
+ # Merge overlapping ranges
185
+ merged_ranges = []
186
+ for start, end in protected_ranges:
187
+ if merged_ranges and start <= merged_ranges[-1][1]:
188
+ # Overlapping or adjacent - merge
189
+ merged_ranges[-1] = (merged_ranges[-1][0], max(end, merged_ranges[-1][1]))
190
+ else:
191
+ merged_ranges.append((start, end))
192
+
107
193
  # Build the escaped content
108
194
  result = []
109
195
  last_end = 0
110
196
 
111
- for start, end in protected_ranges:
197
+ for start, end in merged_ranges:
112
198
  # Escape everything outside protected ranges
113
199
  unprotected_text = content[last_end:start]
114
200
  for char, replacement in HTML_ESCAPE_CHARS.items():
@@ -134,7 +220,7 @@ class ConsoleDisplay:
134
220
  This centralizes the UI display logic used by LLM implementations.
135
221
  """
136
222
 
137
- def __init__(self, config=None) -> None:
223
+ def __init__(self, config: Settings | None = None) -> None:
138
224
  """
139
225
  Initialize the console display handler.
140
226
 
@@ -145,6 +231,29 @@ class ConsoleDisplay:
145
231
  self._markup = config.logger.enable_markup if config else True
146
232
  self._escape_xml = True
147
233
 
234
+ def resolve_streaming_preferences(self) -> tuple[bool, str]:
235
+ """Return whether streaming is enabled plus the active mode."""
236
+ if not self.config:
237
+ return True, "markdown"
238
+
239
+ logger_settings = getattr(self.config, "logger", None)
240
+ if not logger_settings:
241
+ return True, "markdown"
242
+
243
+ streaming_mode = getattr(logger_settings, "streaming", "markdown")
244
+ if streaming_mode not in {"markdown", "plain", "none"}:
245
+ streaming_mode = "markdown"
246
+
247
+ # Legacy compatibility: allow streaming_plain_text override
248
+ if streaming_mode == "markdown" and getattr(logger_settings, "streaming_plain_text", False):
249
+ streaming_mode = "plain"
250
+
251
+ show_chat = bool(getattr(logger_settings, "show_chat", True))
252
+ streaming_display = bool(getattr(logger_settings, "streaming_display", True))
253
+
254
+ enabled = show_chat and streaming_display and streaming_mode != "none"
255
+ return enabled, streaming_mode
256
+
148
257
  @staticmethod
149
258
  def _format_elapsed(elapsed: float) -> str:
150
259
  """Format elapsed seconds for display."""
@@ -223,44 +332,12 @@ class ConsoleDisplay:
223
332
  console.console.print(additional_message, markup=self._markup)
224
333
 
225
334
  # Handle bottom separator with optional metadata
226
- console.console.print()
227
-
228
- if bottom_metadata:
229
- # Apply shortening if requested
230
- display_items = bottom_metadata
231
- if max_item_length:
232
- display_items = self._shorten_items(bottom_metadata, max_item_length)
233
-
234
- # Format the metadata with highlighting, clipped to available width
235
- # Compute available width for the metadata segment (excluding the fixed prefix/suffix)
236
- total_width = console.console.size.width
237
- prefix = Text("─| ")
238
- prefix.stylize("dim")
239
- suffix = Text(" |")
240
- suffix.stylize("dim")
241
- available = max(0, total_width - prefix.cell_len - suffix.cell_len)
242
-
243
- metadata_text = self._format_bottom_metadata(
244
- display_items,
245
- highlight_index,
246
- config["highlight_color"],
247
- max_width=available,
248
- )
249
-
250
- # Create the separator line with metadata
251
- line = Text()
252
- line.append_text(prefix)
253
- line.append_text(metadata_text)
254
- line.append_text(suffix)
255
- remaining = total_width - line.cell_len
256
- if remaining > 0:
257
- line.append("─" * remaining, style="dim")
258
- console.console.print(line, markup=self._markup)
259
- else:
260
- # No metadata - continuous bar
261
- console.console.print("─" * console.console.size.width, style="dim")
262
-
263
- console.console.print()
335
+ self._render_bottom_metadata(
336
+ message_type=message_type,
337
+ bottom_metadata=bottom_metadata,
338
+ highlight_index=highlight_index,
339
+ max_item_length=max_item_length,
340
+ )
264
341
 
265
342
  def _display_content(
266
343
  self,
@@ -483,6 +560,58 @@ class ConsoleDisplay:
483
560
  """
484
561
  return [item[: max_length - 1] + "…" if len(item) > max_length else item for item in items]
485
562
 
563
+ def _render_bottom_metadata(
564
+ self,
565
+ *,
566
+ message_type: MessageType,
567
+ bottom_metadata: List[str] | None,
568
+ highlight_index: int | None,
569
+ max_item_length: int | None,
570
+ ) -> None:
571
+ """
572
+ Render the bottom separator line with optional metadata.
573
+
574
+ Args:
575
+ message_type: The type of message being displayed
576
+ bottom_metadata: Optional list of items to show in the separator
577
+ highlight_index: Optional index of the item to highlight
578
+ max_item_length: Optional maximum length for individual items
579
+ """
580
+ console.console.print()
581
+
582
+ if bottom_metadata:
583
+ display_items = bottom_metadata
584
+ if max_item_length:
585
+ display_items = self._shorten_items(bottom_metadata, max_item_length)
586
+
587
+ total_width = console.console.size.width
588
+ prefix = Text("─| ")
589
+ prefix.stylize("dim")
590
+ suffix = Text(" |")
591
+ suffix.stylize("dim")
592
+ available = max(0, total_width - prefix.cell_len - suffix.cell_len)
593
+
594
+ highlight_color = MESSAGE_CONFIGS[message_type]["highlight_color"]
595
+ metadata_text = self._format_bottom_metadata(
596
+ display_items,
597
+ highlight_index,
598
+ highlight_color,
599
+ max_width=available,
600
+ )
601
+
602
+ line = Text()
603
+ line.append_text(prefix)
604
+ line.append_text(metadata_text)
605
+ line.append_text(suffix)
606
+ remaining = total_width - line.cell_len
607
+ if remaining > 0:
608
+ line.append("─" * remaining, style="dim")
609
+ console.console.print(line, markup=self._markup)
610
+ else:
611
+ console.console.print("─" * console.console.size.width, style="dim")
612
+
613
+ console.console.print()
614
+
486
615
  def _format_bottom_metadata(
487
616
  self,
488
617
  items: List[str],
@@ -538,8 +667,21 @@ class ConsoleDisplay:
538
667
 
539
668
  return formatted
540
669
 
541
- def show_tool_result(self, result: CallToolResult, name: str | None = None) -> None:
542
- """Display a tool result in the new visual style."""
670
+ def show_tool_result(
671
+ self,
672
+ result: CallToolResult,
673
+ name: str | None = None,
674
+ tool_name: str | None = None,
675
+ skybridge_config: "SkybridgeServerConfig | None" = None,
676
+ ) -> None:
677
+ """Display a tool result in the new visual style.
678
+
679
+ Args:
680
+ result: The tool result to display
681
+ name: Optional agent name
682
+ tool_name: Optional tool name for skybridge detection
683
+ skybridge_config: Optional skybridge configuration for the server
684
+ """
543
685
  if not self.config or not self.config.logger.show_tools:
544
686
  return
545
687
 
@@ -548,6 +690,20 @@ class ConsoleDisplay:
548
690
 
549
691
  # Analyze content to determine display format and status
550
692
  content = result.content
693
+ structured_content = getattr(result, "structuredContent", None)
694
+ has_structured = structured_content is not None
695
+
696
+ # Determine if this is a skybridge tool
697
+ is_skybridge_tool = False
698
+ skybridge_resource_uri = None
699
+ if has_structured and tool_name and skybridge_config:
700
+ # Check if this tool is a valid skybridge tool
701
+ for tool_cfg in skybridge_config.tools:
702
+ if tool_cfg.tool_name == tool_name and tool_cfg.is_valid:
703
+ is_skybridge_tool = True
704
+ skybridge_resource_uri = tool_cfg.resource_uri
705
+ break
706
+
551
707
  if result.isError:
552
708
  status = "ERROR"
553
709
  else:
@@ -577,7 +733,7 @@ class ConsoleDisplay:
577
733
  elif channel == "post-sse":
578
734
  transport_info = "HTTP (SSE)"
579
735
  elif channel == "get":
580
- transport_info = "HTTP (SSE)"
736
+ transport_info = "Legacy SSE"
581
737
  elif channel == "resumption":
582
738
  transport_info = "Resumption"
583
739
  elif channel == "stdio":
@@ -591,21 +747,138 @@ class ConsoleDisplay:
591
747
  if isinstance(elapsed, (int, float)):
592
748
  bottom_metadata_items.append(self._format_elapsed(float(elapsed)))
593
749
 
750
+ # Add structured content indicator if present
751
+ if has_structured:
752
+ bottom_metadata_items.append("Structured ■")
753
+
594
754
  bottom_metadata = bottom_metadata_items or None
595
755
 
596
756
  # Build right info (without channel info)
597
757
  right_info = f"[dim]tool result - {status}[/dim]"
598
758
 
599
- # Display using unified method
600
- self.display_message(
601
- content=content,
602
- message_type=MessageType.TOOL_RESULT,
603
- name=name,
604
- right_info=right_info,
605
- bottom_metadata=bottom_metadata,
606
- is_error=result.isError,
607
- truncate_content=True,
608
- )
759
+ if has_structured:
760
+ # Handle structured content display manually to insert it before bottom separator
761
+ # Display main content without bottom separator
762
+ config = MESSAGE_CONFIGS[MessageType.TOOL_RESULT]
763
+ block_color = "red" if result.isError else config["block_color"]
764
+ arrow = config["arrow"]
765
+ arrow_style = config["arrow_style"]
766
+ left = f"[{block_color}]▎[/{block_color}][{arrow_style}]{arrow}[/{arrow_style}]"
767
+ if name:
768
+ left += f" [{block_color if not result.isError else 'red'}]{name}[/{block_color if not result.isError else 'red'}]"
769
+
770
+ # Top separator
771
+ self._create_combined_separator_status(left, right_info)
772
+
773
+ # Main content
774
+ self._display_content(
775
+ content, True, result.isError, MessageType.TOOL_RESULT, check_markdown_markers=False
776
+ )
777
+
778
+ # Structured content separator and display
779
+ console.console.print()
780
+ total_width = console.console.size.width
781
+
782
+ if is_skybridge_tool:
783
+ # Skybridge: magenta separator with resource URI
784
+ resource_label = (
785
+ f"skybridge resource: {skybridge_resource_uri}"
786
+ if skybridge_resource_uri
787
+ else "skybridge resource"
788
+ )
789
+ prefix = Text("─| ")
790
+ prefix.stylize("dim")
791
+ resource_text = Text(resource_label, style="magenta")
792
+ suffix = Text(" |")
793
+ suffix.stylize("dim")
794
+
795
+ separator_line = Text()
796
+ separator_line.append_text(prefix)
797
+ separator_line.append_text(resource_text)
798
+ separator_line.append_text(suffix)
799
+ remaining = total_width - separator_line.cell_len
800
+ if remaining > 0:
801
+ separator_line.append("─" * remaining, style="dim")
802
+ console.console.print(separator_line, markup=self._markup)
803
+ console.console.print()
804
+
805
+ # Display with bright syntax highlighting
806
+ import json
807
+
808
+ from rich.syntax import Syntax
809
+
810
+ json_str = json.dumps(structured_content, indent=2)
811
+ syntax_obj = Syntax(json_str, "json", theme=CODE_STYLE, background_color="default")
812
+ console.console.print(syntax_obj, markup=self._markup)
813
+ else:
814
+ # Regular tool: dim separator
815
+ prefix = Text("─| ")
816
+ prefix.stylize("dim")
817
+ label_text = Text("Structured Content", style="dim")
818
+ suffix = Text(" |")
819
+ suffix.stylize("dim")
820
+
821
+ separator_line = Text()
822
+ separator_line.append_text(prefix)
823
+ separator_line.append_text(label_text)
824
+ separator_line.append_text(suffix)
825
+ remaining = total_width - separator_line.cell_len
826
+ if remaining > 0:
827
+ separator_line.append("─" * remaining, style="dim")
828
+ console.console.print(separator_line, markup=self._markup)
829
+ console.console.print()
830
+
831
+ # Display truncated content in dim
832
+ from rich.pretty import Pretty
833
+
834
+ if self.config and self.config.logger.truncate_tools:
835
+ pretty_obj = Pretty(structured_content, max_length=10, max_string=50)
836
+ else:
837
+ pretty_obj = Pretty(structured_content)
838
+ console.console.print(pretty_obj, style="dim", markup=self._markup)
839
+
840
+ # Bottom separator with metadata
841
+ console.console.print()
842
+ if bottom_metadata:
843
+ display_items = (
844
+ self._shorten_items(bottom_metadata, 12) if True else bottom_metadata
845
+ )
846
+ prefix = Text("─| ")
847
+ prefix.stylize("dim")
848
+ suffix = Text(" |")
849
+ suffix.stylize("dim")
850
+ available = max(0, total_width - prefix.cell_len - suffix.cell_len)
851
+
852
+ metadata_text = self._format_bottom_metadata(
853
+ display_items,
854
+ None,
855
+ config["highlight_color"],
856
+ max_width=available,
857
+ )
858
+
859
+ line = Text()
860
+ line.append_text(prefix)
861
+ line.append_text(metadata_text)
862
+ line.append_text(suffix)
863
+ remaining = total_width - line.cell_len
864
+ if remaining > 0:
865
+ line.append("─" * remaining, style="dim")
866
+ console.console.print(line, markup=self._markup)
867
+ else:
868
+ console.console.print("─" * total_width, style="dim")
869
+ console.console.print()
870
+
871
+ else:
872
+ # No structured content - use standard display
873
+ self.display_message(
874
+ content=content,
875
+ message_type=MessageType.TOOL_RESULT,
876
+ name=name,
877
+ right_info=right_info,
878
+ bottom_metadata=bottom_metadata,
879
+ is_error=result.isError,
880
+ truncate_content=True,
881
+ )
609
882
 
610
883
  def show_tool_call(
611
884
  self,
@@ -669,9 +942,7 @@ class ConsoleDisplay:
669
942
  # No active prompt_toolkit session - display with rich as before
670
943
  # Combined separator and status line
671
944
  if agent_name:
672
- left = (
673
- f"[magenta]▎[/magenta][dim magenta]▶[/dim magenta] [magenta]{agent_name}[/magenta]"
674
- )
945
+ left = f"[magenta]▎[/magenta][dim magenta]▶[/dim magenta] [magenta]{agent_name}[/magenta]"
675
946
  else:
676
947
  left = "[magenta]▎[/magenta][dim magenta]▶[/dim magenta]"
677
948
 
@@ -727,6 +998,164 @@ class ConsoleDisplay:
727
998
  console.console.print(combined, markup=self._markup)
728
999
  console.console.print()
729
1000
 
1001
+ @staticmethod
1002
+ def summarize_skybridge_configs(
1003
+ configs: Mapping[str, "SkybridgeServerConfig"] | None,
1004
+ ) -> Tuple[List[Dict[str, Any]], List[str]]:
1005
+ """Convert raw Skybridge configs into display-friendly summary data."""
1006
+ server_rows: List[Dict[str, Any]] = []
1007
+ warnings: List[str] = []
1008
+ warning_seen: Set[str] = set()
1009
+
1010
+ if not configs:
1011
+ return server_rows, warnings
1012
+
1013
+ def add_warning(message: str) -> None:
1014
+ formatted = message.strip()
1015
+ if not formatted:
1016
+ return
1017
+ if formatted not in warning_seen:
1018
+ warnings.append(formatted)
1019
+ warning_seen.add(formatted)
1020
+
1021
+ for server_name in sorted(configs.keys()):
1022
+ config = configs.get(server_name)
1023
+ if not config:
1024
+ continue
1025
+ resources = list(config.ui_resources or [])
1026
+ has_skybridge_signal = bool(
1027
+ config.enabled or resources or config.tools or config.warnings
1028
+ )
1029
+ if not has_skybridge_signal:
1030
+ continue
1031
+
1032
+ valid_resource_count = sum(1 for resource in resources if resource.is_skybridge)
1033
+
1034
+ server_rows.append(
1035
+ {
1036
+ "server_name": server_name,
1037
+ "config": config,
1038
+ "resources": resources,
1039
+ "valid_resource_count": valid_resource_count,
1040
+ "total_resource_count": len(resources),
1041
+ "active_tools": [
1042
+ {
1043
+ "name": tool.display_name,
1044
+ "template": str(tool.template_uri) if tool.template_uri else None,
1045
+ }
1046
+ for tool in config.tools
1047
+ if tool.is_valid
1048
+ ],
1049
+ "enabled": config.enabled,
1050
+ }
1051
+ )
1052
+
1053
+ for warning in config.warnings:
1054
+ message = warning.strip()
1055
+ if not message:
1056
+ continue
1057
+ if not message.startswith(server_name):
1058
+ message = f"{server_name} {message}"
1059
+ add_warning(message)
1060
+
1061
+ return server_rows, warnings
1062
+
1063
+ def show_skybridge_summary(
1064
+ self,
1065
+ agent_name: str,
1066
+ configs: Mapping[str, "SkybridgeServerConfig"] | None,
1067
+ ) -> None:
1068
+ """Display Skybridge availability and warnings."""
1069
+ server_rows, warnings = self.summarize_skybridge_configs(configs)
1070
+
1071
+ if not server_rows and not warnings:
1072
+ return
1073
+
1074
+ heading = "[dim]OpenAI Apps SDK ([/dim][cyan]skybridge[/cyan][dim]) detected:[/dim]"
1075
+ console.console.print()
1076
+ console.console.print(heading, markup=self._markup)
1077
+
1078
+ if not server_rows:
1079
+ console.console.print("[dim] ● none detected[/dim]", markup=self._markup)
1080
+ else:
1081
+ for row in server_rows:
1082
+ server_name = row["server_name"]
1083
+ resource_count = row["valid_resource_count"]
1084
+ total_resource_count = row["total_resource_count"]
1085
+ tool_infos = row["active_tools"]
1086
+ enabled = row["enabled"]
1087
+
1088
+ tool_count = len(tool_infos)
1089
+ tool_word = "tool" if tool_count == 1 else "tools"
1090
+ resource_word = (
1091
+ "skybridge resource" if resource_count == 1 else "skybridge resources"
1092
+ )
1093
+ tool_segment = f"[cyan]{tool_count}[/cyan][dim] {tool_word}[/dim]"
1094
+ resource_segment = f"[cyan]{resource_count}[/cyan][dim] {resource_word}[/dim]"
1095
+ name_style = "cyan" if enabled else "yellow"
1096
+ status_suffix = "" if enabled else "[dim] (issues detected)[/dim]"
1097
+
1098
+ console.console.print(
1099
+ f"[dim] ● [/dim][{name_style}]{server_name}[/{name_style}]{status_suffix}"
1100
+ f"[dim] — [/dim]{tool_segment}[dim], [/dim]{resource_segment}",
1101
+ markup=self._markup,
1102
+ )
1103
+
1104
+ for tool_info in tool_infos:
1105
+ template_text = (
1106
+ f"[dim] ({tool_info['template']})[/dim]" if tool_info["template"] else ""
1107
+ )
1108
+ console.console.print(
1109
+ f"[dim] ▶ [/dim][white]{tool_info['name']}[/white]{template_text}",
1110
+ markup=self._markup,
1111
+ )
1112
+
1113
+ if tool_count == 0 and resource_count > 0:
1114
+ console.console.print(
1115
+ "[dim] ▶ tools not linked[/dim]",
1116
+ markup=self._markup,
1117
+ )
1118
+ if not enabled and total_resource_count > resource_count:
1119
+ invalid_count = total_resource_count - resource_count
1120
+ invalid_word = "resource" if invalid_count == 1 else "resources"
1121
+ console.console.print(
1122
+ (
1123
+ "[dim] ▶ "
1124
+ f"[/dim][cyan]{invalid_count}[/cyan][dim] {invalid_word} detected with non-skybridge MIME type[/dim]"
1125
+ ),
1126
+ markup=self._markup,
1127
+ )
1128
+
1129
+ for warning_entry in warnings:
1130
+ console.console.print(
1131
+ f"[dim red] ▶ [/dim red][red]warning[/red] [dim]{warning_entry}[/dim]",
1132
+ markup=self._markup,
1133
+ )
1134
+
1135
+ def _extract_reasoning_content(self, message: "PromptMessageExtended") -> Text | None:
1136
+ """Extract reasoning channel content as dim text."""
1137
+ channels = message.channels or {}
1138
+ reasoning_blocks = channels.get(REASONING) or []
1139
+ if not reasoning_blocks:
1140
+ return None
1141
+
1142
+ from fast_agent.mcp.helpers.content_helpers import get_text
1143
+
1144
+ reasoning_segments = []
1145
+ for block in reasoning_blocks:
1146
+ text = get_text(block)
1147
+ if text:
1148
+ reasoning_segments.append(text)
1149
+
1150
+ if not reasoning_segments:
1151
+ return None
1152
+
1153
+ joined = "\n".join(reasoning_segments)
1154
+ if not joined.strip():
1155
+ return None
1156
+
1157
+ return Text(joined, style="dim default")
1158
+
730
1159
  async def show_assistant_message(
731
1160
  self,
732
1161
  message_text: Union[str, Text, "PromptMessageExtended"],
@@ -759,22 +1188,7 @@ class ConsoleDisplay:
759
1188
 
760
1189
  if isinstance(message_text, PromptMessageExtended):
761
1190
  display_text = message_text.last_text() or ""
762
-
763
- channels = message_text.channels or {}
764
- reasoning_blocks = channels.get(REASONING) or []
765
- if reasoning_blocks:
766
- from fast_agent.mcp.helpers.content_helpers import get_text
767
-
768
- reasoning_segments = []
769
- for block in reasoning_blocks:
770
- text = get_text(block)
771
- if text:
772
- reasoning_segments.append(text)
773
-
774
- if reasoning_segments:
775
- joined = "\n".join(reasoning_segments)
776
- if joined.strip():
777
- pre_content = Text(joined, style="dim default")
1191
+ pre_content = self._extract_reasoning_content(message_text)
778
1192
  else:
779
1193
  display_text = message_text
780
1194
 
@@ -806,6 +1220,54 @@ class ConsoleDisplay:
806
1220
  if diagrams:
807
1221
  self._display_mermaid_diagrams(diagrams)
808
1222
 
1223
+ @contextmanager
1224
+ def streaming_assistant_message(
1225
+ self,
1226
+ *,
1227
+ bottom_items: List[str] | None = None,
1228
+ highlight_index: int | None = None,
1229
+ max_item_length: int | None = None,
1230
+ name: str | None = None,
1231
+ model: str | None = None,
1232
+ ) -> Iterator["_StreamingMessageHandle"]:
1233
+ """Create a streaming context for assistant messages."""
1234
+ streaming_enabled, streaming_mode = self.resolve_streaming_preferences()
1235
+
1236
+ if not streaming_enabled:
1237
+ yield _NullStreamingHandle()
1238
+ return
1239
+
1240
+ from fast_agent.ui.progress_display import progress_display
1241
+
1242
+ config = MESSAGE_CONFIGS[MessageType.ASSISTANT]
1243
+ block_color = config["block_color"]
1244
+ arrow = config["arrow"]
1245
+ arrow_style = config["arrow_style"]
1246
+
1247
+ left = f"[{block_color}]▎[/{block_color}][{arrow_style}]{arrow}[/{arrow_style}] "
1248
+ if name:
1249
+ left += f"[{block_color}]{name}[/{block_color}]"
1250
+
1251
+ right_info = f"[dim]{model}[/dim]" if model else ""
1252
+
1253
+ # Determine renderer based on streaming mode
1254
+ use_plain_text = streaming_mode == "plain"
1255
+
1256
+ handle = _StreamingMessageHandle(
1257
+ display=self,
1258
+ bottom_items=bottom_items,
1259
+ highlight_index=highlight_index,
1260
+ max_item_length=max_item_length,
1261
+ use_plain_text=use_plain_text,
1262
+ header_left=left,
1263
+ header_right=right_info,
1264
+ progress_display=progress_display,
1265
+ )
1266
+ try:
1267
+ yield handle
1268
+ finally:
1269
+ handle.close()
1270
+
809
1271
  def _display_mermaid_diagrams(self, diagrams: List[MermaidDiagram]) -> None:
810
1272
  """Display mermaid diagram links."""
811
1273
  diagram_content = Text()
@@ -1096,3 +1558,403 @@ class ConsoleDisplay:
1096
1558
  summary_text = " • ".join(summary_parts)
1097
1559
  console.console.print(f"[dim]{summary_text}[/dim]")
1098
1560
  console.console.print()
1561
+
1562
+
1563
+ class _NullStreamingHandle:
1564
+ """No-op streaming handle used when streaming is disabled."""
1565
+
1566
+ def update(self, _chunk: str) -> None:
1567
+ return
1568
+
1569
+ def finalize(self, _message: "PromptMessageExtended | str") -> None:
1570
+ return
1571
+
1572
+ def close(self) -> None:
1573
+ return
1574
+
1575
+
1576
+ class _StreamingMessageHandle:
1577
+ """Helper that manages live rendering for streaming assistant responses."""
1578
+
1579
+ def __init__(
1580
+ self,
1581
+ *,
1582
+ display: ConsoleDisplay,
1583
+ bottom_items: List[str] | None,
1584
+ highlight_index: int | None,
1585
+ max_item_length: int | None,
1586
+ use_plain_text: bool = False,
1587
+ header_left: str = "",
1588
+ header_right: str = "",
1589
+ progress_display=None,
1590
+ ) -> None:
1591
+ self._display = display
1592
+ self._bottom_items = bottom_items
1593
+ self._highlight_index = highlight_index
1594
+ self._max_item_length = max_item_length
1595
+ self._use_plain_text = use_plain_text
1596
+ self._header_left = header_left
1597
+ self._header_right = header_right
1598
+ self._progress_display = progress_display
1599
+ self._progress_paused = False
1600
+ self._buffer: List[str] = []
1601
+ initial_renderable = Text("") if self._use_plain_text else Markdown("")
1602
+ refresh_rate = (
1603
+ PLAIN_STREAM_REFRESH_PER_SECOND
1604
+ if self._use_plain_text
1605
+ else MARKDOWN_STREAM_REFRESH_PER_SECOND
1606
+ )
1607
+ self._live: Live | None = Live(
1608
+ initial_renderable,
1609
+ console=console.console,
1610
+ vertical_overflow="ellipsis",
1611
+ refresh_per_second=refresh_rate,
1612
+ transient=True,
1613
+ )
1614
+ self._live_started = False
1615
+ self._active = True
1616
+ self._finalized = False
1617
+ # Track whether we're in a table to batch updates
1618
+ self._in_table = False
1619
+ self._pending_table_row = ""
1620
+ # Smart markdown truncator for creating display window (doesn't mutate buffer)
1621
+ self._truncator = MarkdownTruncator(target_height_ratio=MARKDOWN_STREAM_TARGET_RATIO)
1622
+ self._plain_truncator = (
1623
+ PlainTextTruncator(target_height_ratio=PLAIN_STREAM_TARGET_RATIO)
1624
+ if self._use_plain_text
1625
+ else None
1626
+ )
1627
+ self._max_render_height = 0
1628
+
1629
+ def update(self, chunk: str) -> None:
1630
+ if not self._active or not chunk:
1631
+ return
1632
+
1633
+ self._ensure_started()
1634
+
1635
+ if self._use_plain_text:
1636
+ chunk = self._wrap_plain_chunk(chunk)
1637
+ if self._pending_table_row:
1638
+ self._buffer.append(self._pending_table_row)
1639
+ self._pending_table_row = ""
1640
+ else:
1641
+ # Detect if we're streaming table content
1642
+ # Tables have rows starting with '|' and we want to batch updates until we get a complete row
1643
+ text_so_far = "".join(self._buffer)
1644
+
1645
+ # Check if we're currently in a table (last non-empty line starts with |)
1646
+ lines = text_so_far.strip().split("\n")
1647
+ last_line = lines[-1] if lines else ""
1648
+ currently_in_table = last_line.strip().startswith("|")
1649
+
1650
+ # If we're in a table and the chunk doesn't contain a newline, accumulate it
1651
+ if currently_in_table and "\n" not in chunk:
1652
+ self._pending_table_row += chunk
1653
+ # Don't update display yet - wait for complete row
1654
+ return
1655
+
1656
+ # If we have a pending table row, flush it now
1657
+ if self._pending_table_row:
1658
+ self._buffer.append(self._pending_table_row)
1659
+ self._pending_table_row = ""
1660
+
1661
+ self._buffer.append(chunk)
1662
+
1663
+ text = "".join(self._buffer)
1664
+
1665
+ if self._use_plain_text:
1666
+ trimmed = self._trim_to_displayable(text)
1667
+ if trimmed != text:
1668
+ text = trimmed
1669
+ self._buffer = [trimmed]
1670
+
1671
+ # Guard against single logical paragraphs that would expand far wider than expected.
1672
+ trailing_paragraph = self._extract_trailing_paragraph(text)
1673
+ if trailing_paragraph and "\n" not in trailing_paragraph:
1674
+ width = max(1, console.console.size.width)
1675
+ target_ratio = (
1676
+ PLAIN_STREAM_TARGET_RATIO if self._use_plain_text else MARKDOWN_STREAM_TARGET_RATIO
1677
+ )
1678
+ target_rows = max(
1679
+ 1,
1680
+ int(console.console.size.height * target_ratio) - 1,
1681
+ )
1682
+ estimated_rows = math.ceil(len(trailing_paragraph.expandtabs()) / width)
1683
+ if estimated_rows > target_rows:
1684
+ trimmed_text = self._trim_to_displayable(text)
1685
+ if trimmed_text != text:
1686
+ text = trimmed_text
1687
+ self._buffer = [trimmed_text]
1688
+
1689
+ # Trim buffer periodically to avoid unbounded growth
1690
+ # Keep only what can fit in ~1.5x terminal height
1691
+ if len(self._buffer) > 10:
1692
+ text = self._trim_to_displayable(text)
1693
+ self._buffer = [text]
1694
+
1695
+ if self._live:
1696
+ # Build the header bar
1697
+ header = self._build_header()
1698
+
1699
+ # Build the content renderable
1700
+ max_allowed_height = max(1, console.console.size.height - 2)
1701
+ self._max_render_height = min(self._max_render_height, max_allowed_height)
1702
+
1703
+ if self._use_plain_text:
1704
+ # Plain text rendering - no markdown processing
1705
+ content_height = self._estimate_plain_render_height(text)
1706
+ budget_height = min(content_height + PLAIN_STREAM_HEIGHT_FUDGE, max_allowed_height)
1707
+
1708
+ if budget_height > self._max_render_height:
1709
+ self._max_render_height = budget_height
1710
+
1711
+ padding_lines = max(0, self._max_render_height - content_height)
1712
+ display_text = text + ("\n" * padding_lines if padding_lines else "")
1713
+ content = Text(display_text)
1714
+ else:
1715
+ # Markdown rendering with XML escaping
1716
+ prepared = _prepare_markdown_content(text, self._display._escape_xml)
1717
+ prepared_for_display = self._close_incomplete_code_blocks(prepared)
1718
+
1719
+ content_height = self._truncator.measure_rendered_height(
1720
+ prepared_for_display, console.console, CODE_STYLE
1721
+ )
1722
+ budget_height = min(
1723
+ content_height + MARKDOWN_STREAM_HEIGHT_FUDGE, max_allowed_height
1724
+ )
1725
+
1726
+ if budget_height > self._max_render_height:
1727
+ self._max_render_height = budget_height
1728
+
1729
+ padding_lines = max(0, self._max_render_height - content_height)
1730
+ if padding_lines:
1731
+ prepared_for_display = prepared_for_display + ("\n" * padding_lines)
1732
+
1733
+ content = Markdown(prepared_for_display, code_theme=CODE_STYLE)
1734
+
1735
+ # Combine header and content using Group
1736
+ from rich.console import Group
1737
+
1738
+ header_with_spacing = header.copy()
1739
+ header_with_spacing.append("\n", style="default")
1740
+
1741
+ combined = Group(header_with_spacing, content)
1742
+ self._live.update(combined)
1743
+
1744
+ def _build_header(self) -> Text:
1745
+ """Build the header bar as a Text renderable.
1746
+
1747
+ Returns:
1748
+ Text object representing the header bar.
1749
+ """
1750
+ width = console.console.size.width
1751
+
1752
+ # Create left text
1753
+ left_text = Text.from_markup(self._header_left)
1754
+
1755
+ # Create right text if we have info
1756
+ if self._header_right and self._header_right.strip():
1757
+ # Add dim brackets around the right info
1758
+ right_text = Text()
1759
+ right_text.append("[", style="dim")
1760
+ right_text.append_text(Text.from_markup(self._header_right))
1761
+ right_text.append("]", style="dim")
1762
+ # Calculate separator count
1763
+ separator_count = width - left_text.cell_len - right_text.cell_len
1764
+ if separator_count < 1:
1765
+ separator_count = 1 # Always at least 1 separator
1766
+ else:
1767
+ right_text = Text("")
1768
+ separator_count = width - left_text.cell_len
1769
+
1770
+ # Build the combined line
1771
+ combined = Text()
1772
+ combined.append_text(left_text)
1773
+ combined.append(" ", style="default")
1774
+ combined.append("─" * (separator_count - 1), style="dim")
1775
+ combined.append_text(right_text)
1776
+
1777
+ return combined
1778
+
1779
+ def _ensure_started(self) -> None:
1780
+ """Start live rendering and pause progress display if needed."""
1781
+ if self._live_started:
1782
+ return
1783
+
1784
+ if self._progress_display and not self._progress_paused:
1785
+ try:
1786
+ self._progress_display.pause()
1787
+ self._progress_paused = True
1788
+ except Exception:
1789
+ self._progress_paused = False
1790
+
1791
+ if self._live and not self._live_started:
1792
+ self._live.__enter__()
1793
+ self._live_started = True
1794
+
1795
+ def _close_incomplete_code_blocks(self, text: str) -> str:
1796
+ """Add temporary closing fence to incomplete code blocks for display.
1797
+
1798
+ During streaming, incomplete code blocks (opening fence without closing)
1799
+ are rendered as literal text by Rich's Markdown renderer. This method
1800
+ adds a temporary closing fence so the code can be syntax-highlighted
1801
+ during streaming display.
1802
+
1803
+ When the real closing fence arrives in a subsequent chunk, this method
1804
+ will detect the now-complete block and stop adding the temporary fence.
1805
+
1806
+ Args:
1807
+ text: The markdown text that may contain incomplete code blocks.
1808
+
1809
+ Returns:
1810
+ Text with temporary closing fences added for incomplete code blocks.
1811
+ """
1812
+ import re
1813
+
1814
+ # Count opening and closing fences
1815
+ opening_fences = len(re.findall(r"^```", text, re.MULTILINE))
1816
+ closing_fences = len(re.findall(r"^```\s*$", text, re.MULTILINE))
1817
+
1818
+ # If we have more opening fences than closing fences, and the text
1819
+ # doesn't end with a closing fence, we have an incomplete code block
1820
+ if opening_fences > closing_fences:
1821
+ # Check if text ends with a closing fence (might be partial line)
1822
+ if not re.search(r"```\s*$", text):
1823
+ # Add temporary closing fence for display only
1824
+ return text + "\n```\n"
1825
+
1826
+ return text
1827
+
1828
+ def _trim_to_displayable(self, text: str) -> str:
1829
+ """Trim text to keep only displayable content plus small buffer.
1830
+
1831
+ Keeps ~1.5x terminal height worth of recent content.
1832
+ Uses the optimized streaming truncator for better performance.
1833
+
1834
+ Args:
1835
+ text: Full text to trim
1836
+
1837
+ Returns:
1838
+ Trimmed text (most recent content)
1839
+ """
1840
+ if not text:
1841
+ return text
1842
+
1843
+ terminal_height = console.console.size.height - 1
1844
+
1845
+ if self._use_plain_text and self._plain_truncator:
1846
+ terminal_width = console.console.size.width
1847
+ return self._plain_truncator.truncate(
1848
+ text,
1849
+ terminal_height=terminal_height,
1850
+ terminal_width=terminal_width,
1851
+ )
1852
+
1853
+ # Use the optimized streaming truncator (16x faster!) for markdown
1854
+ return self._truncator.truncate(
1855
+ text,
1856
+ terminal_height=terminal_height,
1857
+ console=console.console,
1858
+ code_theme=CODE_STYLE,
1859
+ prefer_recent=True, # Streaming mode
1860
+ )
1861
+
1862
+ def finalize(self, _message: "PromptMessageExtended | str") -> None:
1863
+ if not self._active or self._finalized:
1864
+ return
1865
+
1866
+ self._finalized = True
1867
+ self.close()
1868
+
1869
+ def close(self) -> None:
1870
+ if self._live and self._live_started:
1871
+ self._live.__exit__(None, None, None)
1872
+ self._live = None
1873
+ self._live_started = False
1874
+ if self._progress_display and self._progress_paused:
1875
+ try:
1876
+ self._progress_display.resume()
1877
+ except Exception:
1878
+ pass
1879
+ finally:
1880
+ self._progress_paused = False
1881
+ self._active = False
1882
+ self._max_render_height = 0
1883
+
1884
+ def _extract_trailing_paragraph(self, text: str) -> str:
1885
+ """Return text since the last blank line, used to detect in-progress paragraphs."""
1886
+ if not text:
1887
+ return ""
1888
+ double_break = text.rfind("\n\n")
1889
+ if double_break != -1:
1890
+ candidate = text[double_break + 2 :]
1891
+ else:
1892
+ candidate = text
1893
+ if "\n" in candidate:
1894
+ candidate = candidate.split("\n")[-1]
1895
+ return candidate
1896
+
1897
+ def _wrap_plain_chunk(self, chunk: str) -> str:
1898
+ """Insert soft line breaks into long plain text segments."""
1899
+ width = max(1, console.console.size.width)
1900
+ if not chunk or width <= 1:
1901
+ return chunk
1902
+
1903
+ result_segments: List[str] = []
1904
+ start = 0
1905
+ length = len(chunk)
1906
+
1907
+ while start < length:
1908
+ newline_pos = chunk.find("\n", start)
1909
+ if newline_pos == -1:
1910
+ line = chunk[start:]
1911
+ delimiter = ""
1912
+ start = length
1913
+ else:
1914
+ line = chunk[start:newline_pos]
1915
+ delimiter = "\n"
1916
+ start = newline_pos + 1
1917
+
1918
+ if len(line.expandtabs()) > width:
1919
+ wrapped = self._wrap_plain_line(line, width)
1920
+ result_segments.append("\n".join(wrapped))
1921
+ else:
1922
+ result_segments.append(line)
1923
+
1924
+ result_segments.append(delimiter)
1925
+
1926
+ return "".join(result_segments)
1927
+
1928
+ @staticmethod
1929
+ def _wrap_plain_line(line: str, width: int) -> List[str]:
1930
+ """Wrap a single line to the terminal width."""
1931
+ if not line:
1932
+ return [""]
1933
+
1934
+ segments: List[str] = []
1935
+ remaining = line
1936
+
1937
+ while len(remaining) > width:
1938
+ break_at = remaining.rfind(" ", 0, width)
1939
+ if break_at == -1 or break_at < width // 2:
1940
+ break_at = width
1941
+ segments.append(remaining[:break_at])
1942
+ remaining = remaining[break_at:]
1943
+ else:
1944
+ segments.append(remaining[:break_at])
1945
+ remaining = remaining[break_at + 1 :]
1946
+ segments.append(remaining)
1947
+ return segments
1948
+
1949
+ def _estimate_plain_render_height(self, text: str) -> int:
1950
+ """Estimate rendered height for plain text taking terminal width into account."""
1951
+ if not text:
1952
+ return 0
1953
+
1954
+ width = max(1, console.console.size.width)
1955
+ lines = text.split("\n")
1956
+ total = 0
1957
+ for line in lines:
1958
+ expanded_len = len(line.expandtabs())
1959
+ total += max(1, math.ceil(expanded_len / width)) if expanded_len else 1
1960
+ return total