fast-agent-mcp 0.3.14__py3-none-any.whl → 0.3.16__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 (49) hide show
  1. fast_agent/__init__.py +2 -0
  2. fast_agent/agents/agent_types.py +5 -0
  3. fast_agent/agents/llm_agent.py +52 -4
  4. fast_agent/agents/llm_decorator.py +6 -0
  5. fast_agent/agents/mcp_agent.py +137 -13
  6. fast_agent/agents/tool_agent.py +33 -19
  7. fast_agent/agents/workflow/router_agent.py +2 -1
  8. fast_agent/cli/__main__.py +35 -0
  9. fast_agent/cli/commands/check_config.py +90 -2
  10. fast_agent/cli/commands/go.py +100 -36
  11. fast_agent/cli/constants.py +13 -1
  12. fast_agent/cli/main.py +1 -0
  13. fast_agent/config.py +41 -12
  14. fast_agent/constants.py +8 -0
  15. fast_agent/context.py +24 -15
  16. fast_agent/core/direct_decorators.py +9 -0
  17. fast_agent/core/fastagent.py +115 -2
  18. fast_agent/core/logging/listeners.py +8 -0
  19. fast_agent/core/validation.py +31 -33
  20. fast_agent/human_input/form_fields.py +4 -1
  21. fast_agent/interfaces.py +12 -1
  22. fast_agent/llm/fastagent_llm.py +76 -0
  23. fast_agent/llm/memory.py +26 -1
  24. fast_agent/llm/model_database.py +2 -2
  25. fast_agent/llm/model_factory.py +4 -1
  26. fast_agent/llm/provider/anthropic/llm_anthropic.py +112 -0
  27. fast_agent/llm/provider/openai/llm_openai.py +184 -18
  28. fast_agent/llm/provider/openai/responses.py +133 -0
  29. fast_agent/mcp/prompt_message_extended.py +2 -2
  30. fast_agent/resources/setup/agent.py +2 -0
  31. fast_agent/resources/setup/fastagent.config.yaml +11 -4
  32. fast_agent/skills/__init__.py +9 -0
  33. fast_agent/skills/registry.py +200 -0
  34. fast_agent/tools/shell_runtime.py +404 -0
  35. fast_agent/ui/console_display.py +925 -73
  36. fast_agent/ui/elicitation_form.py +98 -24
  37. fast_agent/ui/elicitation_style.py +2 -2
  38. fast_agent/ui/enhanced_prompt.py +128 -26
  39. fast_agent/ui/history_display.py +20 -5
  40. fast_agent/ui/interactive_prompt.py +108 -3
  41. fast_agent/ui/markdown_truncator.py +942 -0
  42. fast_agent/ui/mcp_display.py +2 -2
  43. fast_agent/ui/plain_text_truncator.py +68 -0
  44. fast_agent/ui/streaming_buffer.py +449 -0
  45. {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.16.dist-info}/METADATA +9 -7
  46. {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.16.dist-info}/RECORD +49 -42
  47. {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.16.dist-info}/WHEEL +0 -0
  48. {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.16.dist-info}/entry_points.txt +0 -0
  49. {fast_agent_mcp-0.3.14.dist-info → fast_agent_mcp-0.3.16.dist-info}/licenses/LICENSE +0 -0
@@ -1,13 +1,22 @@
1
+ import asyncio
2
+ import math
3
+ import time
4
+ from contextlib import contextmanager
1
5
  from enum import Enum
2
6
  from json import JSONDecodeError
3
- from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, Set, Tuple, Union
7
+ from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Union
4
8
 
5
9
  from mcp.types import CallToolResult
10
+ from rich.live import Live
11
+ from rich.markdown import Markdown
6
12
  from rich.panel import Panel
7
13
  from rich.text import Text
8
14
 
15
+ from fast_agent.config import Settings
9
16
  from fast_agent.constants import REASONING
17
+ from fast_agent.core.logging.logger import get_logger
10
18
  from fast_agent.ui import console
19
+ from fast_agent.ui.markdown_truncator import MarkdownTruncator
11
20
  from fast_agent.ui.mcp_ui_utils import UILink
