fast-agent-mcp 0.3.16__py3-none-any.whl → 0.3.18__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,8 @@
1
- import asyncio
2
- import math
3
- import time
4
1
  from contextlib import contextmanager
5
- from enum import Enum
6
2
  from json import JSONDecodeError
7
- from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Union
3
+ from typing import TYPE_CHECKING, Any, Iterator, List, Mapping, Optional, Union
8
4
 
9
5
  from mcp.types import CallToolResult
10
- from rich.live import Live
11
6
  from rich.markdown import Markdown
12
7
  from rich.panel import Panel
13
8
  from rich.text import Text
@@ -16,7 +11,7 @@ from fast_agent.config import Settings
16
11
  from fast_agent.constants import REASONING
17
12
  from fast_agent.core.logging.logger import get_logger
18
13
  from fast_agent.ui import console
19
- from fast_agent.ui.markdown_truncator import MarkdownTruncator
14
+ from fast_agent.ui.markdown_helpers import prepare_markdown_content
20
15
  from fast_agent.ui.mcp_ui_utils import UILink
21
16
  from fast_agent.ui.mermaid_utils import (
22
17
  MermaidDiagram,
@@ -24,7 +19,17 @@ from fast_agent.ui.mermaid_utils import (
24
19
  detect_diagram_type,
25
20
  extract_mermaid_diagrams,
26
21
  )
27
- from fast_agent.ui.plain_text_truncator import PlainTextTruncator
22
+ from fast_agent.ui.message_primitives import MESSAGE_CONFIGS, MessageType
23
+ from fast_agent.ui.streaming import (
24
+ NullStreamingHandle as _NullStreamingHandle,
25
+ )
26
+ from fast_agent.ui.streaming import (
27
+ StreamingHandle,
28
+ )
29
+ from fast_agent.ui.streaming import (
30
+ StreamingMessageHandle as _StreamingMessageHandle,
31
+ )
32
+ from fast_agent.ui.tool_display import ToolDisplay
28
33
 
29
34
  if TYPE_CHECKING:
30
35
  from fast_agent.mcp.prompt_message_extended import PromptMessageExtended
@@ -34,190 +39,6 @@ logger = get_logger(__name__)
34
39
 
35
40
  CODE_STYLE = "native"
36
41
 
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
-
44
-
45
- class MessageType(Enum):
46
- """Types of messages that can be displayed."""
47
-
48
- USER = "user"
49
- ASSISTANT = "assistant"
50
- SYSTEM = "system"
51
- TOOL_CALL = "tool_call"
52
- TOOL_RESULT = "tool_result"
53
-
54
-
55
- # Configuration for each message type
56
- MESSAGE_CONFIGS = {
57
- MessageType.USER: {
58
- "block_color": "blue",
59
- "arrow": "▶",
60
- "arrow_style": "dim blue",
61
- "highlight_color": "blue",
62
- },
63
- MessageType.ASSISTANT: {
64
- "block_color": "green",
65
- "arrow": "◀",
66
- "arrow_style": "dim green",
67
- "highlight_color": "bright_green",
68
- },
69
- MessageType.SYSTEM: {
70
- "block_color": "yellow",
71
- "arrow": "●",
72
- "arrow_style": "dim yellow",
73
- "highlight_color": "bright_yellow",
74
- },
75
- MessageType.TOOL_CALL: {
76
- "block_color": "magenta",
77
- "arrow": "◀",
78
- "arrow_style": "dim magenta",
79
- "highlight_color": "magenta",
80
- },
81
- MessageType.TOOL_RESULT: {
82
- "block_color": "magenta", # Can be overridden to red if error
83
- "arrow": "▶",
84
- "arrow_style": "dim magenta",
85
- "highlight_color": "magenta",
86
- },
87
- }
88
-
89
- HTML_ESCAPE_CHARS = {
90
- "&": "&",
91
- "<": "&lt;",
92
- ">": "&gt;",
93
- '"': "&quot;",
94
- "'": "&#39;",
95
- }
96
-
97
-
98
- def _prepare_markdown_content(content: str, escape_xml: bool = True) -> str:
99
- """Prepare content for markdown rendering by escaping HTML/XML tags
100
- while preserving code blocks and inline code.
101
-
102
- This ensures XML/HTML tags are displayed as visible text rather than
103
- being interpreted as markup by the markdown renderer.
104
-
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).
108
- """
109
- if not escape_xml or not isinstance(content, str):
110
- return content
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
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
172
- import re
173
-
174
- fence_pattern = r"^```"
175
- fences = list(re.finditer(fence_pattern, content, re.MULTILINE))
176
-
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)))
185
-
186
- # Sort and merge overlapping ranges
187
- protected_ranges.sort(key=lambda x: x[0])
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
-
198
- # Build the escaped content
199
- result = []
200
- last_end = 0
201
-
202
- for start, end in merged_ranges:
203
- # Escape everything outside protected ranges
204
- unprotected_text = content[last_end:start]
205
- for char, replacement in HTML_ESCAPE_CHARS.items():
206
- unprotected_text = unprotected_text.replace(char, replacement)
207
- result.append(unprotected_text)
208
-
209
- # Keep protected ranges (code blocks) as-is
210
- result.append(content[start:end])
211
- last_end = end
212
-
213
- # Escape any remaining content after the last protected range
214
- remainder_text = content[last_end:]
215
- for char, replacement in HTML_ESCAPE_CHARS.items():
216
- remainder_text = remainder_text.replace(char, replacement)
217
- result.append(remainder_text)
218
-
219
- return "".join(result)
220
-
221
42
 
