fast-agent-mcp 0.3.15__py3-none-any.whl → 0.3.17__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 (47) 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 +7 -0
  4. fast_agent/agents/llm_decorator.py +6 -0
  5. fast_agent/agents/mcp_agent.py +134 -10
  6. fast_agent/cli/__main__.py +35 -0
  7. fast_agent/cli/commands/check_config.py +85 -0
  8. fast_agent/cli/commands/go.py +100 -36
  9. fast_agent/cli/constants.py +15 -1
  10. fast_agent/cli/main.py +2 -1
  11. fast_agent/config.py +39 -10
  12. fast_agent/constants.py +8 -0
  13. fast_agent/context.py +24 -15
  14. fast_agent/core/direct_decorators.py +9 -0
  15. fast_agent/core/fastagent.py +101 -1
  16. fast_agent/core/logging/listeners.py +8 -0
  17. fast_agent/interfaces.py +12 -0
  18. fast_agent/llm/fastagent_llm.py +45 -0
  19. fast_agent/llm/memory.py +26 -1
  20. fast_agent/llm/model_database.py +4 -1
  21. fast_agent/llm/model_factory.py +4 -2
  22. fast_agent/llm/model_info.py +19 -43
  23. fast_agent/llm/provider/anthropic/llm_anthropic.py +112 -0
  24. fast_agent/llm/provider/google/llm_google_native.py +238 -7
  25. fast_agent/llm/provider/openai/llm_openai.py +382 -19
  26. fast_agent/llm/provider/openai/responses.py +133 -0
  27. fast_agent/resources/setup/agent.py +2 -0
  28. fast_agent/resources/setup/fastagent.config.yaml +6 -0
  29. fast_agent/skills/__init__.py +9 -0
  30. fast_agent/skills/registry.py +208 -0
  31. fast_agent/tools/shell_runtime.py +404 -0
  32. fast_agent/ui/console_display.py +47 -996
  33. fast_agent/ui/elicitation_form.py +76 -24
  34. fast_agent/ui/elicitation_style.py +2 -2
  35. fast_agent/ui/enhanced_prompt.py +107 -37
  36. fast_agent/ui/history_display.py +20 -5
  37. fast_agent/ui/interactive_prompt.py +108 -3
  38. fast_agent/ui/markdown_helpers.py +104 -0
  39. fast_agent/ui/markdown_truncator.py +103 -45
  40. fast_agent/ui/message_primitives.py +50 -0
  41. fast_agent/ui/streaming.py +638 -0
  42. fast_agent/ui/tool_display.py +417 -0
  43. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/METADATA +8 -7
  44. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/RECORD +47 -39
  45. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/WHEEL +0 -0
  46. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/entry_points.txt +0 -0
  47. {fast_agent_mcp-0.3.15.dist-info → fast_agent_mcp-0.3.17.dist-info}/licenses/LICENSE +0 -0
@@ -1,19 +1,17 @@
1
- import math
2
1
  from contextlib import contextmanager
3
- from enum import Enum
4
2
  from json import JSONDecodeError
5
- 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
6
4
 
7
5
  from mcp.types import CallToolResult
8
- from rich.live import Live
9
6
  from rich.markdown import Markdown
10
7
  from rich.panel import Panel
11
8
  from rich.text import Text
12
9
 
13
10
  from fast_agent.config import Settings
14
11
  from fast_agent.constants import REASONING
12
+ from fast_agent.core.logging.logger import get_logger
15
13
  from fast_agent.ui import console
16
- from fast_agent.ui.markdown_truncator import MarkdownTruncator
14
+ from fast_agent.ui.markdown_helpers import prepare_markdown_content
17
15
  from fast_agent.ui.mcp_ui_utils import UILink