12
21
  from fast_agent.ui.mermaid_utils import (
13
22
  MermaidDiagram,
@@ -15,13 +24,23 @@ from fast_agent.ui.mermaid_utils import (
15
24
  detect_diagram_type,
16
25
  extract_mermaid_diagrams,
17
26
  )
27
+ from fast_agent.ui.plain_text_truncator import PlainTextTruncator
18
28
 
19
29
  if TYPE_CHECKING:
20
30
  from fast_agent.mcp.prompt_message_extended import PromptMessageExtended
21
31
  from fast_agent.mcp.skybridge import SkybridgeServerConfig
22
32
 
33
+ logger = get_logger(__name__)
34
+
23
35
  CODE_STYLE = "native"
24
36
 
37
+ MARKDOWN_STREAM_TARGET_RATIO = 0.7
38
+ MARKDOWN_STREAM_REFRESH_PER_SECOND = 4
39
+ MARKDOWN_STREAM_HEIGHT_FUDGE = 1
40
+ PLAIN_STREAM_TARGET_RATIO = 0.9
41
+ PLAIN_STREAM_REFRESH_PER_SECOND = 20
42
+ PLAIN_STREAM_HEIGHT_FUDGE = 1
43
+
25
44
 
26
45
  class MessageType(Enum):
27
46
  """Types of messages that can be displayed."""
@@ -83,33 +102,104 @@ def _prepare_markdown_content(content: str, escape_xml: bool = True) -> str:
83
102
  This ensures XML/HTML tags are displayed as visible text rather than
84
103
  being interpreted as markup by the markdown renderer.
85
104
 
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.
105
+ Uses markdown-it parser to properly identify code regions, avoiding
106
+ the issues with regex-based approaches (e.g., backticks inside fenced
107
+ code blocks being misidentified as inline code).
89
108
  """
90
109
  if not escape_xml or not isinstance(content, str):
91
110
  return content
92
111
 
112
+ # Import markdown-it for proper parsing
113
+ from markdown_it import MarkdownIt
114
+
115
+ # Parse the markdown to identify code regions
116
+ parser = MarkdownIt()
117
+ try:
118
+ tokens = parser.parse(content)
119
+ except Exception:
120
+ # If parsing fails, fall back to escaping everything
121
+ # (better safe than corrupting content)
122
+ result = content
123
+ for char, replacement in HTML_ESCAPE_CHARS.items():
124
+ result = result.replace(char, replacement)
125
+ return result
126
+
127
+ # Collect protected ranges from tokens
93
128
  protected_ranges = []
129
+ lines = content.split("\n")
130
+
131
+ def _flatten_tokens(tokens):
132
+ """Recursively flatten token tree."""
133
+ for token in tokens:
134
+ yield token
135
+ if token.children:
136
+ yield from _flatten_tokens(token.children)
137
+
138
+ # Process all tokens to find code blocks and inline code
139
+ for token in _flatten_tokens(tokens):
140
+ if token.map is not None:
141
+ # Block-level tokens with line mapping (fence, code_block)
142
+ if token.type in ("fence", "code_block"):
143
+ start_line = token.map[0]
144
+ end_line = token.map[1]
145
+ start_pos = sum(len(line) + 1 for line in lines[:start_line])
146
+ end_pos = sum(len(line) + 1 for line in lines[:end_line])
147
+ protected_ranges.append((start_pos, end_pos))
148
+
149
+ # Inline code tokens don't have map, but have content
150
+ if token.type == "code_inline":
151
+ # For inline code, we need to find its position in the source
152
+ # The token has the content, but we need to search for it
153
+ # We'll look for the pattern `content` in the content string
154
+ code_content = token.content
155
+ if code_content:
156
+ # Search for this inline code in the content
157
+ # We need to account for the backticks: `content`
158
+ pattern = f"`{code_content}`"
159
+ start = 0
160
+ while True:
161
+ pos = content.find(pattern, start)
162
+ if pos == -1:
163
+ break
164
+ # Check if this position is already in a protected range
165
+ in_protected = any(s <= pos < e for s, e in protected_ranges)
166
+ if not in_protected:
167
+ protected_ranges.append((pos, pos + len(pattern)))
168
+ start = pos + len(pattern)
169
+
170
+ # Check for incomplete code blocks (streaming scenario)
171
+ # Count opening vs closing fences
94
172
  import re
95
173
 
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()))
174
+ fence_pattern = r"^```"
175
+ fences = list(re.finditer(fence_pattern, content, re.MULTILINE))
100
176
 
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()))
177
+ # If we have an odd number of fences, the last one is incomplete
178
+ if len(fences) % 2 == 1:
179
+ # Protect from the last fence to the end
180
+ last_fence_pos = fences[-1].start()
181
+ # Only add if not already protected
182
+ in_protected = any(s <= last_fence_pos < e for s, e in protected_ranges)
183
+ if not in_protected:
184
+ protected_ranges.append((last_fence_pos, len(content)))
105
185
 
186
+ # Sort and merge overlapping ranges
106
187
  protected_ranges.sort(key=lambda x: x[0])
107
188
 
189
+ # Merge overlapping ranges
190
+ merged_ranges = []
191
+ for start, end in protected_ranges:
192
+ if merged_ranges and start <= merged_ranges[-1][1]:
193
+ # Overlapping or adjacent - merge
194
+ merged_ranges[-1] = (merged_ranges[-1][0], max(end, merged_ranges[-1][1]))
195
+ else:
196
+ merged_ranges.append((start, end))
197
+
108
198
  # Build the escaped content
109
199
  result = []
110
200
  last_end = 0
111
201
 
112
- for start, end in protected_ranges:
202
+ for start, end in merged_ranges:
113
203
  # Escape everything outside protected ranges
114
204
  unprotected_text = content[last_end:start]
115
205
  for char, replacement in HTML_ESCAPE_CHARS.items():
@@ -135,7 +225,7 @@ class ConsoleDisplay:
135
225
  This centralizes the UI display logic used by LLM implementations.
136
226
  """
137
227
 
138
- def __init__(self, config=None) -> None:
228
+ def __init__(self, config: Settings | None = None) -> None:
139
229
  """
140
230
  Initialize the console display handler.
141
231
 
@@ -146,6 +236,29 @@ class ConsoleDisplay:
146
236
  self._markup = config.logger.enable_markup if config else True
147
237
  self._escape_xml = True
148
238
 
239
+ def resolve_streaming_preferences(self) -> tuple[bool, str]:
240
+ """Return whether streaming is enabled plus the active mode."""
241
+ if not self.config:
242
+ return True, "markdown"
243
+
244
+ logger_settings = getattr(self.config, "logger", None)
245
+ if not logger_settings:
246
+ return True, "markdown"
247
+
248
+ streaming_mode = getattr(logger_settings, "streaming", "markdown")
249
+ if streaming_mode not in {"markdown", "plain", "none"}:
250
+ streaming_mode = "markdown"
251
+
252
+ # Legacy compatibility: allow streaming_plain_text override
253
+ if streaming_mode == "markdown" and getattr(logger_settings, "streaming_plain_text", False):
254
+ streaming_mode = "plain"
255
+
256
+ show_chat = bool(getattr(logger_settings, "show_chat", True))
257
+ streaming_display = bool(getattr(logger_settings, "streaming_display", True))
258
+
259
+ enabled = show_chat and streaming_display and streaming_mode != "none"
260
+ return enabled, streaming_mode
261
+
149
262
  @staticmethod
150
263
  def _format_elapsed(elapsed: float) -> str:
151
264
  """Format elapsed seconds for display."""
@@ -224,44 +337,12 @@ class ConsoleDisplay:
224
337
  console.console.print(additional_message, markup=self._markup)
225
338
 
226
339
  # 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()
340
+ self._render_bottom_metadata(
341
+ message_type=message_type,
342
+ bottom_metadata=bottom_metadata,
343
+ highlight_index=highlight_index,
344
+ max_item_length=max_item_length,
345
+ )
265
346
 
266
347
  def _display_content(
267
348
  self,
@@ -484,6 +565,58 @@ class ConsoleDisplay:
484
565
  """
485
566
  return [item[: max_length - 1] + "…" if len(item) > max_length else item for item in items]
486
567
 
568
+ def _render_bottom_metadata(
569
+ self,
570
+ *,
571
+ message_type: MessageType,
572
+ bottom_metadata: List[str] | None,
573
+ highlight_index: int | None,
574
+ max_item_length: int | None,
575
+ ) -> None:
576
+ """
577
+ Render the bottom separator line with optional metadata.
578
+
579
+ Args:
580
+ message_type: The type of message being displayed
581
+ bottom_metadata: Optional list of items to show in the separator
582
+ highlight_index: Optional index of the item to highlight
583
+ max_item_length: Optional maximum length for individual items
584
+ """
585
+ console.console.print()
586
+
587
+ if bottom_metadata:
588
+ display_items = bottom_metadata
589
+ if max_item_length:
590
+ display_items = self._shorten_items(bottom_metadata, max_item_length)
591
+
592
+ total_width = console.console.size.width
593
+ prefix = Text("─| ")
594
+ prefix.stylize("dim")
595
+ suffix = Text(" |")
596
+ suffix.stylize("dim")
597
+ available = max(0, total_width - prefix.cell_len - suffix.cell_len)
598
+
599
+ highlight_color = MESSAGE_CONFIGS[message_type]["highlight_color"]
600
+ metadata_text = self._format_bottom_metadata(
601
+ display_items,
602
+ highlight_index,
603
+ highlight_color,
604
+ max_width=available,
605
+ )
606
+
607
+ line = Text()
608
+ line.append_text(prefix)
609
+ line.append_text(metadata_text)
610
+ line.append_text(suffix)
611
+ remaining = total_width - line.cell_len
612
+ if remaining > 0:
613
+ line.append("─" * remaining, style="dim")
614
+ console.console.print(line, markup=self._markup)
615
+ else:
616
+ console.console.print("─" * console.console.size.width, style="dim")
617
+
618
+ console.console.print()
619
+
487
620
  def _format_bottom_metadata(
488
621
  self,
489
622
  items: List[str],
@@ -603,7 +736,7 @@ class ConsoleDisplay:
603
736
  if channel == "post-json":
604
737
  transport_info = "HTTP (JSON-RPC)"
605
738
  elif channel == "post-sse":
606
- transport_info = "Legacy SSE"
739
+ transport_info = "HTTP (SSE)"
607
740
  elif channel == "get":
608
741
  transport_info = "Legacy SSE"
609
742
  elif channel == "resumption":
@@ -756,10 +889,11 @@ class ConsoleDisplay:
756
889
  self,
757
890
  tool_name: str,
758
891
  tool_args: Dict[str, Any] | None,
759
- bottom_items: List[str] | None = None,
892
+ bottom_items: list[str] | None = None,
760
893
  highlight_index: int | None = None,
761
894
  max_item_length: int | None = None,
762
895
  name: str | None = None,
896
+ metadata: Dict[str, Any] | None = None,
763
897
  ) -> None:
764
898
  """Display a tool call in the new visual style.
765
899
 
@@ -770,23 +904,77 @@ class ConsoleDisplay:
770
904
  highlight_index: Index of item to highlight in the bottom separator (0-based), or None
771
905
  max_item_length: Optional max length for bottom items (with ellipsis)
772
906
  name: Optional agent name
907
+ metadata: Optional dictionary of metadata about the tool call
773
908
  """
774
909
  if not self.config or not self.config.logger.show_tools:
775
910
  return
776
911
 
777
- # Build right info
912
+ tool_args = tool_args or {}
913
+ metadata = metadata or {}
914
+ # Build right info and specialised content for known variants
778
915
  right_info = f"[dim]tool request - {tool_name}[/dim]"
916
+ content: Any = tool_args
917
+ pre_content: Text | None = None
918
+ truncate_content = True
919
+
920
+ if metadata.get("variant") == "shell":
921
+ bottom_items = list()
922
+ max_item_length = 50
923
+ command = metadata.get("command") or tool_args.get("command")
924
+
925
+ command_text = Text()
926
+ if command and isinstance(command, str):
927
+ # Only prepend $ to the first line, not continuation lines
928
+ command_text.append("$ ", style="magenta")
929
+ command_text.append(command, style="white")
930
+ else:
931
+ command_text.append("$ ", style="magenta")
932
+ command_text.append("(no shell command provided)", style="dim")
933
+
934
+ content = command_text
935
+
936
+ # Include shell name and path in the header, with timeout
937
+ shell_name = metadata.get("shell_name") or "shell"
938
+ shell_path = metadata.get("shell_path")
939
+ if shell_path:
940
+ bottom_items.append(str(shell_path))
941
+ # Build header right info with shell and timeout
942
+ right_parts = []
943
+ if shell_path and shell_path != shell_name:
944
+ right_parts.append(f"{shell_name} ({shell_path})")
945
+ elif shell_name:
946
+ right_parts.append(shell_name)
947
+
948
+ right_info = f"[dim]{' | '.join(right_parts)}[/dim]" if right_parts else ""
949
+ truncate_content = False
950
+
951
+ # Build compact metadata summary - just working directory now
952
+ metadata_text = Text()
953
+ working_dir_display = metadata.get("working_dir_display") or metadata.get("working_dir")
954
+ if working_dir_display:
955
+ bottom_items.append(f"cwd: {working_dir_display}")
956
+
957
+ timeout_seconds = metadata.get("timeout_seconds")
958
+ warning_interval = metadata.get("warning_interval_seconds")
959
+
960
+ if timeout_seconds and warning_interval:
961
+ bottom_items.append(
962
+ f"timeout: {timeout_seconds}s, warning every {warning_interval}s"
963
+ )
964
+
965
+ pre_content = metadata_text
779
966
 
780
967
  # Display using unified method
781
968
  self.display_message(
782
- content=tool_args,
969
+ content=content,
783
970
  message_type=MessageType.TOOL_CALL,
784
971
  name=name,
972
+ pre_content=pre_content,
785
973
  right_info=right_info,
786
974
  bottom_metadata=bottom_items,
787
975
  highlight_index=highlight_index,
788
976
  max_item_length=max_item_length,
789
- truncate_content=True,
977
+ truncate_content=truncate_content,
790
978
  )
791
979
 
792
980
  async def show_tool_update(self, updated_server: str, agent_name: str | None = None) -> None:
@@ -1004,6 +1192,30 @@ class ConsoleDisplay:
1004
1192
  markup=self._markup,
1005
1193
  )
