fast-agent-mcp 0.3.14__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.

@@ -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, Mapping, Optional, Set, Tuple, 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,6 +21,7 @@ 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
@@ -22,6 +29,13 @@ if TYPE_CHECKING:
22
29
 
23
30
  CODE_STYLE = "native"
24
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
+
25
39
 
26
40
  class MessageType(Enum):
27
41
  """Types of messages that can be displayed."""
@@ -83,33 +97,104 @@ def _prepare_markdown_content(content: str, escape_xml: bool = True) -> str:
83
97
  This ensures XML/HTML tags are displayed as visible text rather than
84
98
  being interpreted as markup by the markdown renderer.
85
99
 
86
- Note: This method does not handle overlapping code blocks (e.g., if inline
87
- code appears within a fenced code block range). In practice, this is not
88
- 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).
89
103
  """
90
104
  if not escape_xml or not isinstance(content, str):
91
105
  return content
92
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
93
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
94
167
  import re
95
168
 
96
- # Protect fenced code blocks (don't escape anything inside these)
97
- code_block_pattern = r"```[\s\S]*?```"
98
- for match in re.finditer(code_block_pattern, content):
99
- protected_ranges.append((match.start(), match.end()))
169
+ fence_pattern = r"^```"
170
+ fences = list(re.finditer(fence_pattern, content, re.MULTILINE))
100
171
 
101
- # Protect inline code (don't escape anything inside these)
102
- inline_code_pattern = r"(?<!`)`(?!``)[^`\n]+`(?!`)"
103
- for match in re.finditer(inline_code_pattern, content):
104
- 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)))
105
180
 
181
+ # Sort and merge overlapping ranges
106
182
  protected_ranges.sort(key=lambda x: x[0])
107
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
+
108
193
  # Build the escaped content
109
194
  result = []
110
195
  last_end = 0
111
196
 
112
- for start, end in protected_ranges:
197
+ for start, end in merged_ranges:
113
198
  # Escape everything outside protected ranges
114
199
  unprotected_text = content[last_end:start]
115
200
  for char, replacement in HTML_ESCAPE_CHARS.items():
@@ -135,7 +220,7 @@ class ConsoleDisplay:
135
220
  This centralizes the UI display logic used by LLM implementations.
136
221
  """
137
222
 
138
- def __init__(self, config=None) -> None:
223
+ def __init__(self, config: Settings | None = None) -> None:
139
224
  """
140
225
  Initialize the console display handler.
141
226
 
@@ -146,6 +231,29 @@ class ConsoleDisplay:
146
231
  self._markup = config.logger.enable_markup if config else True
147
232
  self._escape_xml = True
148
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
+
149
257
  @staticmethod
150
258
  def _format_elapsed(elapsed: float) -> str:
151
259
  """Format elapsed seconds for display."""
@@ -224,44 +332,12 @@ class ConsoleDisplay:
224
332
  console.console.print(additional_message, markup=self._markup)
225
333
 
226
334
  # Handle bottom separator with optional metadata
227
- console.console.print()
228
-
229
- if bottom_metadata:
230
- # Apply shortening if requested
231
- display_items = bottom_metadata
232
- if max_item_length:
233
- display_items = self._shorten_items(bottom_metadata, max_item_length)
234
-
235
- # Format the metadata with highlighting, clipped to available width
236
- # Compute available width for the metadata segment (excluding the fixed prefix/suffix)
237
- total_width = console.console.size.width
238
- prefix = Text("─| ")
239
- prefix.stylize("dim")
240
- suffix = Text(" |")
241
- suffix.stylize("dim")
242
- available = max(0, total_width - prefix.cell_len - suffix.cell_len)
243
-
244
- metadata_text = self._format_bottom_metadata(
245
- display_items,
246
- highlight_index,
247
- config["highlight_color"],
248
- max_width=available,
249
- )
250
-
251
- # Create the separator line with metadata
252
- line = Text()
253
- line.append_text(prefix)
254
- line.append_text(metadata_text)
255
- line.append_text(suffix)
256
- remaining = total_width - line.cell_len
257
- if remaining > 0:
258
- line.append("─" * remaining, style="dim")
259
- console.console.print(line, markup=self._markup)
260
- else:
261
- # No metadata - continuous bar
262
- console.console.print("─" * console.console.size.width, style="dim")
263
-
264
- 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
+ )
265
341
 
266
342
  def _display_content(
267
343
  self,
@@ -484,6 +560,58 @@ class ConsoleDisplay:
484
560
  """
485
561
  return [item[: max_length - 1] + "…" if len(item) > max_length else item for item in items]
486
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
+
487
615
  def _format_bottom_metadata(
488
616
  self,
489
617
  items: List[str],
@@ -603,7 +731,7 @@ class ConsoleDisplay:
603
731
  if channel == "post-json":
604
732
  transport_info = "HTTP (JSON-RPC)"
605
733
  elif channel == "post-sse":
606
- transport_info = "Legacy SSE"
734
+ transport_info = "HTTP (SSE)"
607
735
  elif channel == "get":
608
736
  transport_info = "Legacy SSE"
609
737
  elif channel == "resumption":
@@ -1004,6 +1132,30 @@ class ConsoleDisplay:
1004
1132
  markup=self._markup,
1005
1133
  )
1006
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
+
1007
1159
  async def show_assistant_message(
1008
1160
  self,
1009
1161
  message_text: Union[str, Text, "PromptMessageExtended"],
@@ -1036,22 +1188,7 @@ class ConsoleDisplay:
1036
1188
 
1037
1189
  if isinstance(message_text, PromptMessageExtended):
1038
1190
  display_text = message_text.last_text() or ""
1039
-
1040
- channels = message_text.channels or {}
1041
- reasoning_blocks = channels.get(REASONING) or []
1042
- if reasoning_blocks:
1043
- from fast_agent.mcp.helpers.content_helpers import get_text
1044
-
1045
- reasoning_segments = []
1046
- for block in reasoning_blocks:
1047
- text = get_text(block)
1048
- if text:
1049
- reasoning_segments.append(text)
1050
-
1051
- if reasoning_segments:
1052
- joined = "\n".join(reasoning_segments)
1053
- if joined.strip():
1054
- pre_content = Text(joined, style="dim default")
1191
+ pre_content = self._extract_reasoning_content(message_text)
1055
1192
  else:
1056
1193
  display_text = message_text
1057
1194
 
@@ -1083,6 +1220,54 @@ class ConsoleDisplay:
1083
1220
  if diagrams:
1084
1221
  self._display_mermaid_diagrams(diagrams)
1085
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
+
1086
1271
  def _display_mermaid_diagrams(self, diagrams: List[MermaidDiagram]) -> None:
1087
1272
  """Display mermaid diagram links."""
1088
1273
  diagram_content = Text()
@@ -1373,3 +1558,403 @@ class ConsoleDisplay:
1373
1558
  summary_text = " • ".join(summary_parts)
1374
1559
  console.console.print(f"[dim]{summary_text}[/dim]")
1375
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