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,235 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import platform
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
import webbrowser
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Iterable
|
|
11
|
+
|
|
12
|
+
from mcp.types import BlobResourceContents, EmbeddedResource, TextResourceContents
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
Utilities for handling MCP-UI resources carried in PromptMessageExtended.channels.
|
|
16
|
+
|
|
17
|
+
Responsibilities:
|
|
18
|
+
- Identify MCP-UI EmbeddedResources from channels
|
|
19
|
+
- Decode text/blob content depending on mimeType
|
|
20
|
+
- Produce local HTML files that safely embed the UI content (srcdoc or iframe)
|
|
21
|
+
- Return presentable link labels for console display
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# Control whether to generate data URLs for embedded HTML content
|
|
25
|
+
# When disabled, always use file:// URLs which work better with most terminals
|
|
26
|
+
ENABLE_DATA_URLS = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class UILink:
|
|
31
|
+
title: str
|
|
32
|
+
file_path: str # absolute path to local html file
|
|
33
|
+
web_url: str | None = None # Preferable clickable link (http(s) or data URL)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _safe_filename(name: str) -> str:
|
|
37
|
+
name = re.sub(r"[^A-Za-z0-9_.-]", "_", name)
|
|
38
|
+
return name[:120] if len(name) > 120 else name
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _ensure_output_dir() -> Path:
|
|
42
|
+
# Read output directory from settings, defaulting to .fast-agent/ui
|
|
43
|
+
try:
|
|
44
|
+
from fast_agent.config import get_settings
|
|
45
|
+
|
|
46
|
+
settings = get_settings()
|
|
47
|
+
dir_setting = getattr(settings, "mcp_ui_output_dir", ".fast-agent/ui") or ".fast-agent/ui"
|
|
48
|
+
except Exception:
|
|
49
|
+
dir_setting = ".fast-agent/ui"
|
|
50
|
+
|
|
51
|
+
base = Path(dir_setting)
|
|
52
|
+
if not base.is_absolute():
|
|
53
|
+
base = Path.cwd() / base
|
|
54
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
return base
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_title(uri: str | None) -> str:
|
|
59
|
+
if not uri:
|
|
60
|
+
return "UI"
|
|
61
|
+
try:
|
|
62
|
+
# ui://component/instance -> component:instance
|
|
63
|
+
without_scheme = uri.split("ui://", 1)[1] if uri.startswith("ui://") else uri
|
|
64
|
+
parts = [p for p in re.split(r"[/:]", without_scheme) if p]
|
|
65
|
+
if len(parts) >= 2:
|
|
66
|
+
return f"{parts[0]}:{parts[1]}"
|
|
67
|
+
return parts[0] if parts else "UI"
|
|
68
|
+
except Exception:
|
|
69
|
+
return "UI"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _decode_text_or_blob(resource) -> str | None:
|
|
73
|
+
"""Return string content from TextResourceContents or BlobResourceContents."""
|
|
74
|
+
if isinstance(resource, TextResourceContents):
|
|
75
|
+
return resource.text or ""
|
|
76
|
+
if isinstance(resource, BlobResourceContents):
|
|
77
|
+
try:
|
|
78
|
+
return base64.b64decode(resource.blob or "").decode("utf-8", errors="replace")
|
|
79
|
+
except Exception:
|
|
80
|
+
return None
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _first_https_url_from_uri_list(text: str) -> str | None:
|
|
85
|
+
for line in text.splitlines():
|
|
86
|
+
line = line.strip()
|
|
87
|
+
if not line or line.startswith("#"):
|
|
88
|
+
continue
|
|
89
|
+
if line.startswith("http://") or line.startswith("https://"):
|
|
90
|
+
return line
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _make_html_for_raw_html(html_string: str) -> str:
|
|
95
|
+
# Wrap with minimal HTML and sandbox guidance (iframe srcdoc will be used by browsers)
|
|
96
|
+
return html_string
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _make_html_for_uri(url: str) -> str:
|
|
100
|
+
return f"""
|
|
101
|
+
<!doctype html>
|
|
102
|
+
<html>
|
|
103
|
+
<head>
|
|
104
|
+
<meta charset=\"utf-8\" />
|
|
105
|
+
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
|
106
|
+
<title>MCP-UI</title>
|
|
107
|
+
<style>html,body,iframe{{margin:0;padding:0;height:100%;width:100%;border:0}}</style>
|
|
108
|
+
</head>
|
|
109
|
+
<body>
|
|
110
|
+
<iframe src=\"{url}\" sandbox=\"allow-scripts allow-forms allow-same-origin\" referrerpolicy=\"no-referrer\"></iframe>
|
|
111
|
+
</body>
|
|
112
|
+
</html>
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _write_html_file(name_hint: str, html: str) -> str:
|
|
117
|
+
out_dir = _ensure_output_dir()
|
|
118
|
+
file_name = _safe_filename(name_hint or "ui") + ".html"
|
|
119
|
+
out_path = out_dir / file_name
|
|
120
|
+
# Ensure unique filename if exists
|
|
121
|
+
i = 1
|
|
122
|
+
while out_path.exists():
|
|
123
|
+
out_path = out_dir / f"{_safe_filename(name_hint)}_{i}.html"
|
|
124
|
+
i += 1
|
|
125
|
+
out_path.write_text(html, encoding="utf-8")
|
|
126
|
+
return str(out_path.resolve())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def ui_links_from_channel(resources: Iterable[EmbeddedResource]) -> list[UILink]:
|
|
130
|
+
"""
|
|
131
|
+
Build local HTML files for a list of MCP-UI EmbeddedResources and return clickable links.
|
|
132
|
+
|
|
133
|
+
Supported mime types:
|
|
134
|
+
- text/html: expects text or base64 blob of HTML
|
|
135
|
+
- text/uri-list: expects text or blob of a single URL (first valid URL is used)
|
|
136
|
+
- application/vnd.mcp-ui.remote-dom* : currently unsupported; generate a placeholder page
|
|
137
|
+
"""
|
|
138
|
+
links: list[UILink] = []
|
|
139
|
+
for emb in resources:
|
|
140
|
+
res = emb.resource
|
|
141
|
+
uri = str(getattr(res, "uri", "")) if getattr(res, "uri", None) else None
|
|
142
|
+
mime = getattr(res, "mimeType", "") or ""
|
|
143
|
+
title = _extract_title(uri)
|
|
144
|
+
content = _decode_text_or_blob(res)
|
|
145
|
+
|
|
146
|
+
if mime.startswith("text/html"):
|
|
147
|
+
if content is None:
|
|
148
|
+
continue
|
|
149
|
+
html = _make_html_for_raw_html(content)
|
|
150
|
+
file_path = _write_html_file(title, html)
|
|
151
|
+
# Generate data URL only if enabled
|
|
152
|
+
if ENABLE_DATA_URLS:
|
|
153
|
+
try:
|
|
154
|
+
b64 = base64.b64encode(html.encode("utf-8")).decode("ascii")
|
|
155
|
+
data_url = f"data:text/html;base64,{b64}"
|
|
156
|
+
# Some terminals have limits; only attach when reasonably small
|
|
157
|
+
web_url = data_url if len(data_url) < 12000 else None
|
|
158
|
+
except Exception:
|
|
159
|
+
web_url = None
|
|
160
|
+
else:
|
|
161
|
+
web_url = None
|
|
162
|
+
links.append(UILink(title=title, file_path=file_path, web_url=web_url))
|
|
163
|
+
|
|
164
|
+
elif mime.startswith("text/uri-list"):
|
|
165
|
+
if content is None:
|
|
166
|
+
continue
|
|
167
|
+
url = _first_https_url_from_uri_list(content)
|
|
168
|
+
if not url:
|
|
169
|
+
# fallback: try to treat entire content as a URL
|
|
170
|
+
url = content.strip()
|
|
171
|
+
if not (url and (url.startswith("http://") or url.startswith("https://"))):
|
|
172
|
+
continue
|
|
173
|
+
html = _make_html_for_uri(url)
|
|
174
|
+
file_path = _write_html_file(title, html)
|
|
175
|
+
# Prefer the direct URL for clickability; keep file for archival
|
|
176
|
+
links.append(UILink(title=title, file_path=file_path, web_url=url))
|
|
177
|
+
|
|
178
|
+
elif mime.startswith("application/vnd.mcp-ui.remote-dom"):
|
|
179
|
+
# Not supported yet - generate informational page
|
|
180
|
+
placeholder = f"""
|
|
181
|
+
<!doctype html>
|
|
182
|
+
<html><head><meta charset=\"utf-8\" /><title>{title} (Unsupported)</title></head>
|
|
183
|
+
<body>
|
|
184
|
+
<p>Remote DOM resources are not supported yet in this client.</p>
|
|
185
|
+
<p>URI: {uri or ""}</p>
|
|
186
|
+
<p>mimeType: {mime}</p>
|
|
187
|
+
<pre style=\"white-space: pre-wrap;\">{(content or "")[:4000]}</pre>
|
|
188
|
+
<p>Please upgrade fast-agent when support becomes available.</p>
|
|
189
|
+
</body></html>
|
|
190
|
+
"""
|
|
191
|
+
file_path = _write_html_file(title + "_unsupported", placeholder)
|
|
192
|
+
links.append(UILink(title=title + " (unsupported)", file_path=file_path))
|
|
193
|
+
else:
|
|
194
|
+
# Unknown, skip quietly
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
return links
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def open_links_in_browser(links: Iterable[UILink], mcp_ui_mode: str = "auto") -> None:
|
|
201
|
+
"""Open links in browser/system viewer.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
links: Links to open
|
|
205
|
+
mcp_ui_mode: UI mode setting ("disabled", "enabled", "auto")
|
|
206
|
+
"""
|
|
207
|
+
# Only attempt to open files when in auto mode
|
|
208
|
+
if mcp_ui_mode != "auto":
|
|
209
|
+
return
|
|
210
|
+
|
|
211
|
+
for link in links:
|
|
212
|
+
try:
|
|
213
|
+
# Use subprocess for better file:// handling across platforms
|
|
214
|
+
file_path = link.file_path
|
|
215
|
+
|
|
216
|
+
system = platform.system()
|
|
217
|
+
if system == "Darwin": # macOS
|
|
218
|
+
subprocess.run(["open", file_path], check=False, capture_output=True)
|
|
219
|
+
elif system == "Windows":
|
|
220
|
+
subprocess.run(
|
|
221
|
+
["start", "", file_path], shell=True, check=False, capture_output=True
|
|
222
|
+
)
|
|
223
|
+
elif system == "Linux":
|
|
224
|
+
# Try xdg-open first (most common), fallback to other options
|
|
225
|
+
try:
|
|
226
|
+
subprocess.run(["xdg-open", file_path], check=False, capture_output=True)
|
|
227
|
+
except FileNotFoundError:
|
|
228
|
+
# Fallback to webbrowser for Linux if xdg-open not available
|
|
229
|
+
webbrowser.open(f"file://{file_path}", new=2)
|
|
230
|
+
else:
|
|
231
|
+
# Unknown system, fallback to webbrowser
|
|
232
|
+
webbrowser.open(f"file://{file_path}", new=2)
|
|
233
|
+
except Exception:
|
|
234
|
+
# Silently ignore errors - user can still manually open the file
|
|
235
|
+
pass
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Utilities for detecting and processing Mermaid diagrams in text content."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
import zlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
# Mermaid chart viewer URL prefix
|
|
9
|
+
MERMAID_VIEWER_URL = "https://www.mermaidchart.com/play#"
|
|
10
|
+
# mermaid.live#pako= also works but the playground has better ux
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class MermaidDiagram:
|
|
15
|
+
"""Represents a detected Mermaid diagram."""
|
|
16
|
+
|
|
17
|
+
content: str
|
|
18
|
+
title: str | None = None
|
|
19
|
+
start_pos: int = 0
|
|
20
|
+
end_pos: int = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def extract_mermaid_diagrams(text: str) -> list[MermaidDiagram]:
|
|
24
|
+
"""
|
|
25
|
+
Extract all Mermaid diagram blocks from text content.
|
|
26
|
+
|
|
27
|
+
Handles both simple mermaid blocks and blocks with titles:
|
|
28
|
+
- ```mermaid
|
|
29
|
+
- ```mermaid title={Some Title}
|
|
30
|
+
|
|
31
|
+
Also extracts titles from within the diagram content.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
text: The text content to search for Mermaid diagrams
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
List of MermaidDiagram objects found in the text
|
|
38
|
+
"""
|
|
39
|
+
diagrams = []
|
|
40
|
+
|
|
41
|
+
# Pattern to match mermaid code blocks with optional title
|
|
42
|
+
# Matches: ```mermaid or ```mermaid title={...}
|
|
43
|
+
pattern = r"```mermaid(?:\s+title=\{([^}]+)\})?\s*\n(.*?)```"
|
|
44
|
+
|
|
45
|
+
for match in re.finditer(pattern, text, re.DOTALL):
|
|
46
|
+
title = match.group(1) # May be None if no title
|
|
47
|
+
content = match.group(2).strip()
|
|
48
|
+
|
|
49
|
+
if content: # Only add if there's actual diagram content
|
|
50
|
+
# If no title from code fence, look for title in the content
|
|
51
|
+
if not title:
|
|
52
|
+
# Look for various title patterns in mermaid diagrams
|
|
53
|
+
# pie title, graph title, etc.
|
|
54
|
+
title_patterns = [
|
|
55
|
+
r"^\s*title\s+(.+?)(?:\n|$)", # Generic title
|
|
56
|
+
r"^\s*pie\s+title\s+(.+?)(?:\n|$)", # Pie chart title
|
|
57
|
+
r"^\s*gantt\s+title\s+(.+?)(?:\n|$)", # Gantt chart title
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
for title_pattern in title_patterns:
|
|
61
|
+
title_match = re.search(title_pattern, content, re.MULTILINE)
|
|
62
|
+
if title_match:
|
|
63
|
+
title = title_match.group(1).strip()
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
diagrams.append(
|
|
67
|
+
MermaidDiagram(
|
|
68
|
+
content=content, title=title, start_pos=match.start(), end_pos=match.end()
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return diagrams
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def create_mermaid_live_link(diagram_content: str) -> str:
|
|
76
|
+
"""
|
|
77
|
+
Create a Mermaid Live Editor link from diagram content.
|
|
78
|
+
|
|
79
|
+
The link uses pako compression (zlib) and base64 encoding.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
diagram_content: The Mermaid diagram source code
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
Complete URL to Mermaid Live Editor
|
|
86
|
+
"""
|
|
87
|
+
# Create the JSON structure expected by Mermaid Live
|
|
88
|
+
# Escape newlines and quotes in the diagram content
|
|
89
|
+
escaped_content = diagram_content.replace('"', '\\"').replace("\n", "\\n")
|
|
90
|
+
json_str = f'{{"code":"{escaped_content}","mermaid":{{"theme":"default"}},"updateEditor":false,"autoSync":true,"updateDiagram":false}}'
|
|
91
|
+
|
|
92
|
+
# Compress using zlib (pako compatible)
|
|
93
|
+
compressed = zlib.compress(json_str.encode("utf-8"))
|
|
94
|
+
|
|
95
|
+
# Base64 encode
|
|
96
|
+
encoded = base64.urlsafe_b64encode(compressed).decode("utf-8")
|
|
97
|
+
|
|
98
|
+
# Remove padding characters as Mermaid Live doesn't use them
|
|
99
|
+
encoded = encoded.rstrip("=")
|
|
100
|
+
|
|
101
|
+
return f"{MERMAID_VIEWER_URL}pako:{encoded}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def format_mermaid_links(diagrams: list[MermaidDiagram]) -> list[str]:
|
|
105
|
+
"""
|
|
106
|
+
Format Mermaid diagrams as markdown links.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
diagrams: List of MermaidDiagram objects
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
List of formatted markdown strings
|
|
113
|
+
"""
|
|
114
|
+
links = []
|
|
115
|
+
|
|
116
|
+
for i, diagram in enumerate(diagrams, 1):
|
|
117
|
+
link = create_mermaid_live_link(diagram.content)
|
|
118
|
+
|
|
119
|
+
if diagram.title:
|
|
120
|
+
# Use the title from the diagram with number
|
|
121
|
+
markdown = f"Diagram {i} - {diagram.title}: [Open Diagram]({link})"
|
|
122
|
+
else:
|
|
123
|
+
# Use generic numbering
|
|
124
|
+
markdown = f"Diagram {i}: [Open Diagram]({link})"
|
|
125
|
+
|
|
126
|
+
links.append(markdown)
|
|
127
|
+
|
|
128
|
+
return links
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def detect_diagram_type(content: str) -> str:
|
|
132
|
+
"""
|
|
133
|
+
Detect the type of mermaid diagram from content.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
content: The mermaid diagram source code
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Human-readable diagram type name
|
|
140
|
+
"""
|
|
141
|
+
content_lower = content.strip().lower()
|
|
142
|
+
|
|
143
|
+
# Check for common diagram types
|
|
144
|
+
if content_lower.startswith(("graph ", "flowchart ")):
|
|
145
|
+
return "Flowchart"
|
|
146
|
+
elif content_lower.startswith("sequencediagram"):
|
|
147
|
+
return "Sequence"
|
|
148
|
+
elif content_lower.startswith("pie"):
|
|
149
|
+
return "Pie Chart"
|
|
150
|
+
elif content_lower.startswith("gantt"):
|
|
151
|
+
return "Gantt Chart"
|
|
152
|
+
elif content_lower.startswith("classdiagram"):
|
|
153
|
+
return "Class Diagram"
|
|
154
|
+
elif content_lower.startswith("statediagram"):
|
|
155
|
+
return "State Diagram"
|
|
156
|
+
elif content_lower.startswith("erdiagram"):
|
|
157
|
+
return "ER Diagram"
|
|
158
|
+
elif content_lower.startswith("journey"):
|
|
159
|
+
return "User Journey"
|
|
160
|
+
elif content_lower.startswith("gitgraph"):
|
|
161
|
+
return "Git Graph"
|
|
162
|
+
elif content_lower.startswith("c4context"):
|
|
163
|
+
return "C4 Context"
|
|
164
|
+
elif content_lower.startswith("mindmap"):
|
|
165
|
+
return "Mind Map"
|
|
166
|
+
elif content_lower.startswith("timeline"):
|
|
167
|
+
return "Timeline"
|
|
168
|
+
else:
|
|
169
|
+
return "Diagram"
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class MessageType(Enum):
|
|
7
|
+
"""Types of messages that can be displayed."""
|
|
8
|
+
|
|
9
|
+
USER = "user"
|
|
10
|
+
ASSISTANT = "assistant"
|
|
11
|
+
SYSTEM = "system"
|
|
12
|
+
TOOL_CALL = "tool_call"
|
|
13
|
+
TOOL_RESULT = "tool_result"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
MESSAGE_CONFIGS: dict[MessageType, dict[str, str]] = {
|
|
17
|
+
MessageType.USER: {
|
|
18
|
+
"block_color": "blue",
|
|
19
|
+
"arrow": "▶",
|
|
20
|
+
"arrow_style": "dim blue",
|
|
21
|
+
"highlight_color": "blue",
|
|
22
|
+
},
|
|
23
|
+
MessageType.ASSISTANT: {
|
|
24
|
+
"block_color": "green",
|
|
25
|
+
"arrow": "◀",
|
|
26
|
+
"arrow_style": "dim green",
|
|
27
|
+
"highlight_color": "bright_green",
|
|
28
|
+
},
|
|
29
|
+
MessageType.SYSTEM: {
|
|
30
|
+
"block_color": "yellow",
|
|
31
|
+
"arrow": "●",
|
|
32
|
+
"arrow_style": "dim yellow",
|
|
33
|
+
"highlight_color": "bright_yellow",
|
|
34
|
+
},
|
|
35
|
+
MessageType.TOOL_CALL: {
|
|
36
|
+
"block_color": "magenta",
|
|
37
|
+
"arrow": "◀",
|
|
38
|
+
"arrow_style": "dim magenta",
|
|
39
|
+
"highlight_color": "magenta",
|
|
40
|
+
},
|
|
41
|
+
MessageType.TOOL_RESULT: {
|
|
42
|
+
"block_color": "magenta",
|
|
43
|
+
"arrow": "▶",
|
|
44
|
+
"arrow_style": "dim magenta",
|
|
45
|
+
"highlight_color": "magenta",
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ["MessageType", "MESSAGE_CONFIGS"]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced notification tracker for prompt_toolkit toolbar display.
|
|
3
|
+
Tracks both active events (sampling/elicitation) and completed notifications.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
# Display metadata for toolbar summaries (singular, plural, compact label)
|
|
9
|
+
_EVENT_ORDER = ("tool_update", "sampling", "elicitation")
|
|
10
|
+
_EVENT_DISPLAY = {
|
|
11
|
+
"tool_update": {"singular": "tool update", "plural": "tool updates", "compact": "tool"},
|
|
12
|
+
"sampling": {"singular": "sample", "plural": "samples", "compact": "samp"},
|
|
13
|
+
"elicitation": {"singular": "elicitation", "plural": "elicitations", "compact": "elic"},
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# Active events currently in progress
|
|
17
|
+
active_events: dict[str, dict[str, str]] = {}
|
|
18
|
+
|
|
19
|
+
# Completed notifications history
|
|
20
|
+
notifications: list[dict[str, str]] = []
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def add_tool_update(server_name: str) -> None:
|
|
24
|
+
"""Add a tool update notification.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
server_name: Name of the server that had tools updated
|
|
28
|
+
"""
|
|
29
|
+
notifications.append({
|
|
30
|
+
'type': 'tool_update',
|
|
31
|
+
'server': server_name
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def start_sampling(server_name: str) -> None:
|
|
36
|
+
"""Start tracking a sampling operation.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
server_name: Name of the server making the sampling request
|
|
40
|
+
"""
|
|
41
|
+
active_events['sampling'] = {
|
|
42
|
+
'server': server_name,
|
|
43
|
+
'start_time': datetime.now().isoformat()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Force prompt_toolkit to redraw if active
|
|
47
|
+
try:
|
|
48
|
+
from prompt_toolkit.application.current import get_app
|
|
49
|
+
get_app().invalidate()
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def end_sampling(server_name: str) -> None:
|
|
55
|
+
"""End tracking a sampling operation and add to completed notifications.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
server_name: Name of the server that made the sampling request
|
|
59
|
+
"""
|
|
60
|
+
if 'sampling' in active_events:
|
|
61
|
+
del active_events['sampling']
|
|
62
|
+
|
|
63
|
+
notifications.append({
|
|
64
|
+
'type': 'sampling',
|
|
65
|
+
'server': server_name
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
# Force prompt_toolkit to redraw if active
|
|
69
|
+
try:
|
|
70
|
+
from prompt_toolkit.application.current import get_app
|
|
71
|
+
get_app().invalidate()
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def start_elicitation(server_name: str) -> None:
|
|
77
|
+
"""Start tracking an elicitation operation.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
server_name: Name of the server making the elicitation request
|
|
81
|
+
"""
|
|
82
|
+
active_events['elicitation'] = {
|
|
83
|
+
'server': server_name,
|
|
84
|
+
'start_time': datetime.now().isoformat()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Force prompt_toolkit to redraw if active
|
|
88
|
+
try:
|
|
89
|
+
from prompt_toolkit.application.current import get_app
|
|
90
|
+
get_app().invalidate()
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def end_elicitation(server_name: str) -> None:
|
|
96
|
+
"""End tracking an elicitation operation and add to completed notifications.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
server_name: Name of the server that made the elicitation request
|
|
100
|
+
"""
|
|
101
|
+
if 'elicitation' in active_events:
|
|
102
|
+
del active_events['elicitation']
|
|
103
|
+
|
|
104
|
+
notifications.append({
|
|
105
|
+
'type': 'elicitation',
|
|
106
|
+
'server': server_name
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
# Force prompt_toolkit to redraw if active
|
|
110
|
+
try:
|
|
111
|
+
from prompt_toolkit.application.current import get_app
|
|
112
|
+
get_app().invalidate()
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_active_status() -> dict[str, str] | None:
|
|
118
|
+
"""Get currently active operation, if any.
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Dict with 'type' and 'server' keys, or None if nothing active
|
|
122
|
+
"""
|
|
123
|
+
if 'sampling' in active_events:
|
|
124
|
+
return {'type': 'sampling', 'server': active_events['sampling']['server']}
|
|
125
|
+
if 'elicitation' in active_events:
|
|
126
|
+
return {'type': 'elicitation', 'server': active_events['elicitation']['server']}
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def clear() -> None:
|
|
131
|
+
"""Clear all notifications and active events."""
|
|
132
|
+
notifications.clear()
|
|
133
|
+
active_events.clear()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_count() -> int:
|
|
137
|
+
"""Get the current completed notification count."""
|
|
138
|
+
return len(notifications)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_latest() -> dict[str, str] | None:
|
|
142
|
+
"""Get the most recent completed notification."""
|
|
143
|
+
return notifications[-1] if notifications else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_counts_by_type() -> dict[str, int]:
|
|
147
|
+
"""Aggregate completed notifications by event type."""
|
|
148
|
+
counts: dict[str, int] = {}
|
|
149
|
+
for notification in notifications:
|
|
150
|
+
event_type = notification['type']
|
|
151
|
+
counts[event_type] = counts.get(event_type, 0) + 1
|
|
152
|
+
|
|
153
|
+
if not counts:
|
|
154
|
+
return {}
|
|
155
|
+
|
|
156
|
+
ordered: dict[str, int] = {}
|
|
157
|
+
for event_type in _EVENT_ORDER:
|
|
158
|
+
if event_type in counts:
|
|
159
|
+
ordered[event_type] = counts[event_type]
|
|
160
|
+
|
|
161
|
+
for event_type, count in counts.items():
|
|
162
|
+
if event_type not in ordered:
|
|
163
|
+
ordered[event_type] = count
|
|
164
|
+
|
|
165
|
+
return ordered
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def format_event_label(event_type: str, count: int, *, compact: bool = False) -> str:
|
|
169
|
+
"""Format a human-readable label for an event count."""
|
|
170
|
+
event_display = _EVENT_DISPLAY.get(event_type)
|
|
171
|
+
|
|
172
|
+
if event_display is None:
|
|
173
|
+
base = event_type.replace('_', ' ')
|
|
174
|
+
if compact:
|
|
175
|
+
return f"{base[:1]}:{count}"
|
|
176
|
+
label = base if count == 1 else f"{base}s"
|
|
177
|
+
return f"{count} {label}"
|
|
178
|
+
|
|
179
|
+
if compact:
|
|
180
|
+
return f"{event_display['compact']}:{count}"
|
|
181
|
+
|
|
182
|
+
label = event_display['singular'] if count == 1 else event_display['plural']
|
|
183
|
+
return f"{count} {label}"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_summary(*, compact: bool = False) -> str:
|
|
187
|
+
"""Get a summary of completed notifications by type.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
compact: When True, use short-form labels for constrained UI areas.
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
String like "3 tool updates, 2 samples" or "tool:3 samp:2" when compact.
|
|
194
|
+
"""
|
|
195
|
+
counts = get_counts_by_type()
|
|
196
|
+
if not counts:
|
|
197
|
+
return ""
|
|
198
|
+
|
|
199
|
+
parts = [
|
|
200
|
+
format_event_label(event_type, count, compact=compact)
|
|
201
|
+
for event_type, count in counts.items()
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
separator = " " if compact else ", "
|
|
205
|
+
return separator.join(parts)
|