222
43
  class ConsoleDisplay:
223
44
  """
@@ -225,6 +46,8 @@ class ConsoleDisplay:
225
46
  This centralizes the UI display logic used by LLM implementations.
226
47
  """
227
48
 
49
+ CODE_STYLE = CODE_STYLE
50
+
228
51
  def __init__(self, config: Settings | None = None) -> None:
229
52
  """
230
53
  Initialize the console display handler.
@@ -235,6 +58,11 @@ class ConsoleDisplay:
235
58
  self.config = config
236
59
  self._markup = config.logger.enable_markup if config else True
237
60
  self._escape_xml = True
61
+ self._tool_display = ToolDisplay(self)
62
+
63
+ @property
64
+ def code_style(self) -> str:
65
+ return CODE_STYLE
238
66
 
239
67
  def resolve_streaming_preferences(self) -> tuple[bool, str]:
240
68
  """Return whether streaming is enabled plus the active mode."""
@@ -365,7 +193,6 @@ class ConsoleDisplay:
365
193
  import json
366
194
  import re
367
195
 
368
- from rich.markdown import Markdown
369
196
  from rich.pretty import Pretty
370
197
  from rich.syntax import Syntax
371
198
 
@@ -411,7 +238,7 @@ class ConsoleDisplay:
411
238
  # Check for markdown markers before deciding to use markdown rendering
412
239
  if any(marker in content for marker in ["##", "**", "*", "`", "---", "###"]):
413
240
  # Has markdown markers - render as markdown with escaping
414
- prepared_content = _prepare_markdown_content(content, self._escape_xml)
241
+ prepared_content = prepare_markdown_content(content, self._escape_xml)
415
242
  md = Markdown(prepared_content, code_theme=CODE_STYLE)
416
243
  console.console.print(md, markup=self._markup)
417
244
  else:
@@ -431,7 +258,7 @@ class ConsoleDisplay:
431
258
  # Check if it looks like markdown
432
259
  if any(marker in content for marker in ["##", "**", "*", "`", "---", "###"]):
433
260
  # Escape HTML/XML tags while preserving code blocks
434
- prepared_content = _prepare_markdown_content(content, self._escape_xml)
261
+ prepared_content = prepare_markdown_content(content, self._escape_xml)
435
262
  md = Markdown(prepared_content, code_theme=CODE_STYLE)
436
263
  # Markdown handles its own styling, don't apply style
437
264
  console.console.print(md, markup=self._markup)
@@ -459,10 +286,6 @@ class ConsoleDisplay:
459
286
  # We need to handle the main content (which may have markdown)
460
287
  # and any styled segments that were appended
461
288
 
462
- # For now, we'll render the entire content with markdown support
463
- # This means extracting each span and handling it appropriately
464
- from rich.markdown import Markdown
465
-
466
289
  # If the Text object has multiple spans with different styles,
467
290
  # we need to be careful about how we render them
468
291
  if len(content._spans) > 1:
@@ -479,9 +302,7 @@ class ConsoleDisplay:
479
302
  marker in markdown_part for marker in ["##", "**", "*", "`", "---", "###"]
480
303
  ):
481
304
  # Render markdown part
482
- prepared_content = _prepare_markdown_content(
483
- markdown_part, self._escape_xml
484
- )
305
+ prepared_content = prepare_markdown_content(markdown_part, self._escape_xml)
485
306
  md = Markdown(prepared_content, code_theme=CODE_STYLE)
486
307
  console.console.print(md, markup=self._markup)
487
308
 
@@ -499,7 +320,7 @@ class ConsoleDisplay:
499
320
  console.console.print(content, markup=self._markup)
500
321
  else:
501
322
  # Simple case: entire text should be rendered as markdown
502
- prepared_content = _prepare_markdown_content(plain_text, self._escape_xml)
323
+ prepared_content = prepare_markdown_content(plain_text, self._escape_xml)
503
324
  md = Markdown(prepared_content, code_theme=CODE_STYLE)
504
325
  console.console.print(md, markup=self._markup)
505
326
  else:
@@ -679,344 +500,35 @@ class ConsoleDisplay:
679
500
  tool_name: str | None = None,
680
501
  skybridge_config: "SkybridgeServerConfig | None" = None,
681
502
  ) -> None:
682
- """Display a tool result in the new visual style.
683
-
684
- Args:
685
- result: The tool result to display
686
- name: Optional agent name
687
- tool_name: Optional tool name for skybridge detection
688
- skybridge_config: Optional skybridge configuration for the server
689
- """
690
- if not self.config or not self.config.logger.show_tools:
691
- return
692
-
693
- # Import content helpers
694
- from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content
695
-
696
- # Analyze content to determine display format and status
697
- content = result.content
698
- structured_content = getattr(result, "structuredContent", None)
699
- has_structured = structured_content is not None
700
-
701
- # Determine if this is a skybridge tool
702
- is_skybridge_tool = False
703
- skybridge_resource_uri = None
704
- if has_structured and tool_name and skybridge_config:
705
- # Check if this tool is a valid skybridge tool
706
- for tool_cfg in skybridge_config.tools:
707
- if tool_cfg.tool_name == tool_name and tool_cfg.is_valid:
708
- is_skybridge_tool = True
709
- skybridge_resource_uri = tool_cfg.resource_uri
710
- break
711
-
712
- if result.isError:
713
- status = "ERROR"
714
- else:
715
- # Check if it's a list with content blocks
716
- if len(content) == 0:
717
- status = "No Content"
718
- elif len(content) == 1 and is_text_content(content[0]):
719
- text_content = get_text(content[0])
720
- char_count = len(text_content) if text_content else 0
721
- status = f"Text Only {char_count} chars"
722
- else:
723
- text_count = sum(1 for item in content if is_text_content(item))
724
- if text_count == len(content):
725
- status = f"{len(content)} Text Blocks" if len(content) > 1 else "1 Text Block"
726
- else:
727
- status = (
728
- f"{len(content)} Content Blocks" if len(content) > 1 else "1 Content Block"
729
- )
730
-
731
- # Build transport channel info for bottom bar
732
- channel = getattr(result, "transport_channel", None)
733
- bottom_metadata_items: List[str] = []
734
- if channel:
735
- # Format channel info for bottom bar
736
- if channel == "post-json":
737
- transport_info = "HTTP (JSON-RPC)"
738
- elif channel == "post-sse":
739
- transport_info = "HTTP (SSE)"
740
- elif channel == "get":
741
- transport_info = "Legacy SSE"
742
- elif channel == "resumption":
743
- transport_info = "Resumption"
744
- elif channel == "stdio":
745
- transport_info = "STDIO"
746
- else:
747
- transport_info = channel.upper()
748
-
749
- bottom_metadata_items.append(transport_info)
750
-
751
- elapsed = getattr(result, "transport_elapsed", None)
752
- if isinstance(elapsed, (int, float)):
753
- bottom_metadata_items.append(self._format_elapsed(float(elapsed)))
754
-
755
- # Add structured content indicator if present
756
- if has_structured:
757
- bottom_metadata_items.append("Structured ■")
758
-
759
- bottom_metadata = bottom_metadata_items or None
760
-
761
- # Build right info (without channel info)
762
- right_info = f"[dim]tool result - {status}[/dim]"
763
-
764
- if has_structured:
765
- # Handle structured content display manually to insert it before bottom separator
766
- # Display main content without bottom separator
767
- config = MESSAGE_CONFIGS[MessageType.TOOL_RESULT]
768
- block_color = "red" if result.isError else config["block_color"]
769
- arrow = config["arrow"]
770
- arrow_style = config["arrow_style"]
771
- left = f"[{block_color}]▎[/{block_color}][{arrow_style}]{arrow}[/{arrow_style}]"
772
- if name:
773
- left += f" [{block_color if not result.isError else 'red'}]{name}[/{block_color if not result.isError else 'red'}]"
774
-
775
- # Top separator
776
- self._create_combined_separator_status(left, right_info)
777
-
778
- # Main content
779
- self._display_content(
780
- content, True, result.isError, MessageType.TOOL_RESULT, check_markdown_markers=False
781
- )
782
-
783
- # Structured content separator and display
784
- console.console.print()
785
- total_width = console.console.size.width
786
-
787
- if is_skybridge_tool:
788
- # Skybridge: magenta separator with resource URI
789
- resource_label = (
790
- f"skybridge resource: {skybridge_resource_uri}"
791
- if skybridge_resource_uri
792
- else "skybridge resource"
793
- )
794
- prefix = Text("─| ")
795
- prefix.stylize("dim")
796
- resource_text = Text(resource_label, style="magenta")
797
- suffix = Text(" |")
798
- suffix.stylize("dim")
799
-
800
- separator_line = Text()
801
- separator_line.append_text(prefix)
802
- separator_line.append_text(resource_text)
803
- separator_line.append_text(suffix)
804
- remaining = total_width - separator_line.cell_len
805
- if remaining > 0:
806
- separator_line.append("─" * remaining, style="dim")
807
- console.console.print(separator_line, markup=self._markup)
808
- console.console.print()
809
-
810
- # Display with bright syntax highlighting
811
- import json
812
-
813
- from rich.syntax import Syntax
814
-
815
- json_str = json.dumps(structured_content, indent=2)
816
- syntax_obj = Syntax(json_str, "json", theme=CODE_STYLE, background_color="default")
817
- console.console.print(syntax_obj, markup=self._markup)
818
- else:
819
- # Regular tool: dim separator
820
- prefix = Text("─| ")
821
- prefix.stylize("dim")
822
- label_text = Text("Structured Content", style="dim")
823
- suffix = Text(" |")
824
- suffix.stylize("dim")
825
-
826
- separator_line = Text()
827
- separator_line.append_text(prefix)
828
- separator_line.append_text(label_text)
829
- separator_line.append_text(suffix)
830
- remaining = total_width - separator_line.cell_len
831
- if remaining > 0:
832
- separator_line.append("─" * remaining, style="dim")
833
- console.console.print(separator_line, markup=self._markup)
834
- console.console.print()
835
-
836
- # Display truncated content in dim
837
- from rich.pretty import Pretty
838
-
839
- if self.config and self.config.logger.truncate_tools:
840
- pretty_obj = Pretty(structured_content, max_length=10, max_string=50)
841
- else:
842
- pretty_obj = Pretty(structured_content)
843
- console.console.print(pretty_obj, style="dim", markup=self._markup)
844
-
845
- # Bottom separator with metadata
846
- console.console.print()
847
- if bottom_metadata:
848
- display_items = (
849
- self._shorten_items(bottom_metadata, 12) if True else bottom_metadata
850
- )
851
- prefix = Text("─| ")
852
- prefix.stylize("dim")
853
- suffix = Text(" |")
854
- suffix.stylize("dim")
855
- available = max(0, total_width - prefix.cell_len - suffix.cell_len)
856
-
857
- metadata_text = self._format_bottom_metadata(
858
- display_items,
859
- None,
860
- config["highlight_color"],
861
- max_width=available,
862
- )
863
-
864
- line = Text()
865
- line.append_text(prefix)
866
- line.append_text(metadata_text)
867
- line.append_text(suffix)
868
- remaining = total_width - line.cell_len
869
- if remaining > 0:
870
- line.append("─" * remaining, style="dim")
871
- console.console.print(line, markup=self._markup)
872
- else:
873
- console.console.print("─" * total_width, style="dim")
874
- console.console.print()
875
-
876
- else:
877
- # No structured content - use standard display
878
- self.display_message(
879
- content=content,
880
- message_type=MessageType.TOOL_RESULT,
881
- name=name,
882
- right_info=right_info,
883
- bottom_metadata=bottom_metadata,
884
- is_error=result.isError,
885
- truncate_content=True,
886
- )
503
+ self._tool_display.show_tool_result(
504
+ result,
505
+ name=name,
506
+ tool_name=tool_name,
507
+ skybridge_config=skybridge_config,
508
+ )
887
509
 
888
510
  def show_tool_call(
889
511
  self,
890
512
  tool_name: str,
891
- tool_args: Dict[str, Any] | None,
513
+ tool_args: dict[str, Any] | None,
892
514
  bottom_items: list[str] | None = None,
893
515
  highlight_index: int | None = None,
894
516
  max_item_length: int | None = None,
895
517
  name: str | None = None,
896
- metadata: Dict[str, Any] | None = None,
518
+ metadata: dict[str, Any] | None = None,
897
519
  ) -> None:
898
- """Display a tool call in the new visual style.
899
-
900
- Args:
901
- tool_name: Name of the tool being called
902
- tool_args: Arguments being passed to the tool
903
- bottom_items: Optional list of items for bottom separator (e.g., available tools)
904
- highlight_index: Index of item to highlight in the bottom separator (0-based), or None
905
- max_item_length: Optional max length for bottom items (with ellipsis)
906
- name: Optional agent name
907
- metadata: Optional dictionary of metadata about the tool call
908
- """
909
- if not self.config or not self.config.logger.show_tools:
910
- return
911
-
912
- tool_args = tool_args or {}
913
- metadata = metadata or {}
914
- # Build right info and specialised content for known variants
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
966
-
967
- # Display using unified method
968
- self.display_message(
969
- content=content,
970
- message_type=MessageType.TOOL_CALL,
971
- name=name,
972
- pre_content=pre_content,
973
- right_info=right_info,
974
- bottom_metadata=bottom_items,
520
+ self._tool_display.show_tool_call(
521
+ tool_name,
522
+ tool_args,
523
+ bottom_items=bottom_items,
975
524
  highlight_index=highlight_index,
976
525
  max_item_length=max_item_length,
977
- truncate_content=truncate_content,
526
+ name=name,
527
+ metadata=metadata,
978
528
  )
979
529
 
980
530
  async def show_tool_update(self, updated_server: str, agent_name: str | None = None) -> None:
981
- """Show a tool update for a server in the new visual style.
982
-
983
- Args:
984
- updated_server: Name of the server being updated
985
- agent_name: Optional agent name to display
986
- """
987
- if not self.config or not self.config.logger.show_tools:
988
- return
989
-
990
- # Check if prompt_toolkit is active
991
- try:
992
- from prompt_toolkit.application.current import get_app
993
-
994
- app = get_app()
995
- # We're in interactive mode - add to notification tracker
996
- from fast_agent.ui import notification_tracker
997
-
998
- notification_tracker.add_tool_update(updated_server)
999
- app.invalidate() # Force toolbar redraw
1000
-
1001
- except: # noqa: E722
1002
- # No active prompt_toolkit session - display with rich as before
1003
- # Combined separator and status line
1004
- if agent_name:
1005
- left = f"[magenta]▎[/magenta][dim magenta]▶[/dim magenta] [magenta]{agent_name}[/magenta]"
1006
- else:
1007
- left = "[magenta]▎[/magenta][dim magenta]▶[/dim magenta]"
1008
-
1009
- right = f"[dim]{updated_server}[/dim]"
1010
- self._create_combined_separator_status(left, right)
1011
-
1012
- # Display update message
1013
- message = f"Updating tools for server {updated_server}"
1014
- console.console.print(message, style="dim", markup=self._markup)
1015
-
1016
- # Bottom separator
1017
- console.console.print()
1018
- console.console.print("─" * console.console.size.width, style="dim")
1019
- console.console.print()
531
+ await self._tool_display.show_tool_update(updated_server, agent_name=agent_name)
1020
532
 
1021
533
  def _create_combined_separator_status(self, left_content: str, right_info: str = "") -> None:
1022
534
  """