18
16
  from fast_agent.ui.mermaid_utils import (
19
17
  MermaidDiagram,
@@ -21,197 +19,25 @@ from fast_agent.ui.mermaid_utils import (
21
19
  detect_diagram_type,
22
20
  extract_mermaid_diagrams,
23
21
  )
24
- 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
25
33
 
26
34
  if TYPE_CHECKING:
27
35
  from fast_agent.mcp.prompt_message_extended import PromptMessageExtended
28
36
  from fast_agent.mcp.skybridge import SkybridgeServerConfig
29
37
 
30
- CODE_STYLE = "native"
31
-
32
- MARKDOWN_STREAM_TARGET_RATIO = 0.7
33
- MARKDOWN_STREAM_REFRESH_PER_SECOND = 4
34
- MARKDOWN_STREAM_HEIGHT_FUDGE = 1
35
- PLAIN_STREAM_TARGET_RATIO = 0.9
36
- PLAIN_STREAM_REFRESH_PER_SECOND = 20
37
- PLAIN_STREAM_HEIGHT_FUDGE = 1
38
-
39
-
40
- class MessageType(Enum):
41
- """Types of messages that can be displayed."""
42
-
43
- USER = "user"
44
- ASSISTANT = "assistant"
45
- SYSTEM = "system"
46
- TOOL_CALL = "tool_call"
47
- TOOL_RESULT = "tool_result"
48
-
49
-
50
- # Configuration for each message type
51
- MESSAGE_CONFIGS = {
52
- MessageType.USER: {
53
- "block_color": "blue",
54
- "arrow": "▶",
55
- "arrow_style": "dim blue",
56
- "highlight_color": "blue",
57
- },
58
- MessageType.ASSISTANT: {
59
- "block_color": "green",
60
- "arrow": "◀",
61
- "arrow_style": "dim green",
62
- "highlight_color": "bright_green",
63
- },
64
- MessageType.SYSTEM: {
65
- "block_color": "yellow",
66
- "arrow": "●",
67
- "arrow_style": "dim yellow",
68
- "highlight_color": "bright_yellow",
69
- },
70
- MessageType.TOOL_CALL: {
71
- "block_color": "magenta",
72
- "arrow": "◀",
73
- "arrow_style": "dim magenta",
74
- "highlight_color": "magenta",
75
- },
76
- MessageType.TOOL_RESULT: {
77
- "block_color": "magenta", # Can be overridden to red if error
78
- "arrow": "▶",
79
- "arrow_style": "dim magenta",
80
- "highlight_color": "magenta",
81
- },
82
- }
83
-
84
- HTML_ESCAPE_CHARS = {
85
- "&": "&",
86
- "<": "&lt;",
87
- ">": "&gt;",
88
- '"': "&quot;",
89
- "'": "&#39;",
90
- }
91
-
92
-
93
- def _prepare_markdown_content(content: str, escape_xml: bool = True) -> str:
94
- """Prepare content for markdown rendering by escaping HTML/XML tags
95
- while preserving code blocks and inline code.
96
-
97
- This ensures XML/HTML tags are displayed as visible text rather than
98
- being interpreted as markup by the markdown renderer.
99
-
100
- Uses markdown-it parser to properly identify code regions, avoiding
101
- the issues with regex-based approaches (e.g., backticks inside fenced
102
- code blocks being misidentified as inline code).
103
- """
104
- if not escape_xml or not isinstance(content, str):
105
- return content
106
-
107
- # Import markdown-it for proper parsing
108
- from markdown_it import MarkdownIt
109
-
110
- # Parse the markdown to identify code regions
111
- parser = MarkdownIt()
112
- try:
113
- tokens = parser.parse(content)
114
- except Exception:
115
- # If parsing fails, fall back to escaping everything
116
- # (better safe than corrupting content)
117
- result = content
118
- for char, replacement in HTML_ESCAPE_CHARS.items():
119
- result = result.replace(char, replacement)
120
- return result
121
-
122
- # Collect protected ranges from tokens
123
- protected_ranges = []
124
- lines = content.split("\n")
125
-
126
- def _flatten_tokens(tokens):
127
- """Recursively flatten token tree."""
128
- for token in tokens:
129
- yield token
130
- if token.children:
131
- yield from _flatten_tokens(token.children)
132
-
133
- # Process all tokens to find code blocks and inline code
134
- for token in _flatten_tokens(tokens):
135
- if token.map is not None:
136
- # Block-level tokens with line mapping (fence, code_block)
137
- if token.type in ("fence", "code_block"):
138
- start_line = token.map[0]
139
- end_line = token.map[1]
140
- start_pos = sum(len(line) + 1 for line in lines[:start_line])
141
- end_pos = sum(len(line) + 1 for line in lines[:end_line])
142
- protected_ranges.append((start_pos, end_pos))
143
-
144
- # Inline code tokens don't have map, but have content
145
- if token.type == "code_inline":
146
- # For inline code, we need to find its position in the source
147
- # The token has the content, but we need to search for it
148
- # We'll look for the pattern `content` in the content string
149
- code_content = token.content
150
- if code_content:
151
- # Search for this inline code in the content
152
- # We need to account for the backticks: `content`
153
- pattern = f"`{code_content}`"
154
- start = 0
155
- while True:
156
- pos = content.find(pattern, start)
157
- if pos == -1:
158
- break
159
- # Check if this position is already in a protected range
160
- in_protected = any(s <= pos < e for s, e in protected_ranges)
161
- if not in_protected:
162
- protected_ranges.append((pos, pos + len(pattern)))
163
- start = pos + len(pattern)
164
-
165
- # Check for incomplete code blocks (streaming scenario)
166
- # Count opening vs closing fences
167
- import re
168
-
169
- fence_pattern = r"^```"
170
- fences = list(re.finditer(fence_pattern, content, re.MULTILINE))
171
-
172
- # If we have an odd number of fences, the last one is incomplete
173
- if len(fences) % 2 == 1:
174
- # Protect from the last fence to the end
175
- last_fence_pos = fences[-1].start()
176
- # Only add if not already protected
177
- in_protected = any(s <= last_fence_pos < e for s, e in protected_ranges)
178
- if not in_protected:
179
- protected_ranges.append((last_fence_pos, len(content)))
180
-
181
- # Sort and merge overlapping ranges
182
- protected_ranges.sort(key=lambda x: x[0])
183
-
184
- # Merge overlapping ranges
185
- merged_ranges = []
186
- for start, end in protected_ranges:
187
- if merged_ranges and start <= merged_ranges[-1][1]:
188
- # Overlapping or adjacent - merge
189
- merged_ranges[-1] = (merged_ranges[-1][0], max(end, merged_ranges[-1][1]))
190
- else:
191
- merged_ranges.append((start, end))
192
-
193
- # Build the escaped content
194
- result = []
195
- last_end = 0
196
-
197
- for start, end in merged_ranges:
198
- # Escape everything outside protected ranges
199
- unprotected_text = content[last_end:start]
200
- for char, replacement in HTML_ESCAPE_CHARS.items():
201
- unprotected_text = unprotected_text.replace(char, replacement)
202
- result.append(unprotected_text)
203
-
204
- # Keep protected ranges (code blocks) as-is
205
- result.append(content[start:end])
206
- last_end = end
207
-
208
- # Escape any remaining content after the last protected range
209
- remainder_text = content[last_end:]
210
- for char, replacement in HTML_ESCAPE_CHARS.items():
211
- remainder_text = remainder_text.replace(char, replacement)
212
- result.append(remainder_text)
38
+ logger = get_logger(__name__)
213
39
 
214
- return "".join(result)
40
+ CODE_STYLE = "native"
215
41
 
216
42
 
217
43
  class ConsoleDisplay:
@@ -220,6 +46,8 @@ class ConsoleDisplay:
220
46
  This centralizes the UI display logic used by LLM implementations.
221
47
  """
222
48
 
49
+ CODE_STYLE = CODE_STYLE
50
+
223
51
  def __init__(self, config: Settings | None = None) -> None:
224
52
  """
225
53
  Initialize the console display handler.
@@ -230,6 +58,11 @@ class ConsoleDisplay:
230
58
  self.config = config
231
59
  self._markup = config.logger.enable_markup if config else True
232
60
  self._escape_xml = True
61
+ self._tool_display = ToolDisplay(self)
62
+
63
+ @property
64
+ def code_style(self) -> str:
65
+ return CODE_STYLE
233
66
 
234
67
  def resolve_streaming_preferences(self) -> tuple[bool, str]:
235
68
  """Return whether streaming is enabled plus the active mode."""
@@ -360,7 +193,6 @@ class ConsoleDisplay:
360
193
  import json
361
194
  import re
362
195
 
363
- from rich.markdown import Markdown
364
196
  from rich.pretty import Pretty
365
197
  from rich.syntax import Syntax
366
198
 
@@ -406,7 +238,7 @@ class ConsoleDisplay:
406
238
  # Check for markdown markers before deciding to use markdown rendering
407
239
  if any(marker in content for marker in ["##", "**", "*", "`", "---", "###"]):
408
240
  # Has markdown markers - render as markdown with escaping
409
- prepared_content = _prepare_markdown_content(content, self._escape_xml)
241
+ prepared_content = prepare_markdown_content(content, self._escape_xml)
410
242
  md = Markdown(prepared_content, code_theme=CODE_STYLE)
411
243
  console.console.print(md, markup=self._markup)
412
244
  else:
@@ -426,7 +258,7 @@ class ConsoleDisplay:
426
258
  # Check if it looks like markdown
427
259
  if any(marker in content for marker in ["##", "**", "*", "`", "---", "###"]):
428
260
  # Escape HTML/XML tags while preserving code blocks
429
- prepared_content = _prepare_markdown_content(content, self._escape_xml)
261
+ prepared_content = prepare_markdown_content(content, self._escape_xml)
430
262
  md = Markdown(prepared_content, code_theme=CODE_STYLE)
431
263
  # Markdown handles its own styling, don't apply style
432
264
  console.console.print(md, markup=self._markup)
@@ -454,10 +286,6 @@ class ConsoleDisplay:
454
286
  # We need to handle the main content (which may have markdown)
455
287
  # and any styled segments that were appended
456
288
 
457
- # For now, we'll render the entire content with markdown support
458
- # This means extracting each span and handling it appropriately
459
- from rich.markdown import Markdown
460
-
461
289
  # If the Text object has multiple spans with different styles,
462
290
  # we need to be careful about how we render them
463
291
  if len(content._spans) > 1:
@@ -474,9 +302,7 @@ class ConsoleDisplay:
474
302
  marker in markdown_part for marker in ["##", "**", "*", "`", "---", "###"]
475
303
  ):