1006
1194
 
1195
+ def _extract_reasoning_content(self, message: "PromptMessageExtended") -> Text | None:
1196
+ """Extract reasoning channel content as dim text."""
1197
+ channels = message.channels or {}
1198
+ reasoning_blocks = channels.get(REASONING) or []
1199
+ if not reasoning_blocks:
1200
+ return None
1201
+
1202
+ from fast_agent.mcp.helpers.content_helpers import get_text
1203
+
1204
+ reasoning_segments = []
1205
+ for block in reasoning_blocks:
1206
+ text = get_text(block)
1207
+ if text:
1208
+ reasoning_segments.append(text)
1209
+
1210
+ if not reasoning_segments:
1211
+ return None
1212
+
1213
+ joined = "\n".join(reasoning_segments)
1214
+ if not joined.strip():
1215
+ return None
1216
+
1217
+ return Text(joined, style="dim default")
1218
+
1007
1219
  async def show_assistant_message(
1008
1220
  self,
1009
1221
  message_text: Union[str, Text, "PromptMessageExtended"],
@@ -1036,22 +1248,7 @@ class ConsoleDisplay:
1036
1248
 
1037
1249
  if isinstance(message_text, PromptMessageExtended):
1038
1250
  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")
1251
+ pre_content = self._extract_reasoning_content(message_text)
1055
1252
  else:
1056
1253
  display_text = message_text
1057
1254
 
@@ -1083,6 +1280,54 @@ class ConsoleDisplay:
1083
1280
  if diagrams:
1084
1281
  self._display_mermaid_diagrams(diagrams)
1085
1282
 
1283
+ @contextmanager
1284
+ def streaming_assistant_message(
1285
+ self,
1286
+ *,
1287
+ bottom_items: List[str] | None = None,
1288
+ highlight_index: int | None = None,
1289
+ max_item_length: int | None = None,
1290
+ name: str | None = None,
1291
+ model: str | None = None,
1292
+ ) -> Iterator["_StreamingMessageHandle"]:
1293
+ """Create a streaming context for assistant messages."""
1294
+ streaming_enabled, streaming_mode = self.resolve_streaming_preferences()
1295
+
1296
+ if not streaming_enabled:
1297
+ yield _NullStreamingHandle()
1298
+ return
1299
+
1300
+ from fast_agent.ui.progress_display import progress_display
1301
+
1302
+ config = MESSAGE_CONFIGS[MessageType.ASSISTANT]
1303
+ block_color = config["block_color"]
1304
+ arrow = config["arrow"]
1305
+ arrow_style = config["arrow_style"]
1306
+
1307
+ left = f"[{block_color}]▎[/{block_color}][{arrow_style}]{arrow}[/{arrow_style}] "
1308
+ if name:
1309
+ left += f"[{block_color}]{name}[/{block_color}]"
1310
+
1311
+ right_info = f"[dim]{model}[/dim]" if model else ""
1312
+
1313
+ # Determine renderer based on streaming mode
1314
+ use_plain_text = streaming_mode == "plain"
1315
+
1316
+ handle = _StreamingMessageHandle(
1317
+ display=self,
1318
+ bottom_items=bottom_items,
1319
+ highlight_index=highlight_index,
1320
+ max_item_length=max_item_length,
1321
+ use_plain_text=use_plain_text,
1322
+ header_left=left,
1323
+ header_right=right_info,
1324
+ progress_display=progress_display,
1325
+ )
1326
+ try:
1327
+ yield handle
1328
+ finally:
1329
+ handle.close()
1330
+
1086
1331
  def _display_mermaid_diagrams(self, diagrams: List[MermaidDiagram]) -> None:
1087
1332
  """Display mermaid diagram links."""
1088
1333
  diagram_content = Text()
@@ -1373,3 +1618,610 @@ class ConsoleDisplay:
1373
1618
  summary_text = " • ".join(summary_parts)
1374
1619
  console.console.print(f"[dim]{summary_text}[/dim]")
1375
1620
  console.console.print()
1621
+
1622
+
1623
+ class _NullStreamingHandle:
1624
+ """No-op streaming handle used when streaming is disabled."""
1625
+
1626
+ def update(self, _chunk: str) -> None:
1627
+ return
1628
+
1629
+ def finalize(self, _message: "PromptMessageExtended | str") -> None:
1630
+ return
1631
+
1632
+ def close(self) -> None:
1633
+ return
1634
+
1635
+
1636
+ class _StreamingMessageHandle:
1637
+ """Helper that manages live rendering for streaming assistant responses."""
1638
+
1639
+ def __init__(
1640
+ self,
1641
+ *,
1642
+ display: ConsoleDisplay,
1643
+ bottom_items: List[str] | None,
1644
+ highlight_index: int | None,
1645
+ max_item_length: int | None,
1646
+ use_plain_text: bool = False,
1647
+ header_left: str = "",
1648
+ header_right: str = "",
1649
+ progress_display=None,
1650
+ ) -> None:
1651
+ self._display = display
1652
+ self._bottom_items = bottom_items
1653
+ self._highlight_index = highlight_index
1654
+ self._max_item_length = max_item_length
1655
+ self._use_plain_text = use_plain_text
1656
+ self._header_left = header_left
1657
+ self._header_right = header_right
1658
+ self._progress_display = progress_display
1659
+ self._progress_paused = False
1660
+ self._buffer: List[str] = []
1661
+ initial_renderable = Text("") if self._use_plain_text else Markdown("")
1662
+ refresh_rate = (
1663
+ PLAIN_STREAM_REFRESH_PER_SECOND
1664
+ if self._use_plain_text
1665
+ else MARKDOWN_STREAM_REFRESH_PER_SECOND
1666
+ )
1667
+ self._min_render_interval = 1.0 / refresh_rate if refresh_rate else None
1668
+ self._last_render_time = 0.0
1669
+ try:
1670
+ self._loop: asyncio.AbstractEventLoop | None = asyncio.get_running_loop()
1671
+ except RuntimeError:
1672
+ self._loop = None
1673
+ self._async_mode = self._loop is not None
1674
+ self._queue: asyncio.Queue[object] | None = asyncio.Queue() if self._async_mode else None
1675
+ self._stop_sentinel: object = object()
1676
+ self._worker_task: asyncio.Task[None] | None = None
1677
+ self._live: Live | None = Live(
1678
+ initial_renderable,
1679
+ console=console.console,
1680
+ vertical_overflow="ellipsis",
1681
+ refresh_per_second=refresh_rate,
1682
+ transient=True,
1683
+ )
1684
+ self._live_started = False
1685
+ self._active = True
1686
+ self._finalized = False
1687
+ # Track whether we're in a table to batch updates
1688
+ self._in_table = False
1689
+ self._pending_table_row = ""
1690
+ # Smart markdown truncator for creating display window (doesn't mutate buffer)
1691
+ self._truncator = MarkdownTruncator(target_height_ratio=MARKDOWN_STREAM_TARGET_RATIO)
1692
+ self._plain_truncator = (
1693
+ PlainTextTruncator(target_height_ratio=PLAIN_STREAM_TARGET_RATIO)
1694
+ if self._use_plain_text
1695
+ else None
1696
+ )
1697
+ self._max_render_height = 0
1698
+
1699
+ if self._async_mode and self._loop and self._queue is not None:
1700
+ self._worker_task = self._loop.create_task(self._render_worker())
1701
+
1702
+ def update(self, chunk: str) -> None:
1703
+ if not self._active or not chunk:
1704
+ return
1705
+
1706
+ if self._async_mode and self._queue is not None:
1707
+ self._enqueue_chunk(chunk)
1708
+ return
1709
+
1710
+ if self._handle_chunk(chunk):
1711
+ self._render_current_buffer()
1712
+
1713
+ def _build_header(self) -> Text:
1714
+ """Build the header bar as a Text renderable.
1715
+
1716
+ Returns:
1717
+ Text object representing the header bar.
1718
+ """
1719
+ width = console.console.size.width
1720
+
1721
+ # Create left text
1722
+ left_text = Text.from_markup(self._header_left)
1723
+
1724
+ # Create right text if we have info
1725
+ if self._header_right and self._header_right.strip():
1726
+ # Add dim brackets around the right info
1727
+ right_text = Text()
1728
+ right_text.append("[", style="dim")
1729
+ right_text.append_text(Text.from_markup(self._header_right))
1730
+ right_text.append("]", style="dim")
1731
+ # Calculate separator count
1732
+ separator_count = width - left_text.cell_len - right_text.cell_len
1733
+ if separator_count < 1:
1734
+ separator_count = 1 # Always at least 1 separator
1735
+ else:
1736
+ right_text = Text("")
1737
+ separator_count = width - left_text.cell_len
1738
+
1739
+ # Build the combined line
1740
+ combined = Text()
1741
+ combined.append_text(left_text)
1742
+ combined.append(" ", style="default")
1743
+ combined.append("─" * (separator_count - 1), style="dim")
1744
+ combined.append_text(right_text)
1745
+
1746
+ return combined
1747
+
1748
+ def _pause_progress_display(self) -> None:
1749
+ if self._progress_display and not self._progress_paused:
1750
+ try:
1751
+ self._progress_display.pause()
1752
+ self._progress_paused = True
1753
+ except Exception:
1754
+ self._progress_paused = False
1755
+
1756
+ def _resume_progress_display(self) -> None:
1757
+ if self._progress_display and self._progress_paused:
1758
+ try:
1759
+ self._progress_display.resume()
1760
+ except Exception:
1761
+ pass
1762
+ finally:
1763
+ self._progress_paused = False
1764
+
1765
+ def _ensure_started(self) -> None:
1766
+ """Start live rendering and pause progress display if needed."""
1767
+ if not self._live:
1768
+ return
1769
+
1770
+ if self._live_started:
1771
+ return
1772
+
1773
+ self._pause_progress_display()
1774
+
1775
+ if self._live and not self._live_started:
1776
+ self._live.__enter__()
1777
+ self._live_started = True
1778
+
1779
+ def _close_incomplete_code_blocks(self, text: str) -> str:
1780
+ """Add temporary closing fence to incomplete code blocks for display.
1781
+
1782
+ During streaming, incomplete code blocks (opening fence without closing)
1783
+ are rendered as literal text by Rich's Markdown renderer. This method
1784
+ adds a temporary closing fence so the code can be syntax-highlighted
1785
+ during streaming display.
1786
+
1787
+ When the real closing fence arrives in a subsequent chunk, this method
1788
+ will detect the now-complete block and stop adding the temporary fence.
1789
+
1790
+ Args:
1791
+ text: The markdown text that may contain incomplete code blocks.
1792
+
1793
+ Returns:
1794
+ Text with temporary closing fences added for incomplete code blocks.
1795
+ """
1796
+ import re
1797
+
1798
+ # Count opening and closing fences
1799
+ opening_fences = len(re.findall(r"^```", text, re.MULTILINE))
1800
+ closing_fences = len(re.findall(r"^```\s*$", text, re.MULTILINE))
1801
+
1802
+ # If we have more opening fences than closing fences, and the text
1803
+ # doesn't end with a closing fence, we have an incomplete code block
1804
+ if opening_fences > closing_fences:
1805
+ # Check if text ends with a closing fence (might be partial line)
1806
+ if not re.search(r"```\s*$", text):
1807
+ # Add temporary closing fence for display only
1808
+ return text + "\n```\n"
1809
+
1810
+ return text
1811
+
1812
+ def _trim_to_displayable(self, text: str) -> str:
1813
+ """Trim text to keep only displayable content plus small buffer.
1814
+
1815
+ Keeps ~1.5x terminal height worth of recent content.
1816
+ Uses the optimized streaming truncator for better performance.
1817
+
1818
+ Args:
1819
+ text: Full text to trim
1820
+
1821
+ Returns:
1822
+ Trimmed text (most recent content)
1823
+ """
1824
+ if not text:
1825
+ return text
1826
+
1827
+ terminal_height = console.console.size.height - 1
1828
+
1829
+ if self._use_plain_text and self._plain_truncator:
1830
+ terminal_width = console.console.size.width
1831
+ return self._plain_truncator.truncate(
1832
+ text,
1833
+ terminal_height=terminal_height,
1834
+ terminal_width=terminal_width,
1835
+ )
1836
+
1837
+ # Use the optimized streaming truncator (16x faster!) for markdown
1838
+ return self._truncator.truncate(
1839
+ text,
1840
+ terminal_height=terminal_height,
1841
+ console=console.console,
1842
+ code_theme=CODE_STYLE,
1843
+ prefer_recent=True, # Streaming mode
1844
+ )
1845
+
1846
+ def _switch_to_plain_text(self) -> None:
1847
+ """Switch from markdown to plain text rendering for tool arguments."""
1848
+ if not self._use_plain_text:
1849
+ self._use_plain_text = True
1850
+ # Initialize plain truncator if needed
1851
+ if not self._plain_truncator:
1852
+ self._plain_truncator = PlainTextTruncator(
1853
+ target_height_ratio=PLAIN_STREAM_TARGET_RATIO
1854
+ )
1855
+
1856
+ def finalize(self, _message: "PromptMessageExtended | str") -> None:
1857
+ if not self._active or self._finalized:
1858
+ return
1859
+
1860
+ self._finalized = True
1861
+ self.close()
1862
+
1863
+ def close(self) -> None:
1864
+ if not self._active:
1865
+ return
1866
+
1867
+ self._active = False
1868
+ if self._async_mode:
1869
+ if self._queue and self._loop:
1870
+ try:
1871
+ current_loop = asyncio.get_running_loop()
1872
+ except RuntimeError:
1873
+ current_loop = None
1874
+
1875
+ # Send stop sentinel to queue
1876
+ try:
1877
+ if current_loop is self._loop:
1878
+ self._queue.put_nowait(self._stop_sentinel)
1879
+ else:
1880
+ # Use call_soon_threadsafe from different thread/loop
1881
+ self._loop.call_soon_threadsafe(self._queue.put_nowait, self._stop_sentinel)
1882
+ except RuntimeError as e:
1883
+ # Expected during event loop shutdown - log at debug level
1884
+ logger.debug(
1885
+ "RuntimeError while closing streaming display (expected during shutdown)",
1886
+ data={"error": str(e)},
1887
+ )
1888
+ except Exception as e:
1889
+ # Unexpected exception - log at warning level
1890
+ logger.warning(
1891
+ "Unexpected error while closing streaming display",
1892
+ exc_info=True,
1893
+ data={"error": str(e)},
1894
+ )
1895
+ if self._worker_task:
1896
+ self._worker_task.cancel()
1897
+ self._worker_task = None
1898
+ self._shutdown_live_resources()
1899
+ self._max_render_height = 0
1900
+
1901
+ def _extract_trailing_paragraph(self, text: str) -> str:
1902
+ """Return text since the last blank line, used to detect in-progress paragraphs."""
1903
+ if not text:
1904
+ return ""
1905
+ double_break = text.rfind("\n\n")
1906
+ if double_break != -1:
1907
+ candidate = text[double_break + 2 :]
1908
+ else:
1909
+ candidate = text
1910
+ if "\n" in candidate:
1911
+ candidate = candidate.split("\n")[-1]
1912
+ return candidate
1913
+
1914
+ def _wrap_plain_chunk(self, chunk: str) -> str:
1915
+ """Insert soft line breaks into long plain text segments."""
1916
+ width = max(1, console.console.size.width)
1917
+ if not chunk or width <= 1:
1918
+ return chunk
1919
+
1920
+ result_segments: List[str] = []
1921
+ start = 0
1922
+ length = len(chunk)
1923
+
1924
+ while start < length:
1925
+ newline_pos = chunk.find("\n", start)
1926
+ if newline_pos == -1:
1927
+ line = chunk[start:]
1928
+ delimiter = ""
1929
+ start = length
1930
+ else:
1931
+ line = chunk[start:newline_pos]
1932
+ delimiter = "\n"
1933
+ start = newline_pos + 1
1934
+
1935
+ if len(line.expandtabs()) > width:
1936
+ wrapped = self._wrap_plain_line(line, width)
1937
+ result_segments.append("\n".join(wrapped))
1938
+ else:
1939
+ result_segments.append(line)
1940
+
1941
+ result_segments.append(delimiter)
1942
+
1943
+ return "".join(result_segments)
1944
+
1945
+ @staticmethod
1946
+ def _wrap_plain_line(line: str, width: int) -> List[str]:
1947
+ """Wrap a single line to the terminal width."""
1948
+ if not line:
1949
+ return [""]
1950
+
1951
+ segments: List[str] = []
1952
+ remaining = line
1953
+
1954
+ while len(remaining) > width:
1955
+ break_at = remaining.rfind(" ", 0, width)
1956
+ if break_at == -1 or break_at < width // 2:
1957
+ break_at = width
1958
+ segments.append(remaining[:break_at])
1959
+ remaining = remaining[break_at:]
1960
+ else:
1961
+ segments.append(remaining[:break_at])
1962
+ remaining = remaining[break_at + 1 :]
1963
+ segments.append(remaining)
1964
+ return segments
1965
+
1966
+ def _estimate_plain_render_height(self, text: str) -> int:
1967
+ """Estimate rendered height for plain text taking terminal width into account."""
1968
+ if not text:
1969
+ return 0
1970
+
1971
+ width = max(1, console.console.size.width)
1972
+ lines = text.split("\n")
1973
+ total = 0
1974
+ for line in lines:
1975
+ expanded_len = len(line.expandtabs())
1976
+ total += max(1, math.ceil(expanded_len / width)) if expanded_len else 1
1977
+ return total
1978
+
1979
+ def _enqueue_chunk(self, chunk: str) -> None:
1980
+ if not self._queue or not self._loop:
1981
+ return
1982
+
1983
+ try:
1984
+ current_loop = asyncio.get_running_loop()
1985
+ except RuntimeError:
1986
+ current_loop = None
1987
+
1988
+ if current_loop is self._loop:
1989
+ try:
1990
+ self._queue.put_nowait(chunk)
1991
+ except asyncio.QueueFull:
1992
+ # Shouldn't happen with default unlimited queue, but fail safe
1993
+ pass
1994
+ else:
1995
+ try:
1996
+ self._loop.call_soon_threadsafe(self._queue.put_nowait, chunk)
1997
+ except RuntimeError as e:
1998
+ # Expected during event loop shutdown - log at debug level
1999
+ logger.debug(
2000
+ "RuntimeError while enqueuing chunk (expected during shutdown)",
2001
+ data={"error": str(e), "chunk_length": len(chunk)},
2002
+ )
2003
+ except Exception as e:
2004
+ # Unexpected exception - log at warning level
2005
+ logger.warning(
2006
+ "Unexpected error while enqueuing chunk",
2007
+ exc_info=True,
2008
+ data={"error": str(e), "chunk_length": len(chunk)},
2009
+ )
2010
+
2011
+ def _handle_chunk(self, chunk: str) -> bool:
2012
+ """
2013
+ Process an incoming chunk and determine whether rendering is needed.
2014
+
2015
+ Returns:
2016
+ True if the display should be updated, False otherwise.
2017
+ """
2018
+ if not chunk:
2019
+ return False
2020
+
2021
+ if self._use_plain_text:
2022
+ chunk = self._wrap_plain_chunk(chunk)
2023
+ if self._pending_table_row:
2024
+ self._buffer.append(self._pending_table_row)
2025
+ self._pending_table_row = ""
2026
+ else:
2027
+ text_so_far = "".join(self._buffer)
2028
+ lines = text_so_far.strip().split("\n")
2029
+ last_line = lines[-1] if lines else ""
2030
+ currently_in_table = last_line.strip().startswith("|")
2031
+
2032
+ if currently_in_table and "\n" not in chunk:
2033
+ self._pending_table_row += chunk
2034
+ return False
2035
+
2036
+ if self._pending_table_row:
2037
+ self._buffer.append(self._pending_table_row)
2038
+ self._pending_table_row = ""
2039
+
2040
+ self._buffer.append(chunk)
2041
+ return True
2042
+
2043
+ def _render_current_buffer(self) -> None:
2044
+ if not self._buffer:
2045
+ return
2046
+
2047
+ self._ensure_started()
2048
+
2049
+ if not self._live:
2050
+ return
2051
+
2052
+ text = "".join(self._buffer)
2053
+
2054
+ if self._use_plain_text:
2055
+ trimmed = self._trim_to_displayable(text)
2056
+ if trimmed != text:
2057
+ text = trimmed
2058
+ self._buffer = [trimmed]
2059
+ trailing_paragraph = self._extract_trailing_paragraph(text)
2060
+ if trailing_paragraph and "\n" not in trailing_paragraph:
2061
+ width = max(1, console.console.size.width)
2062
+ target_ratio = (
2063
+ PLAIN_STREAM_TARGET_RATIO if self._use_plain_text else MARKDOWN_STREAM_TARGET_RATIO
2064
+ )
2065
+ target_rows = max(1, int(console.console.size.height * target_ratio) - 1)
2066
+ estimated_rows = math.ceil(len(trailing_paragraph.expandtabs()) / width)
2067
+ if estimated_rows > target_rows:
2068
+ trimmed_text = self._trim_to_displayable(text)
2069
+ if trimmed_text != text:
2070
+ text = trimmed_text
2071
+ self._buffer = [trimmed_text]
2072
+
2073
+ if len(self._buffer) > 10:
2074
+ text = self._trim_to_displayable(text)
2075
+ self._buffer = [text]
2076
+
2077
+ # Build the header bar
2078
+ header = self._build_header()
2079
+
2080
+ # Build the content renderable
2081
+ max_allowed_height = max(1, console.console.size.height - 2)
2082
+ self._max_render_height = min(self._max_render_height, max_allowed_height)
2083
+
2084
+ if self._use_plain_text:
2085
+ content_height = self._estimate_plain_render_height(text)
2086
+ budget_height = min(content_height + PLAIN_STREAM_HEIGHT_FUDGE, max_allowed_height)
2087
+
2088
+ if budget_height > self._max_render_height:
2089
+ self._max_render_height = budget_height
2090
+
2091
+ padding_lines = max(0, self._max_render_height - content_height)
2092
+ display_text = text + ("\n" * padding_lines if padding_lines else "")
2093
+ content = Text(display_text)
2094
+ else:
2095
+ prepared = _prepare_markdown_content(text, self._display._escape_xml)
2096
+ prepared_for_display = self._close_incomplete_code_blocks(prepared)
2097
+
2098
+ content_height = self._truncator.measure_rendered_height(
2099
+ prepared_for_display, console.console, CODE_STYLE
2100
+ )
2101
+ budget_height = min(content_height + MARKDOWN_STREAM_HEIGHT_FUDGE, max_allowed_height)
2102
+
2103
+ if budget_height > self._max_render_height:
2104
+ self._max_render_height = budget_height
2105
+
2106
+ padding_lines = max(0, self._max_render_height - content_height)
2107
+ if padding_lines:
2108
+ prepared_for_display = prepared_for_display + ("\n" * padding_lines)
2109
+
2110
+ content = Markdown(prepared_for_display, code_theme=CODE_STYLE)
2111
+
2112
+ from rich.console import Group
2113
+
2114
+ header_with_spacing = header.copy()
2115
+ header_with_spacing.append("\n", style="default")
2116
+
2117
+ combined = Group(header_with_spacing, content)
2118
+ try:
2119
+ self._live.update(combined)
2120
+ self._last_render_time = time.monotonic()
2121
+ except Exception:
2122
+ # Avoid crashing streaming on renderer errors
2123
+ pass
2124
+
2125
+ async def _render_worker(self) -> None:
2126
+ assert self._queue is not None
2127
+ try:
2128
+ while True:
2129
+ try:
2130
+ item = await self._queue.get()
2131
+ except asyncio.CancelledError:
2132
+ break
2133
+
2134
+ if item is self._stop_sentinel:
2135
+ break
2136
+
2137
+ stop_requested = False
2138
+ chunks = [item]
2139
+ while True:
2140
+ try:
2141
+ next_item = self._queue.get_nowait()
2142
+ except asyncio.QueueEmpty:
2143
+ break
2144
+ if next_item is self._stop_sentinel:
2145
+ stop_requested = True
2146
+ break
2147
+ chunks.append(next_item)
2148
+
2149
+ should_render = False
2150
+ for chunk in chunks:
2151
+ if isinstance(chunk, str):
2152
+ should_render = self._handle_chunk(chunk) or should_render
2153
+
2154
+ if should_render:
2155
+ self._render_current_buffer()
2156
+ if self._min_render_interval:
2157
+ try:
2158
+ await asyncio.sleep(self._min_render_interval)
2159
+ except asyncio.CancelledError:
2160
+ break
2161
+
2162
+ if stop_requested:
2163
+ break
2164
+ except asyncio.CancelledError:
2165
+ pass
2166
+ finally:
2167
+ self._shutdown_live_resources()
2168
+
2169
+ def _shutdown_live_resources(self) -> None:
2170
+ if self._live and self._live_started:
2171
+ try:
2172
+ self._live.__exit__(None, None, None)
2173
+ except Exception:
2174
+ pass
2175
+ self._live = None
2176
+ self._live_started = False
2177
+
2178
+ self._resume_progress_display()
2179
+ self._active = False
2180
+
2181
+ def handle_tool_event(self, event_type: str, info: Dict[str, Any] | None = None) -> None:
2182
+ """Handle tool streaming events with comprehensive error handling.
2183
+
2184
+ This is called from listener callbacks during async streaming, so we need
2185
+ to be defensive about any errors to prevent crashes in the event loop.
2186
+ """
2187
+ try:
2188
+ if not self._active:
2189
+ return
2190
+
2191
+ # Check if this provider streams tool arguments
2192
+ streams_arguments = info.get("streams_arguments", False) if info else False
2193
+
2194
+ if event_type == "start":
2195
+ if streams_arguments:
2196
+ # OpenAI: Switch to plain text and show tool call header
2197
+ self._switch_to_plain_text()
2198
+ tool_name = info.get("tool_name", "unknown") if info else "unknown"
2199
+ self.update(f"\n→ Calling {tool_name}\n")
2200
+ else:
2201
+ # Anthropic: Close streaming display immediately
2202
+ self.close()
2203
+ return
2204
+ elif event_type == "delta":
2205
+ if streams_arguments and info and "chunk" in info:
2206
+ # Stream the tool argument chunks as plain text
2207
+ self.update(info["chunk"])
2208
+ elif event_type == "text":
2209
+ self._pause_progress_display()
2210
+ elif event_type == "stop":
2211
+ if streams_arguments:
2212
+ # Close the streaming display
2213
+ self.update("\n")
2214
+ self.close()
2215
+ else:
2216
+ self._resume_progress_display()
2217
+ except Exception as e:
2218
+ # Log but don't crash - streaming display is "nice to have"
2219
+ logger.warning(
2220
+ "Error handling tool event",
2221
+ exc_info=True,
2222
+ data={
2223
+ "event_type": event_type,
2224
+ "streams_arguments": info.get("streams_arguments") if info else None,
2225
+ "error": str(e),
2226
+ },
2227
+ )