@@ -1061,136 +573,15 @@ class ConsoleDisplay:
1061
573
  @staticmethod
1062
574
  def summarize_skybridge_configs(
1063
575
  configs: Mapping[str, "SkybridgeServerConfig"] | None,
1064
- ) -> Tuple[List[Dict[str, Any]], List[str]]:
1065
- """Convert raw Skybridge configs into display-friendly summary data."""
1066
- server_rows: List[Dict[str, Any]] = []
1067
- warnings: List[str] = []
1068
- warning_seen: Set[str] = set()
1069
-
1070
- if not configs:
1071
- return server_rows, warnings
1072
-
1073
- def add_warning(message: str) -> None:
1074
- formatted = message.strip()
1075
- if not formatted:
1076
- return
1077
- if formatted not in warning_seen:
1078
- warnings.append(formatted)
1079
- warning_seen.add(formatted)
1080
-
1081
- for server_name in sorted(configs.keys()):
1082
- config = configs.get(server_name)
1083
- if not config:
1084
- continue
1085
- resources = list(config.ui_resources or [])
1086
- has_skybridge_signal = bool(
1087
- config.enabled or resources or config.tools or config.warnings
1088
- )
1089
- if not has_skybridge_signal:
1090
- continue
1091
-
1092
- valid_resource_count = sum(1 for resource in resources if resource.is_skybridge)
1093
-
1094
- server_rows.append(
1095
- {
1096
- "server_name": server_name,
1097
- "config": config,
1098
- "resources": resources,
1099
- "valid_resource_count": valid_resource_count,
1100
- "total_resource_count": len(resources),
1101
- "active_tools": [
1102
- {
1103
- "name": tool.display_name,
1104
- "template": str(tool.template_uri) if tool.template_uri else None,
1105
- }
1106
- for tool in config.tools
1107
- if tool.is_valid
1108
- ],
1109
- "enabled": config.enabled,
1110
- }
1111
- )
1112
-
1113
- for warning in config.warnings:
1114
- message = warning.strip()
1115
- if not message:
1116
- continue
1117
- if not message.startswith(server_name):
1118
- message = f"{server_name} {message}"
1119
- add_warning(message)
1120
-
1121
- return server_rows, warnings
576
+ ) -> tuple[list[dict[str, Any]], list[str]]:
577
+ return ToolDisplay.summarize_skybridge_configs(configs)
1122
578
 