476
304
  # Render markdown part
477
- prepared_content = _prepare_markdown_content(
478
- markdown_part, self._escape_xml
479
- )
305
+ prepared_content = prepare_markdown_content(markdown_part, self._escape_xml)
480
306
  md = Markdown(prepared_content, code_theme=CODE_STYLE)
481
307
  console.console.print(md, markup=self._markup)
482
308
 
@@ -494,7 +320,7 @@ class ConsoleDisplay:
494
320
  console.console.print(content, markup=self._markup)
495
321
  else:
496
322
  # Simple case: entire text should be rendered as markdown
497
- prepared_content = _prepare_markdown_content(plain_text, self._escape_xml)
323
+ prepared_content = prepare_markdown_content(plain_text, self._escape_xml)
498
324
  md = Markdown(prepared_content, code_theme=CODE_STYLE)
499
325
  console.console.print(md, markup=self._markup)
500
326
  else:
@@ -674,289 +500,35 @@ class ConsoleDisplay:
674
500
  tool_name: str | None = None,
675
501
  skybridge_config: "SkybridgeServerConfig | None" = None,
676
502
  ) -> None:
677
- """Display a tool result in the new visual style.
678
-
679
- Args:
680
- result: The tool result to display
681
- name: Optional agent name
682
- tool_name: Optional tool name for skybridge detection
683
- skybridge_config: Optional skybridge configuration for the server
684
- """
685
- if not self.config or not self.config.logger.show_tools:
686
- return
687
-
688
- # Import content helpers
689
- from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content
690
-
691
- # Analyze content to determine display format and status
692
- content = result.content
693
- structured_content = getattr(result, "structuredContent", None)
694
- has_structured = structured_content is not None
695
-
696
- # Determine if this is a skybridge tool
697
- is_skybridge_tool = False
698
- skybridge_resource_uri = None
699
- if has_structured and tool_name and skybridge_config:
700
- # Check if this tool is a valid skybridge tool
701
- for tool_cfg in skybridge_config.tools:
702
- if tool_cfg.tool_name == tool_name and tool_cfg.is_valid:
703
- is_skybridge_tool = True
704
- skybridge_resource_uri = tool_cfg.resource_uri
705
- break
706
-
707
- if result.isError:
708
- status = "ERROR"
709
- else:
710
- # Check if it's a list with content blocks
711
- if len(content) == 0:
712
- status = "No Content"
713
- elif len(content) == 1 and is_text_content(content[0]):
714
- text_content = get_text(content[0])
715
- char_count = len(text_content) if text_content else 0
716
- status = f"Text Only {char_count} chars"
717
- else:
718
- text_count = sum(1 for item in content if is_text_content(item))
719
- if text_count == len(content):
720
- status = f"{len(content)} Text Blocks" if len(content) > 1 else "1 Text Block"
721
- else:
722
- status = (
723
- f"{len(content)} Content Blocks" if len(content) > 1 else "1 Content Block"
724
- )
725
-
726
- # Build transport channel info for bottom bar
727
- channel = getattr(result, "transport_channel", None)
728
- bottom_metadata_items: List[str] = []
729
- if channel:
730
- # Format channel info for bottom bar
731
- if channel == "post-json":
732
- transport_info = "HTTP (JSON-RPC)"
733
- elif channel == "post-sse":
734
- transport_info = "HTTP (SSE)"
735
- elif channel == "get":
736
- transport_info = "Legacy SSE"
737
- elif channel == "resumption":
738
- transport_info = "Resumption"
739
- elif channel == "stdio":
740
- transport_info = "STDIO"
741
- else:
742
- transport_info = channel.upper()
743
-
744
- bottom_metadata_items.append(transport_info)
745
-
746
- elapsed = getattr(result, "transport_elapsed", None)
747
- if isinstance(elapsed, (int, float)):
748
- bottom_metadata_items.append(self._format_elapsed(float(elapsed)))
749
-
750
- # Add structured content indicator if present
751
- if has_structured:
752
- bottom_metadata_items.append("Structured ■")
753
-
754
- bottom_metadata = bottom_metadata_items or None
755
-
756
- # Build right info (without channel info)
757
- right_info = f"[dim]tool result - {status}[/dim]"
758
-
759
- if has_structured:
760
- # Handle structured content display manually to insert it before bottom separator
761
- # Display main content without bottom separator
762
- config = MESSAGE_CONFIGS[MessageType.TOOL_RESULT]
763
- block_color = "red" if result.isError else config["block_color"]
764
- arrow = config["arrow"]
765
- arrow_style = config["arrow_style"]
766
- left = f"[{block_color}]▎[/{block_color}][{arrow_style}]{arrow}[/{arrow_style}]"
767
- if name:
768
- left += f" [{block_color if not result.isError else 'red'}]{name}[/{block_color if not result.isError else 'red'}]"
769
-
770
- # Top separator
771
- self._create_combined_separator_status(left, right_info)
772
-
773
- # Main content
774
- self._display_content(
775
- content, True, result.isError, MessageType.TOOL_RESULT, check_markdown_markers=False
776
- )
777
-
778
- # Structured content separator and display
779
- console.console.print()
780
- total_width = console.console.size.width
781
-
782
- if is_skybridge_tool:
783
- # Skybridge: magenta separator with resource URI
784
- resource_label = (
785
- f"skybridge resource: {skybridge_resource_uri}"
786
- if skybridge_resource_uri
787
- else "skybridge resource"
788
- )
789
- prefix = Text("─| ")
790
- prefix.stylize("dim")
791
- resource_text = Text(resource_label, style="magenta")
792
- suffix = Text(" |")
793
- suffix.stylize("dim")
794
-
795
- separator_line = Text()
796
- separator_line.append_text(prefix)
797
- separator_line.append_text(resource_text)
798
- separator_line.append_text(suffix)
799
- remaining = total_width - separator_line.cell_len
800
- if remaining > 0:
801
- separator_line.append("─" * remaining, style="dim")
802
- console.console.print(separator_line, markup=self._markup)
803
- console.console.print()
804
-
805
- # Display with bright syntax highlighting
806
- import json
807
-
808
- from rich.syntax import Syntax
809
-
810
- json_str = json.dumps(structured_content, indent=2)
811
- syntax_obj = Syntax(json_str, "json", theme=CODE_STYLE, background_color="default")
812
- console.console.print(syntax_obj, markup=self._markup)
813
- else:
814
- # Regular tool: dim separator
815
- prefix = Text("─| ")
816
- prefix.stylize("dim")
817
- label_text = Text("Structured Content", style="dim")
818
- suffix = Text(" |")
819
- suffix.stylize("dim")
820
-
821
- separator_line = Text()
822
- separator_line.append_text(prefix)
823
- separator_line.append_text(label_text)
824
- separator_line.append_text(suffix)
825
- remaining = total_width - separator_line.cell_len
826
- if remaining > 0:
827
- separator_line.append("─" * remaining, style="dim")
828
- console.console.print(separator_line, markup=self._markup)
829
- console.console.print()
830
-
831
- # Display truncated content in dim
832
- from rich.pretty import Pretty
833
-
834
- if self.config and self.config.logger.truncate_tools:
835
- pretty_obj = Pretty(structured_content, max_length=10, max_string=50)
836
- else:
837
- pretty_obj = Pretty(structured_content)
838
- console.console.print(pretty_obj, style="dim", markup=self._markup)
839
-
840
- # Bottom separator with metadata
841
- console.console.print()
842
- if bottom_metadata:
843
- display_items = (
844
- self._shorten_items(bottom_metadata, 12) if True else bottom_metadata
845
- )
846
- prefix = Text("─| ")
847
- prefix.stylize("dim")
848
- suffix = Text(" |")
849
- suffix.stylize("dim")
850
- available = max(0, total_width - prefix.cell_len - suffix.cell_len)
851
-
852
- metadata_text = self._format_bottom_metadata(
853
- display_items,
854
- None,
855
- config["highlight_color"],
856
- max_width=available,
857
- )
858
-
859
- line = Text()
860
- line.append_text(prefix)
861
- line.append_text(metadata_text)
862
- line.append_text(suffix)
863
- remaining = total_width - line.cell_len
864
- if remaining > 0:
865
- line.append("─" * remaining, style="dim")
866
- console.console.print(line, markup=self._markup)
867
- else:
868
- console.console.print("─" * total_width, style="dim")
869
- console.console.print()
870
-
871
- else:
872
- # No structured content - use standard display
873
- self.display_message(
874
- content=content,
875
- message_type=MessageType.TOOL_RESULT,
876
- name=name,
877
- right_info=right_info,
878
- bottom_metadata=bottom_metadata,
879
- is_error=result.isError,
880
- truncate_content=True,
881
- )
503
+ self._tool_display.show_tool_result(
504
+ result,
505
+ name=name,
506
+ tool_name=tool_name,
507
+ skybridge_config=skybridge_config,
508
+ )
882
509
 
