fast-agent-mcp 0.4.7__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.
- fast_agent/__init__.py +183 -0
- fast_agent/acp/__init__.py +19 -0
- fast_agent/acp/acp_aware_mixin.py +304 -0
- fast_agent/acp/acp_context.py +437 -0
- fast_agent/acp/content_conversion.py +136 -0
- fast_agent/acp/filesystem_runtime.py +427 -0
- fast_agent/acp/permission_store.py +269 -0
- fast_agent/acp/server/__init__.py +5 -0
- fast_agent/acp/server/agent_acp_server.py +1472 -0
- fast_agent/acp/slash_commands.py +1050 -0
- fast_agent/acp/terminal_runtime.py +408 -0
- fast_agent/acp/tool_permission_adapter.py +125 -0
- fast_agent/acp/tool_permissions.py +474 -0
- fast_agent/acp/tool_progress.py +814 -0
- fast_agent/agents/__init__.py +85 -0
- fast_agent/agents/agent_types.py +64 -0
- fast_agent/agents/llm_agent.py +350 -0
- fast_agent/agents/llm_decorator.py +1139 -0
- fast_agent/agents/mcp_agent.py +1337 -0
- fast_agent/agents/tool_agent.py +271 -0
- fast_agent/agents/workflow/agents_as_tools_agent.py +849 -0
- fast_agent/agents/workflow/chain_agent.py +212 -0
- fast_agent/agents/workflow/evaluator_optimizer.py +380 -0
- fast_agent/agents/workflow/iterative_planner.py +652 -0
- fast_agent/agents/workflow/maker_agent.py +379 -0
- fast_agent/agents/workflow/orchestrator_models.py +218 -0
- fast_agent/agents/workflow/orchestrator_prompts.py +248 -0
- fast_agent/agents/workflow/parallel_agent.py +250 -0
- fast_agent/agents/workflow/router_agent.py +353 -0
- fast_agent/cli/__init__.py +0 -0
- fast_agent/cli/__main__.py +73 -0
- fast_agent/cli/commands/acp.py +159 -0
- fast_agent/cli/commands/auth.py +404 -0
- fast_agent/cli/commands/check_config.py +783 -0
- fast_agent/cli/commands/go.py +514 -0
- fast_agent/cli/commands/quickstart.py +557 -0
- fast_agent/cli/commands/serve.py +143 -0
- fast_agent/cli/commands/server_helpers.py +114 -0
- fast_agent/cli/commands/setup.py +174 -0
- fast_agent/cli/commands/url_parser.py +190 -0
- fast_agent/cli/constants.py +40 -0
- fast_agent/cli/main.py +115 -0
- fast_agent/cli/terminal.py +24 -0
- fast_agent/config.py +798 -0
- fast_agent/constants.py +41 -0
- fast_agent/context.py +279 -0
- fast_agent/context_dependent.py +50 -0
- fast_agent/core/__init__.py +92 -0
- fast_agent/core/agent_app.py +448 -0
- fast_agent/core/core_app.py +137 -0
- fast_agent/core/direct_decorators.py +784 -0
- fast_agent/core/direct_factory.py +620 -0
- fast_agent/core/error_handling.py +27 -0
- fast_agent/core/exceptions.py +90 -0
- fast_agent/core/executor/__init__.py +0 -0
- fast_agent/core/executor/executor.py +280 -0
- fast_agent/core/executor/task_registry.py +32 -0
- fast_agent/core/executor/workflow_signal.py +324 -0
- fast_agent/core/fastagent.py +1186 -0
- fast_agent/core/logging/__init__.py +5 -0
- fast_agent/core/logging/events.py +138 -0
- fast_agent/core/logging/json_serializer.py +164 -0
- fast_agent/core/logging/listeners.py +309 -0
- fast_agent/core/logging/logger.py +278 -0
- fast_agent/core/logging/transport.py +481 -0
- fast_agent/core/prompt.py +9 -0
- fast_agent/core/prompt_templates.py +183 -0
- fast_agent/core/validation.py +326 -0
- fast_agent/event_progress.py +62 -0
- fast_agent/history/history_exporter.py +49 -0
- fast_agent/human_input/__init__.py +47 -0
- fast_agent/human_input/elicitation_handler.py +123 -0
- fast_agent/human_input/elicitation_state.py +33 -0
- fast_agent/human_input/form_elements.py +59 -0
- fast_agent/human_input/form_fields.py +256 -0
- fast_agent/human_input/simple_form.py +113 -0
- fast_agent/human_input/types.py +40 -0
- fast_agent/interfaces.py +310 -0
- fast_agent/llm/__init__.py +9 -0
- fast_agent/llm/cancellation.py +22 -0
- fast_agent/llm/fastagent_llm.py +931 -0
- fast_agent/llm/internal/passthrough.py +161 -0
- fast_agent/llm/internal/playback.py +129 -0
- fast_agent/llm/internal/silent.py +41 -0
- fast_agent/llm/internal/slow.py +38 -0
- fast_agent/llm/memory.py +275 -0
- fast_agent/llm/model_database.py +490 -0
- fast_agent/llm/model_factory.py +388 -0
- fast_agent/llm/model_info.py +102 -0
- fast_agent/llm/prompt_utils.py +155 -0
- fast_agent/llm/provider/anthropic/anthropic_utils.py +84 -0
- fast_agent/llm/provider/anthropic/cache_planner.py +56 -0
- fast_agent/llm/provider/anthropic/llm_anthropic.py +796 -0
- fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +462 -0
- fast_agent/llm/provider/bedrock/bedrock_utils.py +218 -0
- fast_agent/llm/provider/bedrock/llm_bedrock.py +2207 -0
- fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +84 -0
- fast_agent/llm/provider/google/google_converter.py +466 -0
- fast_agent/llm/provider/google/llm_google_native.py +681 -0
- fast_agent/llm/provider/openai/llm_aliyun.py +31 -0
- fast_agent/llm/provider/openai/llm_azure.py +143 -0
- fast_agent/llm/provider/openai/llm_deepseek.py +76 -0
- fast_agent/llm/provider/openai/llm_generic.py +35 -0
- fast_agent/llm/provider/openai/llm_google_oai.py +32 -0
- fast_agent/llm/provider/openai/llm_groq.py +42 -0
- fast_agent/llm/provider/openai/llm_huggingface.py +85 -0
- fast_agent/llm/provider/openai/llm_openai.py +1195 -0
- fast_agent/llm/provider/openai/llm_openai_compatible.py +138 -0
- fast_agent/llm/provider/openai/llm_openrouter.py +45 -0
- fast_agent/llm/provider/openai/llm_tensorzero_openai.py +128 -0
- fast_agent/llm/provider/openai/llm_xai.py +38 -0
- fast_agent/llm/provider/openai/multipart_converter_openai.py +561 -0
- fast_agent/llm/provider/openai/openai_multipart.py +169 -0
- fast_agent/llm/provider/openai/openai_utils.py +67 -0
- fast_agent/llm/provider/openai/responses.py +133 -0
- fast_agent/llm/provider_key_manager.py +139 -0
- fast_agent/llm/provider_types.py +34 -0
- fast_agent/llm/request_params.py +61 -0
- fast_agent/llm/sampling_converter.py +98 -0
- fast_agent/llm/stream_types.py +9 -0
- fast_agent/llm/usage_tracking.py +445 -0
- fast_agent/mcp/__init__.py +56 -0
- fast_agent/mcp/common.py +26 -0
- fast_agent/mcp/elicitation_factory.py +84 -0
- fast_agent/mcp/elicitation_handlers.py +164 -0
- fast_agent/mcp/gen_client.py +83 -0
- fast_agent/mcp/helpers/__init__.py +36 -0
- fast_agent/mcp/helpers/content_helpers.py +352 -0
- fast_agent/mcp/helpers/server_config_helpers.py +25 -0
- fast_agent/mcp/hf_auth.py +147 -0
- fast_agent/mcp/interfaces.py +92 -0
- fast_agent/mcp/logger_textio.py +108 -0
- fast_agent/mcp/mcp_agent_client_session.py +411 -0
- fast_agent/mcp/mcp_aggregator.py +2175 -0
- fast_agent/mcp/mcp_connection_manager.py +723 -0
- fast_agent/mcp/mcp_content.py +262 -0
- fast_agent/mcp/mime_utils.py +108 -0
- fast_agent/mcp/oauth_client.py +509 -0
- fast_agent/mcp/prompt.py +159 -0
- fast_agent/mcp/prompt_message_extended.py +155 -0
- fast_agent/mcp/prompt_render.py +84 -0
- fast_agent/mcp/prompt_serialization.py +580 -0
- fast_agent/mcp/prompts/__init__.py +0 -0
- fast_agent/mcp/prompts/__main__.py +7 -0
- fast_agent/mcp/prompts/prompt_constants.py +18 -0
- fast_agent/mcp/prompts/prompt_helpers.py +238 -0
- fast_agent/mcp/prompts/prompt_load.py +186 -0
- fast_agent/mcp/prompts/prompt_server.py +552 -0
- fast_agent/mcp/prompts/prompt_template.py +438 -0
- fast_agent/mcp/resource_utils.py +215 -0
- fast_agent/mcp/sampling.py +200 -0
- fast_agent/mcp/server/__init__.py +4 -0
- fast_agent/mcp/server/agent_server.py +613 -0
- fast_agent/mcp/skybridge.py +44 -0
- fast_agent/mcp/sse_tracking.py +287 -0
- fast_agent/mcp/stdio_tracking_simple.py +59 -0
- fast_agent/mcp/streamable_http_tracking.py +309 -0
- fast_agent/mcp/tool_execution_handler.py +137 -0
- fast_agent/mcp/tool_permission_handler.py +88 -0
- fast_agent/mcp/transport_tracking.py +634 -0
- fast_agent/mcp/types.py +24 -0
- fast_agent/mcp/ui_agent.py +48 -0
- fast_agent/mcp/ui_mixin.py +209 -0
- fast_agent/mcp_server_registry.py +89 -0
- fast_agent/py.typed +0 -0
- fast_agent/resources/examples/data-analysis/analysis-campaign.py +189 -0
- fast_agent/resources/examples/data-analysis/analysis.py +68 -0
- fast_agent/resources/examples/data-analysis/fastagent.config.yaml +41 -0
- fast_agent/resources/examples/data-analysis/mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv +1471 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +297 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
- fast_agent/resources/examples/mcp/elicitations/forms_demo.py +107 -0
- fast_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
- fast_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
- fast_agent/resources/examples/mcp/elicitations/tool_call.py +21 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_one.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_two.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +27 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +15 -0
- fast_agent/resources/examples/researcher/fastagent.config.yaml +61 -0
- fast_agent/resources/examples/researcher/researcher-eval.py +53 -0
- fast_agent/resources/examples/researcher/researcher-imp.py +189 -0
- fast_agent/resources/examples/researcher/researcher.py +36 -0
- fast_agent/resources/examples/tensorzero/.env.sample +2 -0
- fast_agent/resources/examples/tensorzero/Makefile +31 -0
- fast_agent/resources/examples/tensorzero/README.md +56 -0
- fast_agent/resources/examples/tensorzero/agent.py +35 -0
- fast_agent/resources/examples/tensorzero/demo_images/clam.jpg +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/crab.png +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/shrimp.png +0 -0
- fast_agent/resources/examples/tensorzero/docker-compose.yml +105 -0
- fast_agent/resources/examples/tensorzero/fastagent.config.yaml +19 -0
- fast_agent/resources/examples/tensorzero/image_demo.py +67 -0
- fast_agent/resources/examples/tensorzero/mcp_server/Dockerfile +25 -0
- fast_agent/resources/examples/tensorzero/mcp_server/entrypoint.sh +35 -0
- fast_agent/resources/examples/tensorzero/mcp_server/mcp_server.py +31 -0
- fast_agent/resources/examples/tensorzero/mcp_server/pyproject.toml +11 -0
- fast_agent/resources/examples/tensorzero/simple_agent.py +25 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_schema.json +29 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_template.minijinja +11 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/tensorzero.toml +35 -0
- fast_agent/resources/examples/workflows/agents_as_tools_extended.py +73 -0
- fast_agent/resources/examples/workflows/agents_as_tools_simple.py +50 -0
- fast_agent/resources/examples/workflows/chaining.py +37 -0
- fast_agent/resources/examples/workflows/evaluator.py +77 -0
- fast_agent/resources/examples/workflows/fastagent.config.yaml +26 -0
- fast_agent/resources/examples/workflows/graded_report.md +89 -0
- fast_agent/resources/examples/workflows/human_input.py +28 -0
- fast_agent/resources/examples/workflows/maker.py +156 -0
- fast_agent/resources/examples/workflows/orchestrator.py +70 -0
- fast_agent/resources/examples/workflows/parallel.py +56 -0
- fast_agent/resources/examples/workflows/router.py +69 -0
- fast_agent/resources/examples/workflows/short_story.md +13 -0
- fast_agent/resources/examples/workflows/short_story.txt +19 -0
- fast_agent/resources/setup/.gitignore +30 -0
- fast_agent/resources/setup/agent.py +28 -0
- fast_agent/resources/setup/fastagent.config.yaml +65 -0
- fast_agent/resources/setup/fastagent.secrets.yaml.example +38 -0
- fast_agent/resources/setup/pyproject.toml.tmpl +23 -0
- fast_agent/skills/__init__.py +9 -0
- fast_agent/skills/registry.py +235 -0
- fast_agent/tools/elicitation.py +369 -0
- fast_agent/tools/shell_runtime.py +402 -0
- fast_agent/types/__init__.py +59 -0
- fast_agent/types/conversation_summary.py +294 -0
- fast_agent/types/llm_stop_reason.py +78 -0
- fast_agent/types/message_search.py +249 -0
- fast_agent/ui/__init__.py +38 -0
- fast_agent/ui/console.py +59 -0
- fast_agent/ui/console_display.py +1080 -0
- fast_agent/ui/elicitation_form.py +946 -0
- fast_agent/ui/elicitation_style.py +59 -0
- fast_agent/ui/enhanced_prompt.py +1400 -0
- fast_agent/ui/history_display.py +734 -0
- fast_agent/ui/interactive_prompt.py +1199 -0
- fast_agent/ui/markdown_helpers.py +104 -0
- fast_agent/ui/markdown_truncator.py +1004 -0
- fast_agent/ui/mcp_display.py +857 -0
- fast_agent/ui/mcp_ui_utils.py +235 -0
- fast_agent/ui/mermaid_utils.py +169 -0
- fast_agent/ui/message_primitives.py +50 -0
- fast_agent/ui/notification_tracker.py +205 -0
- fast_agent/ui/plain_text_truncator.py +68 -0
- fast_agent/ui/progress_display.py +10 -0
- fast_agent/ui/rich_progress.py +195 -0
- fast_agent/ui/streaming.py +774 -0
- fast_agent/ui/streaming_buffer.py +449 -0
- fast_agent/ui/tool_display.py +422 -0
- fast_agent/ui/usage_display.py +204 -0
- fast_agent/utils/__init__.py +5 -0
- fast_agent/utils/reasoning_stream_parser.py +77 -0
- fast_agent/utils/time.py +22 -0
- fast_agent/workflow_telemetry.py +261 -0
- fast_agent_mcp-0.4.7.dist-info/METADATA +788 -0
- fast_agent_mcp-0.4.7.dist-info/RECORD +261 -0
- fast_agent_mcp-0.4.7.dist-info/WHEEL +4 -0
- fast_agent_mcp-0.4.7.dist-info/entry_points.txt +7 -0
- fast_agent_mcp-0.4.7.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
from contextlib import contextmanager
|
|
2
|
+
from json import JSONDecodeError
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Iterator, Mapping, Union
|
|
4
|
+
|
|
5
|
+
from mcp.types import CallToolResult
|
|
6
|
+
from rich.markdown import Markdown
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from fast_agent.config import LoggerSettings, Settings
|
|
11
|
+
from fast_agent.constants import REASONING
|
|
12
|
+
from fast_agent.core.logging.logger import get_logger
|
|
13
|
+
from fast_agent.ui import console
|
|
14
|
+
from fast_agent.ui.markdown_helpers import prepare_markdown_content
|
|
15
|
+
from fast_agent.ui.mcp_ui_utils import UILink
|
|
16
|
+
from fast_agent.ui.mermaid_utils import (
|
|
17
|
+
MermaidDiagram,
|
|
18
|
+
create_mermaid_live_link,
|
|
19
|
+
detect_diagram_type,
|
|
20
|
+
extract_mermaid_diagrams,
|
|
21
|
+
)
|
|
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
|
|
33
|
+
from fast_agent.utils.time import format_duration
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from fast_agent.mcp.prompt_message_extended import PromptMessageExtended
|
|
37
|
+
from fast_agent.mcp.skybridge import SkybridgeServerConfig
|
|
38
|
+
|
|
39
|
+
logger = get_logger(__name__)
|
|
40
|
+
|
|
41
|
+
CODE_STYLE = "native"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConsoleDisplay:
|
|
45
|
+
"""
|
|
46
|
+
Handles displaying formatted messages, tool calls, and results to the console.
|
|
47
|
+
This centralizes the UI display logic used by LLM implementations.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
CODE_STYLE = CODE_STYLE
|
|
51
|
+
|
|
52
|
+
def __init__(self, config: Settings | None = None) -> None:
|
|
53
|
+
"""
|
|
54
|
+
Initialize the console display handler.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
config: Configuration object containing display preferences
|
|
58
|
+
"""
|
|
59
|
+
self.config = config
|
|
60
|
+
self._logger_settings = self._resolve_logger_settings(config)
|
|
61
|
+
if self.config and not getattr(self.config, "logger", None):
|
|
62
|
+
# Ensure callers passing in a bare namespace still get sane defaults
|
|
63
|
+
try:
|
|
64
|
+
setattr(self.config, "logger", self._logger_settings)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
self._markup = getattr(self._logger_settings, "enable_markup", True)
|
|
68
|
+
self._escape_xml = True
|
|
69
|
+
self._tool_display = ToolDisplay(self)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _resolve_logger_settings(config: Settings | None) -> LoggerSettings:
|
|
73
|
+
"""Provide a logger settings object even when callers omit it."""
|
|
74
|
+
logger_settings = getattr(config, "logger", None) if config else None
|
|
75
|
+
return logger_settings if logger_settings is not None else LoggerSettings()
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def code_style(self) -> str:
|
|
79
|
+
return CODE_STYLE
|
|
80
|
+
|
|
81
|
+
def resolve_streaming_preferences(self) -> tuple[bool, str]:
|
|
82
|
+
"""Return whether streaming is enabled plus the active mode."""
|
|
83
|
+
if not self.config:
|
|
84
|
+
return True, "markdown"
|
|
85
|
+
|
|
86
|
+
logger_settings = getattr(self.config, "logger", None)
|
|
87
|
+
if not logger_settings:
|
|
88
|
+
return True, "markdown"
|
|
89
|
+
|
|
90
|
+
streaming_mode = getattr(logger_settings, "streaming", "markdown")
|
|
91
|
+
if streaming_mode not in {"markdown", "plain", "none"}:
|
|
92
|
+
streaming_mode = "markdown"
|
|
93
|
+
|
|
94
|
+
# Legacy compatibility: allow streaming_plain_text override
|
|
95
|
+
if streaming_mode == "markdown" and getattr(logger_settings, "streaming_plain_text", False):
|
|
96
|
+
streaming_mode = "plain"
|
|
97
|
+
|
|
98
|
+
show_chat = bool(getattr(logger_settings, "show_chat", True))
|
|
99
|
+
streaming_display = bool(getattr(logger_settings, "streaming_display", True))
|
|
100
|
+
|
|
101
|
+
enabled = show_chat and streaming_display and streaming_mode != "none"
|
|
102
|
+
return enabled, streaming_mode
|
|
103
|
+
|
|
104
|
+
@staticmethod
|
|
105
|
+
def _looks_like_markdown(text: str) -> bool:
|
|
106
|
+
"""
|
|
107
|
+
Heuristic to detect markdown-ish content.
|
|
108
|
+
|
|
109
|
+
We keep this lightweight: focus on common structures that benefit from markdown
|
|
110
|
+
rendering without requiring strict syntax validation.
|
|
111
|
+
"""
|
|
112
|
+
import re
|
|
113
|
+
|
|
114
|
+
if not text or len(text) < 3:
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
if "```" in text:
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
# Simple markers for common cases that the regex might miss
|
|
121
|
+
# Note: single "*" excluded to avoid false positives
|
|
122
|
+
simple_markers = ["##", "**", "---", "###"]
|
|
123
|
+
if any(marker in text for marker in simple_markers):
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
markdown_patterns = [
|
|
127
|
+
r"^#{1,6}\s+\S", # headings
|
|
128
|
+
r"^\s*[-*+]\s+\S", # unordered list
|
|
129
|
+
r"^\s*\d+\.\s+\S", # ordered list
|
|
130
|
+
r"`[^`]+`", # inline code
|
|
131
|
+
r"\*\*[^*]+\*\*",
|
|
132
|
+
r"__[^_]+__",
|
|
133
|
+
r"^\s*>\s+\S", # blockquote
|
|
134
|
+
r"\[.+?\]\(.+?\)", # links
|
|
135
|
+
r"!\[.*?\]\(.+?\)", # images
|
|
136
|
+
r"^\s*\|.+\|\s*$", # simple tables
|
|
137
|
+
r"^\s*[-*_]{3,}\s*$", # horizontal rules
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
return any(re.search(pattern, text, re.MULTILINE) for pattern in markdown_patterns)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def _format_elapsed(elapsed: float) -> str:
|
|
144
|
+
"""Format elapsed seconds for display."""
|
|
145
|
+
if elapsed < 0:
|
|
146
|
+
elapsed = 0.0
|
|
147
|
+
if elapsed < 0.001:
|
|
148
|
+
return "<1ms"
|
|
149
|
+
if elapsed < 1:
|
|
150
|
+
return f"{elapsed * 1000:.0f}ms"
|
|
151
|
+
if elapsed < 10:
|
|
152
|
+
return f"{elapsed:.2f}s"
|
|
153
|
+
if elapsed < 60:
|
|
154
|
+
return f"{elapsed:.1f}s"
|
|
155
|
+
return format_duration(elapsed)
|
|
156
|
+
|
|
157
|
+
def display_message(
|
|
158
|
+
self,
|
|
159
|
+
content: Any,
|
|
160
|
+
message_type: MessageType,
|
|
161
|
+
name: str | None = None,
|
|
162
|
+
right_info: str = "",
|
|
163
|
+
bottom_metadata: list[str] | None = None,
|
|
164
|
+
highlight_index: int | None = None,
|
|
165
|
+
max_item_length: int | None = None,
|
|
166
|
+
is_error: bool = False,
|
|
167
|
+
truncate_content: bool = True,
|
|
168
|
+
additional_message: Text | None = None,
|
|
169
|
+
pre_content: Text | None = None,
|
|
170
|
+
) -> None:
|
|
171
|
+
"""
|
|
172
|
+
Unified method to display formatted messages to the console.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
content: The main content to display (str, Text, JSON, etc.)
|
|
176
|
+
message_type: Type of message (USER, ASSISTANT, TOOL_CALL, TOOL_RESULT)
|
|
177
|
+
name: Optional name to display (agent name, user name, etc.)
|
|
178
|
+
right_info: Information to display on the right side of the header
|
|
179
|
+
bottom_metadata: Optional list of items for bottom separator
|
|
180
|
+
highlight_index: Index of item to highlight in bottom metadata (0-based), or None
|
|
181
|
+
max_item_length: Optional max length for bottom metadata items (with ellipsis)
|
|
182
|
+
is_error: For tool results, whether this is an error (uses red color)
|
|
183
|
+
truncate_content: Whether to truncate long content
|
|
184
|
+
additional_message: Optional Rich Text appended after the main content
|
|
185
|
+
pre_content: Optional Rich Text shown before the main content
|
|
186
|
+
"""
|
|
187
|
+
# Get configuration for this message type
|
|
188
|
+
config = MESSAGE_CONFIGS[message_type]
|
|
189
|
+
|
|
190
|
+
# Override colors for error states
|
|
191
|
+
if is_error and message_type == MessageType.TOOL_RESULT:
|
|
192
|
+
block_color = "red"
|
|
193
|
+
else:
|
|
194
|
+
block_color = config["block_color"]
|
|
195
|
+
|
|
196
|
+
# Build the left side of the header
|
|
197
|
+
arrow = config["arrow"]
|
|
198
|
+
arrow_style = config["arrow_style"]
|
|
199
|
+
left = f"[{block_color}]▎[/{block_color}][{arrow_style}]{arrow}[/{arrow_style}]"
|
|
200
|
+
if name:
|
|
201
|
+
left += f" [{block_color if not is_error else 'red'}]{name}[/{block_color if not is_error else 'red'}]"
|
|
202
|
+
|
|
203
|
+
# Create combined separator and status line
|
|
204
|
+
self._create_combined_separator_status(left, right_info)
|
|
205
|
+
|
|
206
|
+
# Display the content
|
|
207
|
+
if pre_content and pre_content.plain:
|
|
208
|
+
console.console.print(pre_content, markup=self._markup)
|
|
209
|
+
self._display_content(
|
|
210
|
+
content, truncate_content, is_error, message_type, check_markdown_markers=False
|
|
211
|
+
)
|
|
212
|
+
if additional_message:
|
|
213
|
+
console.console.print(additional_message, markup=self._markup)
|
|
214
|
+
|
|
215
|
+
# Handle bottom separator with optional metadata
|
|
216
|
+
self._render_bottom_metadata(
|
|
217
|
+
message_type=message_type,
|
|
218
|
+
bottom_metadata=bottom_metadata,
|
|
219
|
+
highlight_index=highlight_index,
|
|
220
|
+
max_item_length=max_item_length,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def _display_content(
|
|
224
|
+
self,
|
|
225
|
+
content: Any,
|
|
226
|
+
truncate: bool = True,
|
|
227
|
+
is_error: bool = False,
|
|
228
|
+
message_type: MessageType | None = None,
|
|
229
|
+
check_markdown_markers: bool = False,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""
|
|
232
|
+
Display content in the appropriate format.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
content: Content to display
|
|
236
|
+
truncate: Whether to truncate long content
|
|
237
|
+
is_error: Whether this is error content (affects styling)
|
|
238
|
+
message_type: Type of message to determine appropriate styling
|
|
239
|
+
check_markdown_markers: If True, only use markdown rendering when markers are present
|
|
240
|
+
"""
|
|
241
|
+
import json
|
|
242
|
+
import re
|
|
243
|
+
|
|
244
|
+
from rich.pretty import Pretty
|
|
245
|
+
from rich.syntax import Syntax
|
|
246
|
+
|
|
247
|
+
from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content
|
|
248
|
+
|
|
249
|
+
# Determine the style based on message type
|
|
250
|
+
# USER, ASSISTANT, and SYSTEM messages should display in normal style
|
|
251
|
+
# TOOL_CALL and TOOL_RESULT should be dimmed
|
|
252
|
+
if is_error:
|
|
253
|
+
style = "dim red"
|
|
254
|
+
elif message_type in [MessageType.USER, MessageType.ASSISTANT, MessageType.SYSTEM]:
|
|
255
|
+
style = None # No style means default/normal white
|
|
256
|
+
else:
|
|
257
|
+
style = "dim"
|
|
258
|
+
|
|
259
|
+
# Handle different content types
|
|
260
|
+
if isinstance(content, str):
|
|
261
|
+
# Try to detect and handle different string formats
|
|
262
|
+
try:
|
|
263
|
+
# Try as JSON first
|
|
264
|
+
json_obj = json.loads(content)
|
|
265
|
+
if truncate and self.config and self.config.logger.truncate_tools:
|
|
266
|
+
pretty_obj = Pretty(json_obj, max_length=10, max_string=50)
|
|
267
|
+
else:
|
|
268
|
+
pretty_obj = Pretty(json_obj)
|
|
269
|
+
# Apply style only if specified
|
|
270
|
+
if style:
|
|
271
|
+
console.console.print(pretty_obj, style=style, markup=self._markup)
|
|
272
|
+
else:
|
|
273
|
+
console.console.print(pretty_obj, markup=self._markup)
|
|
274
|
+
except (JSONDecodeError, TypeError, ValueError):
|
|
275
|
+
# Check if content appears to be primarily XML
|
|
276
|
+
xml_pattern = r"^<[a-zA-Z_][a-zA-Z0-9_-]*[^>]*>"
|
|
277
|
+
is_xml_content = (
|
|
278
|
+
bool(re.match(xml_pattern, content.strip())) and content.count("<") > 5
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if is_xml_content:
|
|
282
|
+
# Display XML content with syntax highlighting for better readability
|
|
283
|
+
syntax = Syntax(content, "xml", theme=CODE_STYLE, line_numbers=False)
|
|
284
|
+
console.console.print(syntax, markup=self._markup)
|
|
285
|
+
elif check_markdown_markers:
|
|
286
|
+
# Check for markdown markers before deciding to use markdown rendering
|
|
287
|
+
if self._looks_like_markdown(content):
|
|
288
|
+
# Has markdown markers - render as markdown with escaping
|
|
289
|
+
prepared_content = prepare_markdown_content(content, self._escape_xml)
|
|
290
|
+
md = Markdown(prepared_content, code_theme=CODE_STYLE)
|
|
291
|
+
console.console.print(md, markup=self._markup)
|
|
292
|
+
else:
|
|
293
|
+
# Plain text - display as-is
|
|
294
|
+
if (
|
|
295
|
+
truncate
|
|
296
|
+
and self.config
|
|
297
|
+
and self.config.logger.truncate_tools
|
|
298
|
+
and len(content) > 360
|
|
299
|
+
):
|
|
300
|
+
content = content[:360] + "..."
|
|
301
|
+
if style:
|
|
302
|
+
console.console.print(content, style=style, markup=self._markup)
|
|
303
|
+
else:
|
|
304
|
+
console.console.print(content, markup=self._markup)
|
|
305
|
+
else:
|
|
306
|
+
# Check if content has substantial XML (mixed content)
|
|
307
|
+
# If so, skip markdown rendering as it turns XML into an unreadable blob
|
|
308
|
+
has_substantial_xml = content.count("<") > 5 and content.count(">") > 5
|
|
309
|
+
|
|
310
|
+
# Check if it looks like markdown
|
|
311
|
+
if self._looks_like_markdown(content) and not has_substantial_xml:
|
|
312
|
+
# Escape HTML/XML tags while preserving code blocks
|
|
313
|
+
prepared_content = prepare_markdown_content(content, self._escape_xml)
|
|
314
|
+
md = Markdown(prepared_content, code_theme=CODE_STYLE)
|
|
315
|
+
# Markdown handles its own styling, don't apply style
|
|
316
|
+
console.console.print(md, markup=self._markup)
|
|
317
|
+
else:
|
|
318
|
+
# Plain text (or mixed markdown+XML content)
|
|
319
|
+
if (
|
|
320
|
+
truncate
|
|
321
|
+
and self.config
|
|
322
|
+
and self.config.logger.truncate_tools
|
|
323
|
+
and len(content) > 360
|
|
324
|
+
):
|
|
325
|
+
content = content[:360] + "..."
|
|
326
|
+
# Apply style only if specified (None means default white)
|
|
327
|
+
if style:
|
|
328
|
+
console.console.print(content, style=style, markup=self._markup)
|
|
329
|
+
else:
|
|
330
|
+
console.console.print(content, markup=self._markup)
|
|
331
|
+
elif isinstance(content, Text):
|
|
332
|
+
# Rich Text object - check if it contains markdown
|
|
333
|
+
plain_text = content.plain
|
|
334
|
+
|
|
335
|
+
# Check if the plain text contains markdown markers
|
|
336
|
+
if self._looks_like_markdown(plain_text):
|
|
337
|
+
# Split the Text object into segments
|
|
338
|
+
# We need to handle the main content (which may have markdown)
|
|
339
|
+
# and any styled segments that were appended
|
|
340
|
+
|
|
341
|
+
# If the Text object has multiple spans with different styles,
|
|
342
|
+
# we need to be careful about how we render them
|
|
343
|
+
if len(content._spans) > 1:
|
|
344
|
+
# Complex case: Text has multiple styled segments
|
|
345
|
+
# We'll render the first part as markdown if it contains markers
|
|
346
|
+
# and append other styled parts separately
|
|
347
|
+
|
|
348
|
+
# Find where the markdown content ends (usually the first span)
|
|
349
|
+
markdown_end = content._spans[0].end if content._spans else len(plain_text)
|
|
350
|
+
markdown_part = plain_text[:markdown_end]
|
|
351
|
+
|
|
352
|
+
# Check if the first part has markdown
|
|
353
|
+
if self._looks_like_markdown(markdown_part):
|
|
354
|
+
# Render markdown part
|
|
355
|
+
prepared_content = prepare_markdown_content(markdown_part, self._escape_xml)
|
|
356
|
+
md = Markdown(prepared_content, code_theme=CODE_STYLE)
|
|
357
|
+
console.console.print(md, markup=self._markup)
|
|
358
|
+
|
|
359
|
+
# Then render any additional styled segments
|
|
360
|
+
if markdown_end < len(plain_text):
|
|
361
|
+
remaining_text = Text()
|
|
362
|
+
for span in content._spans:
|
|
363
|
+
if span.start >= markdown_end:
|
|
364
|
+
segment_text = plain_text[span.start : span.end]
|
|
365
|
+
remaining_text.append(segment_text, style=span.style)
|
|
366
|
+
if remaining_text.plain:
|
|
367
|
+
console.console.print(remaining_text, markup=self._markup)
|
|
368
|
+
else:
|
|
369
|
+
# No markdown in first part, just print the whole Text object
|
|
370
|
+
console.console.print(content, markup=self._markup)
|
|
371
|
+
else:
|
|
372
|
+
# Simple case: entire text should be rendered as markdown
|
|
373
|
+
prepared_content = prepare_markdown_content(plain_text, self._escape_xml)
|
|
374
|
+
md = Markdown(prepared_content, code_theme=CODE_STYLE)
|
|
375
|
+
console.console.print(md, markup=self._markup)
|
|
376
|
+
else:
|
|
377
|
+
# No markdown markers, print as regular Rich Text
|
|
378
|
+
console.console.print(content, markup=self._markup)
|
|
379
|
+
elif isinstance(content, list):
|
|
380
|
+
# Handle content blocks (for tool results)
|
|
381
|
+
if len(content) == 1 and is_text_content(content[0]):
|
|
382
|
+
# Single text block - display directly
|
|
383
|
+
text_content = get_text(content[0])
|
|
384
|
+
if text_content:
|
|
385
|
+
if (
|
|
386
|
+
truncate
|
|
387
|
+
and self.config
|
|
388
|
+
and self.config.logger.truncate_tools
|
|
389
|
+
and len(text_content) > 360
|
|
390
|
+
):
|
|
391
|
+
text_content = text_content[:360] + "..."
|
|
392
|
+
# Apply style only if specified
|
|
393
|
+
if style:
|
|
394
|
+
console.console.print(text_content, style=style, markup=self._markup)
|
|
395
|
+
else:
|
|
396
|
+
console.console.print(text_content, markup=self._markup)
|
|
397
|
+
else:
|
|
398
|
+
# Apply style only if specified
|
|
399
|
+
if style:
|
|
400
|
+
console.console.print("(empty text)", style=style, markup=self._markup)
|
|
401
|
+
else:
|
|
402
|
+
console.console.print("(empty text)", markup=self._markup)
|
|
403
|
+
else:
|
|
404
|
+
# Multiple blocks or non-text content
|
|
405
|
+
if truncate and self.config and self.config.logger.truncate_tools:
|
|
406
|
+
pretty_obj = Pretty(content, max_length=10, max_string=50)
|
|
407
|
+
else:
|
|
408
|
+
pretty_obj = Pretty(content)
|
|
409
|
+
# Apply style only if specified
|
|
410
|
+
if style:
|
|
411
|
+
console.console.print(pretty_obj, style=style, markup=self._markup)
|
|
412
|
+
else:
|
|
413
|
+
console.console.print(pretty_obj, markup=self._markup)
|
|
414
|
+
else:
|
|
415
|
+
# Any other type - use Pretty
|
|
416
|
+
if truncate and self.config and self.config.logger.truncate_tools:
|
|
417
|
+
pretty_obj = Pretty(content, max_length=10, max_string=50)
|
|
418
|
+
else:
|
|
419
|
+
pretty_obj = Pretty(content)
|
|
420
|
+
# Apply style only if specified
|
|
421
|
+
if style:
|
|
422
|
+
console.console.print(pretty_obj, style=style, markup=self._markup)
|
|
423
|
+
else:
|
|
424
|
+
console.console.print(pretty_obj, markup=self._markup)
|
|
425
|
+
|
|
426
|
+
def _shorten_items(self, items: list[str], max_length: int) -> list[str]:
|
|
427
|
+
"""
|
|
428
|
+
Shorten items to max_length with ellipsis if needed.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
items: List of strings to potentially shorten
|
|
432
|
+
max_length: Maximum length for each item
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
List of shortened strings
|
|
436
|
+
"""
|
|
437
|
+
return [item[: max_length - 1] + "…" if len(item) > max_length else item for item in items]
|
|
438
|
+
|
|
439
|
+
def _render_bottom_metadata(
|
|
440
|
+
self,
|
|
441
|
+
*,
|
|
442
|
+
message_type: MessageType,
|
|
443
|
+
bottom_metadata: list[str] | None,
|
|
444
|
+
highlight_index: int | None,
|
|
445
|
+
max_item_length: int | None,
|
|
446
|
+
) -> None:
|
|
447
|
+
"""
|
|
448
|
+
Render the bottom separator line with optional metadata.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
message_type: The type of message being displayed
|
|
452
|
+
bottom_metadata: Optional list of items to show in the separator
|
|
453
|
+
highlight_index: Optional index of the item to highlight
|
|
454
|
+
max_item_length: Optional maximum length for individual items
|
|
455
|
+
"""
|
|
456
|
+
console.console.print()
|
|
457
|
+
|
|
458
|
+
if bottom_metadata:
|
|
459
|
+
display_items = bottom_metadata
|
|
460
|
+
if max_item_length:
|
|
461
|
+
display_items = self._shorten_items(bottom_metadata, max_item_length)
|
|
462
|
+
|
|
463
|
+
total_width = console.console.size.width
|
|
464
|
+
prefix = Text("─| ")
|
|
465
|
+
prefix.stylize("dim")
|
|
466
|
+
suffix = Text(" |")
|
|
467
|
+
suffix.stylize("dim")
|
|
468
|
+
available = max(0, total_width - prefix.cell_len - suffix.cell_len)
|
|
469
|
+
|
|
470
|
+
highlight_color = MESSAGE_CONFIGS[message_type]["highlight_color"]
|
|
471
|
+
metadata_text = self._format_bottom_metadata(
|
|
472
|
+
display_items,
|
|
473
|
+
highlight_index,
|
|
474
|
+
highlight_color,
|
|
475
|
+
max_width=available,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
line = Text()
|
|
479
|
+
line.append_text(prefix)
|
|
480
|
+
line.append_text(metadata_text)
|
|
481
|
+
line.append_text(suffix)
|
|
482
|
+
remaining = total_width - line.cell_len
|
|
483
|
+
if remaining > 0:
|
|
484
|
+
line.append("─" * remaining, style="dim")
|
|
485
|
+
console.console.print(line, markup=self._markup)
|
|
486
|
+
else:
|
|
487
|
+
console.console.print("─" * console.console.size.width, style="dim")
|
|
488
|
+
|
|
489
|
+
console.console.print()
|
|
490
|
+
|
|
491
|
+
def _format_bottom_metadata(
|
|
492
|
+
self,
|
|
493
|
+
items: list[str],
|
|
494
|
+
highlight_index: int | None,
|
|
495
|
+
highlight_color: str,
|
|
496
|
+
max_width: int | None = None,
|
|
497
|
+
) -> Text:
|
|
498
|
+
"""
|
|
499
|
+
Format a list of items with pipe separators and highlighting.
|
|
500
|
+
|
|
501
|
+
Args:
|
|
502
|
+
items: List of items to display
|
|
503
|
+
highlight_index: Index of item to highlight (0-based), or None for no highlighting
|
|
504
|
+
highlight_color: Color to use for highlighting
|
|
505
|
+
max_width: Maximum width for the formatted text
|
|
506
|
+
|
|
507
|
+
Returns:
|
|
508
|
+
Formatted Text object with proper separators and highlighting
|
|
509
|
+
"""
|
|
510
|
+
formatted = Text()
|
|
511
|
+
|
|
512
|
+
def will_fit(next_segment: Text) -> bool:
|
|
513
|
+
if max_width is None:
|
|
514
|
+
return True
|
|
515
|
+
# projected length if we append next_segment
|
|
516
|
+
return formatted.cell_len + next_segment.cell_len <= max_width
|
|
517
|
+
|
|
518
|
+
for i, item in enumerate(items):
|
|
519
|
+
sep = Text(" | ", style="dim") if i > 0 else Text("")
|
|
520
|
+
|
|
521
|
+
# Prepare item text with potential highlighting
|
|
522
|
+
should_highlight = highlight_index is not None and i == highlight_index
|
|
523
|
+
|
|
524
|
+
item_text = Text(item, style=(highlight_color if should_highlight else "dim"))
|
|
525
|
+
|
|
526
|
+
# Check if separator + item fits in available width
|
|
527
|
+
if not will_fit(sep + item_text):
|
|
528
|
+
# If nothing has been added yet and the item itself is too long,
|
|
529
|
+
# leave space for an ellipsis and stop.
|
|
530
|
+
if formatted.cell_len == 0 and max_width is not None and max_width > 1:
|
|
531
|
+
# show truncated indicator only
|
|
532
|
+
formatted.append("…", style="dim")
|
|
533
|
+
else:
|
|
534
|
+
# Indicate there are more items but avoid wrapping
|
|
535
|
+
if max_width is None or formatted.cell_len < max_width:
|
|
536
|
+
formatted.append(" …", style="dim")
|
|
537
|
+
break
|
|
538
|
+
|
|
539
|
+
# Append separator and item
|
|
540
|
+
if sep.plain:
|
|
541
|
+
formatted.append_text(sep)
|
|
542
|
+
formatted.append_text(item_text)
|
|
543
|
+
|
|
544
|
+
return formatted
|
|
545
|
+
|
|
546
|
+
def show_tool_result(
|
|
547
|
+
self,
|
|
548
|
+
result: CallToolResult,
|
|
549
|
+
name: str | None = None,
|
|
550
|
+
tool_name: str | None = None,
|
|
551
|
+
skybridge_config: "SkybridgeServerConfig | None" = None,
|
|
552
|
+
timing_ms: float | None = None,
|
|
553
|
+
type_label: str | None = None,
|
|
554
|
+
) -> None:
|
|
555
|
+
kwargs: dict[str, Any] = {
|
|
556
|
+
"name": name,
|
|
557
|
+
"tool_name": tool_name,
|
|
558
|
+
"skybridge_config": skybridge_config,
|
|
559
|
+
"timing_ms": timing_ms,
|
|
560
|
+
}
|
|
561
|
+
if type_label is not None:
|
|
562
|
+
kwargs["type_label"] = type_label
|
|
563
|
+
|
|
564
|
+
self._tool_display.show_tool_result(result, **kwargs)
|
|
565
|
+
|
|
566
|
+
def show_tool_call(
|
|
567
|
+
self,
|
|
568
|
+
tool_name: str,
|
|
569
|
+
tool_args: dict[str, Any] | None,
|
|
570
|
+
bottom_items: list[str] | None = None,
|
|
571
|
+
highlight_index: int | None = None,
|
|
572
|
+
max_item_length: int | None = None,
|
|
573
|
+
name: str | None = None,
|
|
574
|
+
metadata: dict[str, Any] | None = None,
|
|
575
|
+
type_label: str | None = None,
|
|
576
|
+
) -> None:
|
|
577
|
+
kwargs: dict[str, Any] = {
|
|
578
|
+
"bottom_items": bottom_items,
|
|
579
|
+
"highlight_index": highlight_index,
|
|
580
|
+
"max_item_length": max_item_length,
|
|
581
|
+
"name": name,
|
|
582
|
+
"metadata": metadata,
|
|
583
|
+
}
|
|
584
|
+
if type_label is not None:
|
|
585
|
+
kwargs["type_label"] = type_label
|
|
586
|
+
|
|
587
|
+
self._tool_display.show_tool_call(tool_name, tool_args, **kwargs)
|
|
588
|
+
|
|
589
|
+
async def show_tool_update(self, updated_server: str, agent_name: str | None = None) -> None:
|
|
590
|
+
await self._tool_display.show_tool_update(updated_server, agent_name=agent_name)
|
|
591
|
+
|
|
592
|
+
def _create_combined_separator_status(self, left_content: str, right_info: str = "") -> None:
|
|
593
|
+
"""
|
|
594
|
+
Create a combined separator and status line.
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
left_content: The main content (block, arrow, name) - left justified with color
|
|
598
|
+
right_info: Supplementary information to show in brackets - right aligned
|
|
599
|
+
"""
|
|
600
|
+
width = console.console.size.width
|
|
601
|
+
|
|
602
|
+
# Create left text
|
|
603
|
+
left_text = Text.from_markup(left_content)
|
|
604
|
+
|
|
605
|
+
# Create right text if we have info
|
|
606
|
+
if right_info and right_info.strip():
|
|
607
|
+
# Add dim brackets around the right info
|
|
608
|
+
right_text = Text()
|
|
609
|
+
right_text.append("[", style="dim")
|
|
610
|
+
right_text.append_text(Text.from_markup(right_info))
|
|
611
|
+
right_text.append("]", style="dim")
|
|
612
|
+
# Calculate separator count
|
|
613
|
+
separator_count = width - left_text.cell_len - right_text.cell_len
|
|
614
|
+
if separator_count < 1:
|
|
615
|
+
separator_count = 1 # Always at least 1 separator
|
|
616
|
+
else:
|
|
617
|
+
right_text = Text("")
|
|
618
|
+
separator_count = width - left_text.cell_len
|
|
619
|
+
|
|
620
|
+
# Build the combined line
|
|
621
|
+
combined = Text()
|
|
622
|
+
combined.append_text(left_text)
|
|
623
|
+
combined.append(" ", style="default")
|
|
624
|
+
combined.append("─" * (separator_count - 1), style="dim")
|
|
625
|
+
combined.append_text(right_text)
|
|
626
|
+
|
|
627
|
+
# Print with empty line before
|
|
628
|
+
console.console.print()
|
|
629
|
+
console.console.print(combined, markup=self._markup)
|
|
630
|
+
console.console.print()
|
|
631
|
+
|
|
632
|
+
@staticmethod
|
|
633
|
+
def summarize_skybridge_configs(
|
|
634
|
+
configs: Mapping[str, "SkybridgeServerConfig"] | None,
|
|
635
|
+
) -> tuple[list[dict[str, Any]], list[str]]:
|
|
636
|
+
return ToolDisplay.summarize_skybridge_configs(configs)
|
|
637
|
+
|
|
638
|
+
def show_skybridge_summary(
|
|
639
|
+
self,
|
|
640
|
+
agent_name: str,
|
|
641
|
+
configs: Mapping[str, "SkybridgeServerConfig"] | None,
|
|
642
|
+
) -> None:
|
|
643
|
+
self._tool_display.show_skybridge_summary(agent_name, configs)
|
|
644
|
+
|
|
645
|
+
def _extract_reasoning_content(self, message: "PromptMessageExtended") -> Text | None:
|
|
646
|
+
"""Extract reasoning channel content as dim text."""
|
|
647
|
+
channels = message.channels or {}
|
|
648
|
+
reasoning_blocks = channels.get(REASONING) or []
|
|
649
|
+
if not reasoning_blocks:
|
|
650
|
+
return None
|
|
651
|
+
|
|
652
|
+
from fast_agent.mcp.helpers.content_helpers import get_text
|
|
653
|
+
|
|
654
|
+
reasoning_segments = []
|
|
655
|
+
for block in reasoning_blocks:
|
|
656
|
+
text = get_text(block)
|
|
657
|
+
if text:
|
|
658
|
+
reasoning_segments.append(text)
|
|
659
|
+
|
|
660
|
+
if not reasoning_segments:
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
joined = "\n".join(reasoning_segments)
|
|
664
|
+
if not joined.strip():
|
|
665
|
+
return None
|
|
666
|
+
|
|
667
|
+
# Render reasoning in dim italic and leave a blank line before main content
|
|
668
|
+
text = joined
|
|
669
|
+
if not text.endswith("\n"):
|
|
670
|
+
text += "\n"
|
|
671
|
+
text += "\n"
|
|
672
|
+
return Text(text, style="dim italic")
|
|
673
|
+
|
|
674
|
+
async def show_assistant_message(
|
|
675
|
+
self,
|
|
676
|
+
message_text: Union[str, Text, "PromptMessageExtended"],
|
|
677
|
+
bottom_items: list[str] | None = None,
|
|
678
|
+
highlight_index: int | None = None,
|
|
679
|
+
max_item_length: int | None = None,
|
|
680
|
+
name: str | None = None,
|
|
681
|
+
model: str | None = None,
|
|
682
|
+
additional_message: Text | None = None,
|
|
683
|
+
) -> None:
|
|
684
|
+
"""Display an assistant message in a formatted panel.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
message_text: The message content to display (str, Text, or PromptMessageExtended)
|
|
688
|
+
bottom_items: Optional list of items for bottom separator (e.g., servers, destinations)
|
|
689
|
+
highlight_index: Index of item to highlight in the bottom separator (0-based), or None
|
|
690
|
+
max_item_length: Optional max length for bottom items (with ellipsis)
|
|
691
|
+
title: Title for the message (default "ASSISTANT")
|
|
692
|
+
name: Optional agent name
|
|
693
|
+
model: Optional model name for right info
|
|
694
|
+
additional_message: Optional additional styled message to append
|
|
695
|
+
"""
|
|
696
|
+
if self.config and not self.config.logger.show_chat:
|
|
697
|
+
return
|
|
698
|
+
|
|
699
|
+
# Extract text from PromptMessageExtended if needed
|
|
700
|
+
from fast_agent.types import PromptMessageExtended
|
|
701
|
+
|
|
702
|
+
pre_content: Text | None = None
|
|
703
|
+
|
|
704
|
+
if isinstance(message_text, PromptMessageExtended):
|
|
705
|
+
display_text = message_text.last_text() or ""
|
|
706
|
+
pre_content = self._extract_reasoning_content(message_text)
|
|
707
|
+
else:
|
|
708
|
+
display_text = message_text
|
|
709
|
+
|
|
710
|
+
# Build right info
|
|
711
|
+
right_info = f"[dim]{model}[/dim]" if model else ""
|
|
712
|
+
|
|
713
|
+
# Display main message using unified method
|
|
714
|
+
self.display_message(
|
|
715
|
+
content=display_text,
|
|
716
|
+
message_type=MessageType.ASSISTANT,
|
|
717
|
+
name=name,
|
|
718
|
+
right_info=right_info,
|
|
719
|
+
bottom_metadata=bottom_items,
|
|
720
|
+
highlight_index=highlight_index,
|
|
721
|
+
max_item_length=max_item_length,
|
|
722
|
+
truncate_content=False, # Assistant messages shouldn't be truncated
|
|
723
|
+
additional_message=additional_message,
|
|
724
|
+
pre_content=pre_content,
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
# Handle mermaid diagrams separately (after the main message)
|
|
728
|
+
# Extract plain text for mermaid detection
|
|
729
|
+
plain_text = display_text
|
|
730
|
+
if isinstance(display_text, Text):
|
|
731
|
+
plain_text = display_text.plain
|
|
732
|
+
|
|
733
|
+
if isinstance(plain_text, str):
|
|
734
|
+
diagrams = extract_mermaid_diagrams(plain_text)
|
|
735
|
+
if diagrams:
|
|
736
|
+
self._display_mermaid_diagrams(diagrams)
|
|
737
|
+
|
|
738
|
+
@contextmanager
|
|
739
|
+
def streaming_assistant_message(
|
|
740
|
+
self,
|
|
741
|
+
*,
|
|
742
|
+
bottom_items: list[str] | None = None,
|
|
743
|
+
highlight_index: int | None = None,
|
|
744
|
+
max_item_length: int | None = None,
|
|
745
|
+
name: str | None = None,
|
|
746
|
+
model: str | None = None,
|
|
747
|
+
) -> Iterator[StreamingHandle]:
|
|
748
|
+
"""Create a streaming context for assistant messages."""
|
|
749
|
+
streaming_enabled, streaming_mode = self.resolve_streaming_preferences()
|
|
750
|
+
|
|
751
|
+
if not streaming_enabled:
|
|
752
|
+
yield _NullStreamingHandle()
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
from fast_agent.ui.progress_display import progress_display
|
|
756
|
+
|
|
757
|
+
config = MESSAGE_CONFIGS[MessageType.ASSISTANT]
|
|
758
|
+
block_color = config["block_color"]
|
|
759
|
+
arrow = config["arrow"]
|
|
760
|
+
arrow_style = config["arrow_style"]
|
|
761
|
+
|
|
762
|
+
left = f"[{block_color}]▎[/{block_color}][{arrow_style}]{arrow}[/{arrow_style}] "
|
|
763
|
+
if name:
|
|
764
|
+
left += f"[{block_color}]{name}[/{block_color}]"
|
|
765
|
+
|
|
766
|
+
right_info = f"[dim]{model}[/dim]" if model else ""
|
|
767
|
+
|
|
768
|
+
# Determine renderer based on streaming mode
|
|
769
|
+
use_plain_text = streaming_mode == "plain"
|
|
770
|
+
|
|
771
|
+
handle = _StreamingMessageHandle(
|
|
772
|
+
display=self,
|
|
773
|
+
bottom_items=bottom_items,
|
|
774
|
+
highlight_index=highlight_index,
|
|
775
|
+
max_item_length=max_item_length,
|
|
776
|
+
use_plain_text=use_plain_text,
|
|
777
|
+
header_left=left,
|
|
778
|
+
header_right=right_info,
|
|
779
|
+
progress_display=progress_display,
|
|
780
|
+
)
|
|
781
|
+
try:
|
|
782
|
+
yield handle
|
|
783
|
+
finally:
|
|
784
|
+
handle.close()
|
|
785
|
+
|
|
786
|
+
def _display_mermaid_diagrams(self, diagrams: list[MermaidDiagram]) -> None:
|
|
787
|
+
"""Display mermaid diagram links."""
|
|
788
|
+
diagram_content = Text()
|
|
789
|
+
# Add bullet at the beginning
|
|
790
|
+
diagram_content.append("● ", style="dim")
|
|
791
|
+
|
|
792
|
+
for i, diagram in enumerate(diagrams, 1):
|
|
793
|
+
if i > 1:
|
|
794
|
+
diagram_content.append(" • ", style="dim")
|
|
795
|
+
|
|
796
|
+
# Generate URL
|
|
797
|
+
url = create_mermaid_live_link(diagram.content)
|
|
798
|
+
|
|
799
|
+
# Format: "1 - Title" or "1 - Flowchart" or "Diagram 1"
|
|
800
|
+
if diagram.title:
|
|
801
|
+
diagram_content.append(f"{i} - {diagram.title}", style=f"bright_blue link {url}")
|
|
802
|
+
else:
|
|
803
|
+
# Try to detect diagram type, fallback to "Diagram N"
|
|
804
|
+
diagram_type = detect_diagram_type(diagram.content)
|
|
805
|
+
if diagram_type != "Diagram":
|
|
806
|
+
diagram_content.append(f"{i} - {diagram_type}", style=f"bright_blue link {url}")
|
|
807
|
+
else:
|
|
808
|
+
diagram_content.append(f"Diagram {i}", style=f"bright_blue link {url}")
|
|
809
|
+
|
|
810
|
+
# Display diagrams on a simple new line (more space efficient)
|
|
811
|
+
console.console.print()
|
|
812
|
+
console.console.print(diagram_content, markup=self._markup)
|
|
813
|
+
|
|
814
|
+
async def show_mcp_ui_links(self, links: list[UILink]) -> None:
|
|
815
|
+
"""Display MCP-UI links beneath the chat like mermaid links."""
|
|
816
|
+
if self.config and not self.config.logger.show_chat:
|
|
817
|
+
return
|
|
818
|
+
|
|
819
|
+
if not links:
|
|
820
|
+
return
|
|
821
|
+
|
|
822
|
+
content = Text()
|
|
823
|
+
content.append("● mcp-ui ", style="dim")
|
|
824
|
+
for i, link in enumerate(links, 1):
|
|
825
|
+
if i > 1:
|
|
826
|
+
content.append(" • ", style="dim")
|
|
827
|
+
# Prefer a web-friendly URL (http(s) or data:) if available; fallback to local file
|
|
828
|
+
url = link.web_url if getattr(link, "web_url", None) else f"file://{link.file_path}"
|
|
829
|
+
label = f"{i} - {link.title}"
|
|
830
|
+
content.append(label, style=f"bright_blue link {url}")
|
|
831
|
+
|
|
832
|
+
console.console.print()
|
|
833
|
+
console.console.print(content, markup=self._markup)
|
|
834
|
+
|
|
835
|
+
def show_user_message(
|
|
836
|
+
self,
|
|
837
|
+
message: Union[str, Text],
|
|
838
|
+
model: str | None = None,
|
|
839
|
+
chat_turn: int = 0,
|
|
840
|
+
name: str | None = None,
|
|
841
|
+
attachments: list[str] | None = None,
|
|
842
|
+
) -> None:
|
|
843
|
+
"""Display a user message in the new visual style."""
|
|
844
|
+
if self.config and not self.config.logger.show_chat:
|
|
845
|
+
return
|
|
846
|
+
|
|
847
|
+
# Build right side with model and turn
|
|
848
|
+
right_parts = []
|
|
849
|
+
if model:
|
|
850
|
+
right_parts.append(model)
|
|
851
|
+
if chat_turn > 0:
|
|
852
|
+
right_parts.append(f"turn {chat_turn}")
|
|
853
|
+
|
|
854
|
+
right_info = f"[dim]{' '.join(right_parts)}[/dim]" if right_parts else ""
|
|
855
|
+
|
|
856
|
+
# Build attachment indicator as pre_content
|
|
857
|
+
pre_content: Text | None = None
|
|
858
|
+
if attachments:
|
|
859
|
+
pre_content = Text()
|
|
860
|
+
pre_content.append("🔗 ", style="dim")
|
|
861
|
+
pre_content.append(", ".join(attachments), style="dim blue")
|
|
862
|
+
|
|
863
|
+
self.display_message(
|
|
864
|
+
content=message,
|
|
865
|
+
message_type=MessageType.USER,
|
|
866
|
+
name=name,
|
|
867
|
+
right_info=right_info,
|
|
868
|
+
truncate_content=False, # User messages typically shouldn't be truncated
|
|
869
|
+
pre_content=pre_content,
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
def show_system_message(
|
|
873
|
+
self,
|
|
874
|
+
system_prompt: str,
|
|
875
|
+
agent_name: str | None = None,
|
|
876
|
+
server_count: int = 0,
|
|
877
|
+
) -> None:
|
|
878
|
+
"""Display the system prompt in a formatted panel."""
|
|
879
|
+
if self.config and not self.config.logger.show_chat:
|
|
880
|
+
return
|
|
881
|
+
|
|
882
|
+
# Build right side info
|
|
883
|
+
right_parts = []
|
|
884
|
+
if server_count > 0:
|
|
885
|
+
server_word = "server" if server_count == 1 else "servers"
|
|
886
|
+
right_parts.append(f"{server_count} MCP {server_word}")
|
|
887
|
+
|
|
888
|
+
right_info = f"[dim]{' '.join(right_parts)}[/dim]" if right_parts else ""
|
|
889
|
+
|
|
890
|
+
self.display_message(
|
|
891
|
+
content=system_prompt,
|
|
892
|
+
message_type=MessageType.SYSTEM,
|
|
893
|
+
name=agent_name,
|
|
894
|
+
right_info=right_info,
|
|
895
|
+
truncate_content=False, # Don't truncate system prompts
|
|
896
|
+
)
|
|
897
|
+
|
|
898
|
+
async def show_prompt_loaded(
|
|
899
|
+
self,
|
|
900
|
+
prompt_name: str,
|
|
901
|
+
description: str | None = None,
|
|
902
|
+
message_count: int = 0,
|
|
903
|
+
agent_name: str | None = None,
|
|
904
|
+
server_list: list[str] | None = None,
|
|
905
|
+
highlight_server: str | None = None,
|
|
906
|
+
arguments: dict[str, str] | None = None,
|
|
907
|
+
) -> None:
|
|
908
|
+
"""
|
|
909
|
+
Display information about a loaded prompt template.
|
|
910
|
+
|
|
911
|
+
Args:
|
|
912
|
+
prompt_name: The name of the prompt that was loaded
|
|
913
|
+
description: Optional description of the prompt
|
|
914
|
+
message_count: Number of messages added to the conversation history
|
|
915
|
+
agent_name: Name of the agent using the prompt
|
|
916
|
+
server_list: Optional list of servers to display
|
|
917
|
+
highlight_server: Optional server name to highlight
|
|
918
|
+
arguments: Optional dictionary of arguments passed to the prompt template
|
|
919
|
+
"""
|
|
920
|
+
if self.config and not self.config.logger.show_tools:
|
|
921
|
+
return
|
|
922
|
+
|
|
923
|
+
# Build the server list with highlighting
|
|
924
|
+
display_server_list = Text()
|
|
925
|
+
if server_list:
|
|
926
|
+
for server_name in server_list:
|
|
927
|
+
style = "green" if server_name == highlight_server else "dim white"
|
|
928
|
+
display_server_list.append(f"[{server_name}] ", style)
|
|
929
|
+
|
|
930
|
+
# Create content text
|
|
931
|
+
content = Text()
|
|
932
|
+
messages_phrase = f"Loaded {message_count} message{'s' if message_count != 1 else ''}"
|
|
933
|
+
content.append(f"{messages_phrase} from template ", style="cyan italic")
|
|
934
|
+
content.append(f"'{prompt_name}'", style="cyan bold italic")
|
|
935
|
+
|
|
936
|
+
if agent_name:
|
|
937
|
+
content.append(f" for {agent_name}", style="cyan italic")
|
|
938
|
+
|
|
939
|
+
# Add template arguments if provided
|
|
940
|
+
if arguments:
|
|
941
|
+
content.append("\n\nArguments:", style="cyan")
|
|
942
|
+
for key, value in arguments.items():
|
|
943
|
+
content.append(f"\n {key}: ", style="cyan bold")
|
|
944
|
+
content.append(value, style="white")
|
|
945
|
+
|
|
946
|
+
if description:
|
|
947
|
+
content.append("\n\n", style="default")
|
|
948
|
+
content.append(description, style="dim white")
|
|
949
|
+
|
|
950
|
+
# Create panel
|
|
951
|
+
panel = Panel(
|
|
952
|
+
content,
|
|
953
|
+
title="[PROMPT LOADED]",
|
|
954
|
+
title_align="right",
|
|
955
|
+
style="cyan",
|
|
956
|
+
border_style="white",
|
|
957
|
+
padding=(1, 2),
|
|
958
|
+
subtitle=display_server_list,
|
|
959
|
+
subtitle_align="left",
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
console.console.print(panel, markup=self._markup)
|
|
963
|
+
console.console.print("\n")
|
|
964
|
+
|
|
965
|
+
def show_parallel_results(self, parallel_agent) -> None:
|
|
966
|
+
"""Display parallel agent results in a clean, organized format.
|
|
967
|
+
|
|
968
|
+
Args:
|
|
969
|
+
parallel_agent: The parallel agent containing fan_out_agents with results
|
|
970
|
+
"""
|
|
971
|
+
|
|
972
|
+
from rich.text import Text
|
|
973
|
+
|
|
974
|
+
if self.config and not self.config.logger.show_chat:
|
|
975
|
+
return
|
|
976
|
+
|
|
977
|
+
if not parallel_agent or not hasattr(parallel_agent, "fan_out_agents"):
|
|
978
|
+
return
|
|
979
|
+
|
|
980
|
+
# Collect results and agent information
|
|
981
|
+
agent_results = []
|
|
982
|
+
|
|
983
|
+
for agent in parallel_agent.fan_out_agents:
|
|
984
|
+
# Get the last response text from this agent
|
|
985
|
+
message_history = agent.message_history
|
|
986
|
+
if not message_history:
|
|
987
|
+
continue
|
|
988
|
+
|
|
989
|
+
last_message = message_history[-1]
|
|
990
|
+
content = last_message.last_text()
|
|
991
|
+
|
|
992
|
+
# Get model name
|
|
993
|
+
model = "unknown"
|
|
994
|
+
if agent.llm:
|
|
995
|
+
model = agent.llm.model_name or "unknown"
|
|
996
|
+
|
|
997
|
+
# Get usage information
|
|
998
|
+
tokens = 0
|
|
999
|
+
tool_calls = 0
|
|
1000
|
+
if hasattr(agent, "usage_accumulator") and agent.usage_accumulator:
|
|
1001
|
+
summary = agent.usage_accumulator.get_summary()
|
|
1002
|
+
tokens = summary.get("cumulative_input_tokens", 0) + summary.get(
|
|
1003
|
+
"cumulative_output_tokens", 0
|
|
1004
|
+
)
|
|
1005
|
+
tool_calls = summary.get("cumulative_tool_calls", 0)
|
|
1006
|
+
|
|
1007
|
+
agent_results.append(
|
|
1008
|
+
{
|
|
1009
|
+
"name": agent.name,
|
|
1010
|
+
"model": model,
|
|
1011
|
+
"content": content,
|
|
1012
|
+
"tokens": tokens,
|
|
1013
|
+
"tool_calls": tool_calls,
|
|
1014
|
+
}
|
|
1015
|
+
)
|
|
1016
|
+
|
|
1017
|
+
if not agent_results:
|
|
1018
|
+
return
|
|
1019
|
+
|
|
1020
|
+
# Display header
|
|
1021
|
+
console.console.print()
|
|
1022
|
+
console.console.print("[dim]Parallel execution complete[/dim]")
|
|
1023
|
+
console.console.print()
|
|
1024
|
+
|
|
1025
|
+
# Display results for each agent
|
|
1026
|
+
for i, result in enumerate(agent_results):
|
|
1027
|
+
if i > 0:
|
|
1028
|
+
# Simple full-width separator
|
|
1029
|
+
console.console.print()
|
|
1030
|
+
console.console.print("─" * console.console.size.width, style="dim")
|
|
1031
|
+
console.console.print()
|
|
1032
|
+
|
|
1033
|
+
# Two column header: model name (green) + usage info (dim)
|
|
1034
|
+
left = f"[green]▎[/green] [bold green]{result['model']}[/bold green]"
|
|
1035
|
+
|
|
1036
|
+
# Build right side with tokens and tool calls if available
|
|
1037
|
+
right_parts = []
|
|
1038
|
+
if result["tokens"] > 0:
|
|
1039
|
+
right_parts.append(f"{result['tokens']:,} tokens")
|
|
1040
|
+
if result["tool_calls"] > 0:
|
|
1041
|
+
right_parts.append(f"{result['tool_calls']} tools")
|
|
1042
|
+
|
|
1043
|
+
right = f"[dim]{' • '.join(right_parts) if right_parts else 'no usage data'}[/dim]"
|
|
1044
|
+
|
|
1045
|
+
# Calculate padding to right-align usage info
|
|
1046
|
+
width = console.console.size.width
|
|
1047
|
+
left_text = Text.from_markup(left)
|
|
1048
|
+
right_text = Text.from_markup(right)
|
|
1049
|
+
padding = max(1, width - left_text.cell_len - right_text.cell_len)
|
|
1050
|
+
|
|
1051
|
+
console.console.print(left + " " * padding + right, markup=self._markup)
|
|
1052
|
+
console.console.print()
|
|
1053
|
+
|
|
1054
|
+
# Display content based on its type (check for markdown markers in parallel results)
|
|
1055
|
+
content = result["content"]
|
|
1056
|
+
# Use _display_content with assistant message type so content isn't dimmed
|
|
1057
|
+
self._display_content(
|
|
1058
|
+
content,
|
|
1059
|
+
truncate=False,
|
|
1060
|
+
is_error=False,
|
|
1061
|
+
message_type=MessageType.ASSISTANT,
|
|
1062
|
+
check_markdown_markers=True,
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
# Summary
|
|
1066
|
+
console.console.print()
|
|
1067
|
+
console.console.print("─" * console.console.size.width, style="dim")
|
|
1068
|
+
|
|
1069
|
+
total_tokens = sum(result["tokens"] for result in agent_results)
|
|
1070
|
+
total_tools = sum(result["tool_calls"] for result in agent_results)
|
|
1071
|
+
|
|
1072
|
+
summary_parts = [f"{len(agent_results)} models"]
|
|
1073
|
+
if total_tokens > 0:
|
|
1074
|
+
summary_parts.append(f"{total_tokens:,} tokens")
|
|
1075
|
+
if total_tools > 0:
|
|
1076
|
+
summary_parts.append(f"{total_tools} tools")
|
|
1077
|
+
|
|
1078
|
+
summary_text = " • ".join(summary_parts)
|
|
1079
|
+
console.console.print(f"[dim]{summary_text}[/dim]")
|
|
1080
|
+
console.console.print()
|