1123
579
  def show_skybridge_summary(
1124
580
  self,
1125
581
  agent_name: str,
1126
582
  configs: Mapping[str, "SkybridgeServerConfig"] | None,
1127
583
  ) -> None:
1128
- """Display Skybridge availability and warnings."""
1129
- server_rows, warnings = self.summarize_skybridge_configs(configs)
1130
-
1131
- if not server_rows and not warnings:
1132
- return
1133
-
1134
- heading = "[dim]OpenAI Apps SDK ([/dim][cyan]skybridge[/cyan][dim]) detected:[/dim]"
1135
- console.console.print()
1136
- console.console.print(heading, markup=self._markup)
1137
-
1138
- if not server_rows:
1139
- console.console.print("[dim] ● none detected[/dim]", markup=self._markup)
1140
- else:
1141
- for row in server_rows:
1142
- server_name = row["server_name"]
1143
- resource_count = row["valid_resource_count"]
1144
- total_resource_count = row["total_resource_count"]
1145
- tool_infos = row["active_tools"]
1146
- enabled = row["enabled"]
1147
-
1148
- tool_count = len(tool_infos)
1149
- tool_word = "tool" if tool_count == 1 else "tools"
1150
- resource_word = (
1151
- "skybridge resource" if resource_count == 1 else "skybridge resources"
1152
- )
1153
- tool_segment = f"[cyan]{tool_count}[/cyan][dim] {tool_word}[/dim]"
1154
- resource_segment = f"[cyan]{resource_count}[/cyan][dim] {resource_word}[/dim]"
1155
- name_style = "cyan" if enabled else "yellow"
1156
- status_suffix = "" if enabled else "[dim] (issues detected)[/dim]"
1157
-
1158
- console.console.print(
1159
- f"[dim] ● [/dim][{name_style}]{server_name}[/{name_style}]{status_suffix}"
1160
- f"[dim] — [/dim]{tool_segment}[dim], [/dim]{resource_segment}",
1161
- markup=self._markup,
1162
- )
1163
-
1164
- for tool_info in tool_infos:
1165
- template_text = (
1166
- f"[dim] ({tool_info['template']})[/dim]" if tool_info["template"] else ""
1167
- )
1168
- console.console.print(
1169
- f"[dim] ▶ [/dim][white]{tool_info['name']}[/white]{template_text}",
1170
- markup=self._markup,
1171
- )
1172
-
1173
- if tool_count == 0 and resource_count > 0:
1174
- console.console.print(
1175
- "[dim] ▶ tools not linked[/dim]",
1176
- markup=self._markup,
1177
- )
1178
- if not enabled and total_resource_count > resource_count:
1179
- invalid_count = total_resource_count - resource_count
1180
- invalid_word = "resource" if invalid_count == 1 else "resources"
1181
- console.console.print(
1182
- (
1183
- "[dim] ▶ "
1184
- f"[/dim][cyan]{invalid_count}[/cyan][dim] {invalid_word} detected with non-skybridge MIME type[/dim]"
1185
- ),
1186
- markup=self._markup,
1187
- )
1188
-
1189
- for warning_entry in warnings:
1190
- console.console.print(
1191
- f"[dim red] ▶ [/dim red][red]warning[/red] [dim]{warning_entry}[/dim]",
1192
- markup=self._markup,
1193
- )
584
+ self._tool_display.show_skybridge_summary(agent_name, configs)
1194
585
 
1195
586
  def _extract_reasoning_content(self, message: "PromptMessageExtended") -> Text | None:
1196
587
  """Extract reasoning channel content as dim text."""
@@ -1289,7 +680,7 @@ class ConsoleDisplay:
1289
680
  max_item_length: int | None = None,
1290
681
  name: str | None = None,
1291
682
  model: str | None = None,
1292
- ) -> Iterator["_StreamingMessageHandle"]:
683
+ ) -> Iterator[StreamingHandle]:
1293
684
  """Create a streaming context for assistant messages."""
1294
685
  streaming_enabled, streaming_mode = self.resolve_streaming_preferences()
1295
686
 
@@ -1618,610 +1009,3 @@ class ConsoleDisplay:
1618
1009
  summary_text = " • ".join(summary_parts)
1619
1010
  console.console.print(f"[dim]{summary_text}[/dim]")
1620
1011
  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
- )