883
510
  def show_tool_call(
884
511
  self,
885
512
  tool_name: str,
886
- tool_args: Dict[str, Any] | None,
887
- bottom_items: List[str] | None = None,
513
+ tool_args: dict[str, Any] | None,
514
+ bottom_items: list[str] | None = None,
888
515
  highlight_index: int | None = None,
889
516
  max_item_length: int | None = None,
890
517
  name: str | None = None,
518
+ metadata: dict[str, Any] | None = None,
891
519
  ) -> None:
892
- """Display a tool call in the new visual style.
893
-
894
- Args:
895
- tool_name: Name of the tool being called
896
- tool_args: Arguments being passed to the tool
897
- bottom_items: Optional list of items for bottom separator (e.g., available tools)
898
- highlight_index: Index of item to highlight in the bottom separator (0-based), or None
899
- max_item_length: Optional max length for bottom items (with ellipsis)
900
- name: Optional agent name
901
- """
902
- if not self.config or not self.config.logger.show_tools:
903
- return
904
-
905
- # Build right info
906
- right_info = f"[dim]tool request - {tool_name}[/dim]"
907
-
908
- # Display using unified method
909
- self.display_message(
910
- content=tool_args,
911
- message_type=MessageType.TOOL_CALL,
912
- name=name,
913
- right_info=right_info,
914
- bottom_metadata=bottom_items,
520
+ self._tool_display.show_tool_call(
521
+ tool_name,
522
+ tool_args,
523
+ bottom_items=bottom_items,
915
524
  highlight_index=highlight_index,
916
525
  max_item_length=max_item_length,
917
- truncate_content=True,
526
+ name=name,
527
+ metadata=metadata,
918
528
  )
919
529
 
920
530
  async def show_tool_update(self, updated_server: str, agent_name: str | None = None) -> None:
921
- """Show a tool update for a server in the new visual style.
922
-
923
- Args:
924
- updated_server: Name of the server being updated
925
- agent_name: Optional agent name to display
926
- """
927
- if not self.config or not self.config.logger.show_tools:
928
- return
929
-
930
- # Check if prompt_toolkit is active
931
- try:
932
- from prompt_toolkit.application.current import get_app
933
-
934
- app = get_app()
935
- # We're in interactive mode - add to notification tracker
936
- from fast_agent.ui import notification_tracker
937
-
938
- notification_tracker.add_tool_update(updated_server)
939
- app.invalidate() # Force toolbar redraw
940
-
941
- except: # noqa: E722
942
- # No active prompt_toolkit session - display with rich as before
943
- # Combined separator and status line
944
- if agent_name:
945
- left = f"[magenta]▎[/magenta][dim magenta]▶[/dim magenta] [magenta]{agent_name}[/magenta]"
946
- else:
947
- left = "[magenta]▎[/magenta][dim magenta]▶[/dim magenta]"
948
-
949
- right = f"[dim]{updated_server}[/dim]"
950
- self._create_combined_separator_status(left, right)
951
-
952
- # Display update message
953
- message = f"Updating tools for server {updated_server}"
954
- console.console.print(message, style="dim", markup=self._markup)
955
-
956
- # Bottom separator
957
- console.console.print()
958
- console.console.print("─" * console.console.size.width, style="dim")
959
- console.console.print()
531
+ await self._tool_display.show_tool_update(updated_server, agent_name=agent_name)
960
532
 
961
533
  def _create_combined_separator_status(self, left_content: str, right_info: str = "") -> None:
962
534
  """
@@ -1001,136 +573,15 @@ class ConsoleDisplay:
1001
573
  @staticmethod
1002
574
  def summarize_skybridge_configs(
1003
575
  configs: Mapping[str, "SkybridgeServerConfig"] | None,
1004
- ) -> Tuple[List[Dict[str, Any]], List[str]]:
1005
- """Convert raw Skybridge configs into display-friendly summary data."""
1006
- server_rows: List[Dict[str, Any]] = []
1007
- warnings: List[str] = []
1008
- warning_seen: Set[str] = set()
1009
-
1010
- if not configs:
1011
- return server_rows, warnings
1012
-
1013
- def add_warning(message: str) -> None:
1014
- formatted = message.strip()
1015
- if not formatted:
1016
- return
1017
- if formatted not in warning_seen:
1018
- warnings.append(formatted)
1019
- warning_seen.add(formatted)
1020
-
1021
- for server_name in sorted(configs.keys()):
1022
- config = configs.get(server_name)
1023
- if not config:
1024
- continue
1025
- resources = list(config.ui_resources or [])
1026
- has_skybridge_signal = bool(
1027
- config.enabled or resources or config.tools or config.warnings
1028
- )
1029
- if not has_skybridge_signal:
1030
- continue
1031
-
1032
- valid_resource_count = sum(1 for resource in resources if resource.is_skybridge)
1033
-
1034
- server_rows.append(
1035
- {
1036
- "server_name": server_name,
1037
- "config": config,
1038
- "resources": resources,
1039
- "valid_resource_count": valid_resource_count,
1040
- "total_resource_count": len(resources),
1041
- "active_tools": [
1042
- {
1043
- "name": tool.display_name,
1044
- "template": str(tool.template_uri) if tool.template_uri else None,
1045
- }
1046
- for tool in config.tools
1047
- if tool.is_valid
1048
- ],
1049
- "enabled": config.enabled,
1050
- }
1051
- )
1052
-
1053
- for warning in config.warnings:
1054
- message = warning.strip()
1055
- if not message:
1056
- continue
1057
- if not message.startswith(server_name):
1058
- message = f"{server_name} {message}"
1059
- add_warning(message)
1060
-
1061
- return server_rows, warnings
576
+ ) -> tuple[list[dict[str, Any]], list[str]]:
577
+ return ToolDisplay.summarize_skybridge_configs(configs)
1062
578
 
1063
579
  def show_skybridge_summary(
1064
580
  self,
1065
581
  agent_name: str,
1066
582
  configs: Mapping[str, "SkybridgeServerConfig"] | None,
1067
583
  ) -> None:
1068
- """Display Skybridge availability and warnings."""
1069
- server_rows, warnings = self.summarize_skybridge_configs(configs)
1070
-
1071
- if not server_rows and not warnings:
1072
- return
1073
-
1074
- heading = "[dim]OpenAI Apps SDK ([/dim][cyan]skybridge[/cyan][dim]) detected:[/dim]"
1075
- console.console.print()
1076
- console.console.print(heading, markup=self._markup)
1077
-
1078
- if not server_rows:
1079
- console.console.print("[dim] ● none detected[/dim]", markup=self._markup)
1080
- else:
1081
- for row in server_rows:
1082
- server_name = row["server_name"]
1083
- resource_count = row["valid_resource_count"]
1084
- total_resource_count = row["total_resource_count"]
1085
- tool_infos = row["active_tools"]
1086
- enabled = row["enabled"]
1087
-
1088
- tool_count = len(tool_infos)
1089
- tool_word = "tool" if tool_count == 1 else "tools"
1090
- resource_word = (
1091
- "skybridge resource" if resource_count == 1 else "skybridge resources"
1092
- )
1093
- tool_segment = f"[cyan]{tool_count}[/cyan][dim] {tool_word}[/dim]"
1094
- resource_segment = f"[cyan]{resource_count}[/cyan][dim] {resource_word}[/dim]"
1095
- name_style = "cyan" if enabled else "yellow"
1096
- status_suffix = "" if enabled else "[dim] (issues detected)[/dim]"
1097
-
1098
- console.console.print(
1099
- f"[dim] ● [/dim][{name_style}]{server_name}[/{name_style}]{status_suffix}"
1100
- f"[dim] — [/dim]{tool_segment}[dim], [/dim]{resource_segment}",
1101
- markup=self._markup,
1102
- )
1103
-
1104
- for tool_info in tool_infos:
1105
- template_text = (
1106
- f"[dim] ({tool_info['template']})[/dim]" if tool_info["template"] else ""
1107
- )
1108
- console.console.print(
1109
- f"[dim] ▶ [/dim][white]{tool_info['name']}[/white]{template_text}",
1110
- markup=self._markup,
1111
- )
1112
-
1113
- if tool_count == 0 and resource_count > 0:
1114
- console.console.print(
1115
- "[dim] ▶ tools not linked[/dim]",
1116
- markup=self._markup,
1117
- )
1118
- if not enabled and total_resource_count > resource_count:
1119
- invalid_count = total_resource_count - resource_count
1120
- invalid_word = "resource" if invalid_count == 1 else "resources"
1121
- console.console.print(
1122
- (
1123
- "[dim] ▶ "
1124
- f"[/dim][cyan]{invalid_count}[/cyan][dim] {invalid_word} detected with non-skybridge MIME type[/dim]"
1125
- ),
1126
- markup=self._markup,
1127
- )
1128
-
1129
- for warning_entry in warnings:
1130
- console.console.print(
1131
- f"[dim red] ▶ [/dim red][red]warning[/red] [dim]{warning_entry}[/dim]",
1132
- markup=self._markup,
1133
- )
584
+ self._tool_display.show_skybridge_summary(agent_name, configs)
1134
585
 
1135
586
  def _extract_reasoning_content(self, message: "PromptMessageExtended") -> Text | None:
1136
587
  """Extract reasoning channel content as dim text."""
@@ -1229,7 +680,7 @@ class ConsoleDisplay:
1229
680
  max_item_length: int | None = None,
1230
681
  name: str | None = None,
1231
682
  model: str | None = None,
1232
- ) -> Iterator["_StreamingMessageHandle"]:
683
+ ) -> Iterator[StreamingHandle]:
1233
684
  """Create a streaming context for assistant messages."""
1234
685
  streaming_enabled, streaming_mode = self.resolve_streaming_preferences()
1235
686
 
@@ -1558,403 +1009,3 @@ class ConsoleDisplay:
1558
1009
  summary_text = " • ".join(summary_parts)
1559
1010
  console.console.print(f"[dim]{summary_text}[/dim]")
1560
1011
  console.console.print()
1561
-
1562
-
1563
- class _NullStreamingHandle:
1564
- """No-op streaming handle used when streaming is disabled."""
1565
-
1566
- def update(self, _chunk: str) -> None:
1567
- return
1568
-
1569
- def finalize(self, _message: "PromptMessageExtended | str") -> None:
1570
- return
1571
-
1572
- def close(self) -> None:
1573
- return
1574
-
1575
-
1576
- class _StreamingMessageHandle:
1577
- """Helper that manages live rendering for streaming assistant responses."""
1578
-
1579
- def __init__(
1580
- self,
1581
- *,
1582
- display: ConsoleDisplay,
1583
- bottom_items: List[str] | None,
1584
- highlight_index: int | None,
1585
- max_item_length: int | None,
1586
- use_plain_text: bool = False,
1587
- header_left: str = "",
1588
- header_right: str = "",
1589
- progress_display=None,
1590
- ) -> None:
1591
- self._display = display
1592
- self._bottom_items = bottom_items
1593
- self._highlight_index = highlight_index
1594
- self._max_item_length = max_item_length
1595
- self._use_plain_text = use_plain_text
1596
- self._header_left = header_left
1597
- self._header_right = header_right
1598
- self._progress_display = progress_display
1599
- self._progress_paused = False
1600
- self._buffer: List[str] = []
1601
- initial_renderable = Text("") if self._use_plain_text else Markdown("")
1602
- refresh_rate = (
1603
- PLAIN_STREAM_REFRESH_PER_SECOND
1604
- if self._use_plain_text
1605
- else MARKDOWN_STREAM_REFRESH_PER_SECOND
1606
- )
1607
- self._live: Live | None = Live(
1608
- initial_renderable,
1609
- console=console.console,
1610
- vertical_overflow="ellipsis",
1611
- refresh_per_second=refresh_rate,
1612
- transient=True,
1613
- )
1614
- self._live_started = False
1615
- self._active = True
1616
- self._finalized = False
1617
- # Track whether we're in a table to batch updates
1618
- self._in_table = False
1619
- self._pending_table_row = ""
1620
- # Smart markdown truncator for creating display window (doesn't mutate buffer)
1621
- self._truncator = MarkdownTruncator(target_height_ratio=MARKDOWN_STREAM_TARGET_RATIO)
1622
- self._plain_truncator = (
1623
- PlainTextTruncator(target_height_ratio=PLAIN_STREAM_TARGET_RATIO)
1624
- if self._use_plain_text
1625
- else None
1626
- )
1627
- self._max_render_height = 0
1628
-
1629
- def update(self, chunk: str) -> None:
1630
- if not self._active or not chunk:
1631
- return
1632
-
1633
- self._ensure_started()
1634
-
1635
- if self._use_plain_text:
1636
- chunk = self._wrap_plain_chunk(chunk)
1637
- if self._pending_table_row:
1638
- self._buffer.append(self._pending_table_row)
1639
- self._pending_table_row = ""
1640
- else:
1641
- # Detect if we're streaming table content
1642
- # Tables have rows starting with '|' and we want to batch updates until we get a complete row
1643
- text_so_far = "".join(self._buffer)
1644
-
1645
- # Check if we're currently in a table (last non-empty line starts with |)
1646
- lines = text_so_far.strip().split("\n")
1647
- last_line = lines[-1] if lines else ""
1648
- currently_in_table = last_line.strip().startswith("|")
1649
-
1650
- # If we're in a table and the chunk doesn't contain a newline, accumulate it
1651
- if currently_in_table and "\n" not in chunk:
1652
- self._pending_table_row += chunk
1653
- # Don't update display yet - wait for complete row
1654
- return
1655
-
1656
- # If we have a pending table row, flush it now
1657
- if self._pending_table_row:
1658
- self._buffer.append(self._pending_table_row)
1659
- self._pending_table_row = ""
1660
-
1661
- self._buffer.append(chunk)
1662
-
1663
- text = "".join(self._buffer)
1664
-
1665
- if self._use_plain_text:
1666
- trimmed = self._trim_to_displayable(text)
1667
- if trimmed != text:
1668
- text = trimmed
1669
- self._buffer = [trimmed]
1670
-
1671
- # Guard against single logical paragraphs that would expand far wider than expected.
1672
- trailing_paragraph = self._extract_trailing_paragraph(text)
1673
- if trailing_paragraph and "\n" not in trailing_paragraph:
1674
- width = max(1, console.console.size.width)
1675
- target_ratio = (
1676
- PLAIN_STREAM_TARGET_RATIO if self._use_plain_text else MARKDOWN_STREAM_TARGET_RATIO
1677
- )
1678
- target_rows = max(
1679
- 1,
1680
- int(console.console.size.height * target_ratio) - 1,
1681
- )
1682
- estimated_rows = math.ceil(len(trailing_paragraph.expandtabs()) / width)
1683
- if estimated_rows > target_rows:
1684
- trimmed_text = self._trim_to_displayable(text)
1685
- if trimmed_text != text:
1686
- text = trimmed_text
1687
- self._buffer = [trimmed_text]
1688
-
1689
- # Trim buffer periodically to avoid unbounded growth
1690
- # Keep only what can fit in ~1.5x terminal height
1691
- if len(self._buffer) > 10:
1692
- text = self._trim_to_displayable(text)
1693
- self._buffer = [text]
1694
-
1695
- if self._live:
1696
- # Build the header bar
1697
- header = self._build_header()
1698
-
1699
- # Build the content renderable
1700
- max_allowed_height = max(1, console.console.size.height - 2)
1701
- self._max_render_height = min(self._max_render_height, max_allowed_height)
1702
-
1703
- if self._use_plain_text:
1704
- # Plain text rendering - no markdown processing
1705
- content_height = self._estimate_plain_render_height(text)
1706
- budget_height = min(content_height + PLAIN_STREAM_HEIGHT_FUDGE, max_allowed_height)
1707
-
1708
- if budget_height > self._max_render_height:
1709
- self._max_render_height = budget_height
1710
-
1711
- padding_lines = max(0, self._max_render_height - content_height)
1712
- display_text = text + ("\n" * padding_lines if padding_lines else "")
1713
- content = Text(display_text)
1714
- else:
1715
- # Markdown rendering with XML escaping
1716
- prepared = _prepare_markdown_content(text, self._display._escape_xml)
1717
- prepared_for_display = self._close_incomplete_code_blocks(prepared)
1718
-
1719
- content_height = self._truncator.measure_rendered_height(
1720
- prepared_for_display, console.console, CODE_STYLE
1721
- )
1722
- budget_height = min(
1723
- content_height + MARKDOWN_STREAM_HEIGHT_FUDGE, max_allowed_height
1724
- )
1725
-
1726
- if budget_height > self._max_render_height:
1727
- self._max_render_height = budget_height
1728
-
1729
- padding_lines = max(0, self._max_render_height - content_height)
1730
- if padding_lines:
1731
- prepared_for_display = prepared_for_display + ("\n" * padding_lines)
1732
-
1733
- content = Markdown(prepared_for_display, code_theme=CODE_STYLE)
1734
-
1735
- # Combine header and content using Group
1736
- from rich.console import Group
1737
-
1738
- header_with_spacing = header.copy()
1739
- header_with_spacing.append("\n", style="default")
1740
-
1741
- combined = Group(header_with_spacing, content)
1742
- self._live.update(combined)
1743
-
1744
- def _build_header(self) -> Text:
1745
- """Build the header bar as a Text renderable.
1746
-
1747
- Returns:
1748
- Text object representing the header bar.
1749
- """
1750
- width = console.console.size.width
1751
-
1752
- # Create left text
1753
- left_text = Text.from_markup(self._header_left)
1754
-
1755
- # Create right text if we have info
1756
- if self._header_right and self._header_right.strip():
1757
- # Add dim brackets around the right info
1758
- right_text = Text()
1759
- right_text.append("[", style="dim")
1760
- right_text.append_text(Text.from_markup(self._header_right))
1761
- right_text.append("]", style="dim")
1762
- # Calculate separator count
1763
- separator_count = width - left_text.cell_len - right_text.cell_len
1764
- if separator_count < 1:
1765
- separator_count = 1 # Always at least 1 separator
1766
- else:
1767
- right_text = Text("")
1768
- separator_count = width - left_text.cell_len
1769
-
1770
- # Build the combined line
1771
- combined = Text()
1772
- combined.append_text(left_text)
1773
- combined.append(" ", style="default")
1774
- combined.append("─" * (separator_count - 1), style="dim")
1775
- combined.append_text(right_text)
1776
-
1777
- return combined
1778
-
1779
- def _ensure_started(self) -> None:
1780
- """Start live rendering and pause progress display if needed."""
1781
- if self._live_started:
1782
- return
1783
-
1784
- if self._progress_display and not self._progress_paused:
1785
- try:
1786
- self._progress_display.pause()
1787
- self._progress_paused = True
1788
- except Exception:
1789
- self._progress_paused = False
1790
-
1791
- if self._live and not self._live_started:
1792
- self._live.__enter__()
1793
- self._live_started = True
1794
-
1795
- def _close_incomplete_code_blocks(self, text: str) -> str:
1796
- """Add temporary closing fence to incomplete code blocks for display.
1797
-
1798
- During streaming, incomplete code blocks (opening fence without closing)
1799
- are rendered as literal text by Rich's Markdown renderer. This method
1800
- adds a temporary closing fence so the code can be syntax-highlighted
1801
- during streaming display.
1802
-
1803
- When the real closing fence arrives in a subsequent chunk, this method
1804
- will detect the now-complete block and stop adding the temporary fence.
1805
-
1806
- Args:
1807
- text: The markdown text that may contain incomplete code blocks.
1808
-
1809
- Returns:
1810
- Text with temporary closing fences added for incomplete code blocks.
1811
- """
1812
- import re
1813
-
1814
- # Count opening and closing fences
1815
- opening_fences = len(re.findall(r"^```", text, re.MULTILINE))
1816
- closing_fences = len(re.findall(r"^```\s*$", text, re.MULTILINE))
1817
-
1818
- # If we have more opening fences than closing fences, and the text
1819
- # doesn't end with a closing fence, we have an incomplete code block
1820
- if opening_fences > closing_fences:
1821
- # Check if text ends with a closing fence (might be partial line)
1822
- if not re.search(r"```\s*$", text):
1823
- # Add temporary closing fence for display only
1824
- return text + "\n```\n"
1825
-
1826
- return text
1827
-
1828
- def _trim_to_displayable(self, text: str) -> str:
1829
- """Trim text to keep only displayable content plus small buffer.
1830
-
1831
- Keeps ~1.5x terminal height worth of recent content.
1832
- Uses the optimized streaming truncator for better performance.
1833
-
1834
- Args:
1835
- text: Full text to trim
1836
-
1837
- Returns:
1838
- Trimmed text (most recent content)
1839
- """
1840
- if not text:
1841
- return text
1842
-
1843
- terminal_height = console.console.size.height - 1
1844
-
1845
- if self._use_plain_text and self._plain_truncator:
1846
- terminal_width = console.console.size.width
1847
- return self._plain_truncator.truncate(
1848
- text,
1849
- terminal_height=terminal_height,
1850
- terminal_width=terminal_width,
1851
- )
1852
-
1853
- # Use the optimized streaming truncator (16x faster!) for markdown
1854
- return self._truncator.truncate(
1855
- text,
1856
- terminal_height=terminal_height,
1857
- console=console.console,
1858
- code_theme=CODE_STYLE,
1859
- prefer_recent=True, # Streaming mode
1860
- )
1861
-
1862
- def finalize(self, _message: "PromptMessageExtended | str") -> None:
1863
- if not self._active or self._finalized:
1864
- return
1865
-
1866
- self._finalized = True
1867
- self.close()
1868
-
1869
- def close(self) -> None:
1870
- if self._live and self._live_started:
1871
- self._live.__exit__(None, None, None)
1872
- self._live = None
1873
- self._live_started = False
1874
- if self._progress_display and self._progress_paused:
1875
- try:
1876
- self._progress_display.resume()
1877
- except Exception:
1878
- pass
1879
- finally:
1880
- self._progress_paused = False
1881
- self._active = False
1882
- self._max_render_height = 0
1883
-
1884
- def _extract_trailing_paragraph(self, text: str) -> str:
1885
- """Return text since the last blank line, used to detect in-progress paragraphs."""
1886
- if not text:
1887
- return ""
1888
- double_break = text.rfind("\n\n")
1889
- if double_break != -1:
1890
- candidate = text[double_break + 2 :]
1891
- else:
1892
- candidate = text
1893
- if "\n" in candidate:
1894
- candidate = candidate.split("\n")[-1]
1895
- return candidate
1896
-
1897
- def _wrap_plain_chunk(self, chunk: str) -> str:
1898
- """Insert soft line breaks into long plain text segments."""
1899
- width = max(1, console.console.size.width)
1900
- if not chunk or width <= 1:
1901
- return chunk
1902
-
1903
- result_segments: List[str] = []
1904
- start = 0
1905
- length = len(chunk)
1906
-
1907
- while start < length:
1908
- newline_pos = chunk.find("\n", start)
1909
- if newline_pos == -1:
1910
- line = chunk[start:]
1911
- delimiter = ""
1912
- start = length
1913
- else:
1914
- line = chunk[start:newline_pos]
1915
- delimiter = "\n"
1916
- start = newline_pos + 1
1917
-
1918
- if len(line.expandtabs()) > width:
1919
- wrapped = self._wrap_plain_line(line, width)
1920
- result_segments.append("\n".join(wrapped))
1921
- else:
1922
- result_segments.append(line)
1923
-
1924
- result_segments.append(delimiter)
1925
-
1926
- return "".join(result_segments)
1927
-
1928
- @staticmethod
1929
- def _wrap_plain_line(line: str, width: int) -> List[str]:
1930
- """Wrap a single line to the terminal width."""
1931
- if not line:
1932
- return [""]
1933
-
1934
- segments: List[str] = []
1935
- remaining = line
1936
-
1937
- while len(remaining) > width:
1938
- break_at = remaining.rfind(" ", 0, width)
1939
- if break_at == -1 or break_at < width // 2:
1940
- break_at = width
1941
- segments.append(remaining[:break_at])
1942
- remaining = remaining[break_at:]
1943
- else:
1944
- segments.append(remaining[:break_at])
1945
- remaining = remaining[break_at + 1 :]
1946
- segments.append(remaining)
1947
- return segments
1948
-
1949
- def _estimate_plain_render_height(self, text: str) -> int:
1950
- """Estimate rendered height for plain text taking terminal width into account."""
1951
- if not text:
1952
- return 0
1953
-
1954
- width = max(1, console.console.size.width)
1955
- lines = text.split("\n")
1956
- total = 0
1957
- for line in lines:
1958
- expanded_len = len(line.expandtabs())
1959
- total += max(1, math.ceil(expanded_len / width)) if expanded_len else 1
1960
- return total